diff --git a/CHANGELOG.md b/CHANGELOG.md index 541a16c7ba4..d9bbbe919be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Factory/Droid: add token-rate-limit billing windows, Core fallback buckets, and extra usage balance display (#878). Thanks @dantemoon1! - Usage pace: compute pace for any explicit reset window instead of a provider allowlist (#875). Thanks @ViperThanks! - Crof: add API-key provider support with request quota and credit balance tracking (#872). Thanks @baanish! +- Doubao: add API-key provider support for Volcengine Ark request-limit tracking (#498). Thanks @LeoLin990405! - Command Code: add browser-cookie provider support for monthly USD billing credits (#857). Thanks @sixhobbits! - StepFun: add username/password or Oasis-Token provider support for Step Plan rate-limit tracking (#815). Thanks @tevenfeng! - OpenRouter, Mistral, and Kimi K2: show balance/spend metrics in menu bar text when quota percentage is not useful (#853). Thanks @willytop8! diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift new file mode 100644 index 00000000000..fe974bed896 --- /dev/null +++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift @@ -0,0 +1,42 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct DoubaoProviderImplementation: ProviderImplementation { + let id: UsageProvider = .doubao + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.doubaoAPIToken + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "doubao-api-token", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine " + + "Ark console.", + kind: .secure, + placeholder: "ark-...", + binding: context.stringBinding(\.doubaoAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "doubao-open-dashboard", + title: "Open Volcengine Ark Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.volcengine.com/ark/") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift new file mode 100644 index 00000000000..4d69a273f12 --- /dev/null +++ b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var doubaoAPIToken: String { + get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .doubao) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .doubao, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 0125a298000..d30f9bcae66 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -39,6 +39,7 @@ enum ProviderImplementationRegistry { case .warp: WarpProviderImplementation() case .windsurf: WindsurfProviderImplementation() case .perplexity: PerplexityProviderImplementation() + case .doubao: DoubaoProviderImplementation() case .abacus: AbacusProviderImplementation() case .mistral: MistralProviderImplementation() case .deepseek: DeepSeekProviderImplementation() diff --git a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg new file mode 100644 index 00000000000..9c20430a1cd --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg @@ -0,0 +1 @@ +Doubao diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index c00798fd9e4..095de632406 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -897,6 +897,7 @@ extension UsageStore { .kimi: "Kimi debug log not yet implemented", .kimik2: "Kimi K2 debug log not yet implemented", .jetbrains: "JetBrains AI debug log not yet implemented", + .doubao: "Doubao debug log not yet implemented", .venice: "Venice debug log not yet implemented", .commandcode: "Command Code debug log not yet implemented", .stepfun: "StepFun debug log not yet implemented", @@ -973,8 +974,8 @@ extension UsageStore { hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains, .perplexity, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, - .commandcode, .stepfun: + .kimik2, .jetbrains, .perplexity, .doubao, .abacus, .mistral, .codebuff, .crof, .windsurf, + .venice, .commandcode, .stepfun: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 446dc98e630..65c76d54695 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -181,6 +181,8 @@ struct TokenAccountCLIContext { perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .doubao: + return nil case .abacus: return self.makeSnapshot( abacus: ProviderSettingsSnapshot.AbacusProviderSettings( diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index a58ad85ac4c..10198e53d13 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -46,6 +46,10 @@ public enum ProviderConfigEnvironment { { env[key] = apiKey } + case .doubao: + if let key = DoubaoSettingsReader.apiKeyEnvironmentKeys.first { + env[key] = apiKey + } default: break } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 3a510f957b1..e96dbe4b284 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -24,6 +24,7 @@ public enum LogCategories { public static let cursorLogin = "cursor-login" public static let deepSeekSettings = "deepseek-settings" public static let deepSeekUsage = "deepseek-usage" + public static let doubaoUsage = "doubao-usage" public static let geminiProbe = "gemini-probe" public static let keychainCache = "keychain-cache" public static let keychainMigration = "keychain-migration" diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift new file mode 100644 index 00000000000..69fb57c894d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -0,0 +1,69 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DoubaoProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .doubao, + metadata: ProviderMetadata( + id: .doubao, + displayName: "Doubao", + sessionLabel: "Requests", + weeklyLabel: "Rate limit", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Doubao usage", + cliName: "doubao", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=subscribe", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .doubao, + iconResourceName: "ProviderIcon-doubao", + color: ProviderColor(red: 51 / 255, green: 112 / 255, blue: 255 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Doubao cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DoubaoAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "doubao", + aliases: ["volcengine", "ark", "bytedance"], + versionDetector: nil)) + } +} + +struct DoubaoAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "doubao.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw DoubaoUsageError.missingCredentials + } + let usage = try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.doubaoToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift new file mode 100644 index 00000000000..43568d4a56b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift @@ -0,0 +1,37 @@ +import Foundation + +public struct DoubaoSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "ARK_API_KEY", + "VOLCENGINE_API_KEY", + "DOUBAO_API_KEY", + ] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift new file mode 100644 index 00000000000..617791e66db --- /dev/null +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -0,0 +1,290 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct DoubaoUsageSnapshot: Sendable { + public let remainingRequests: Int + public let limitRequests: Int + public let resetTime: Date? + public let updatedAt: Date + public let apiKeyValid: Bool + public let totalTokens: Int? + public init( + remainingRequests: Int, + limitRequests: Int, + resetTime: Date?, + updatedAt: Date, + apiKeyValid: Bool = false, + totalTokens: Int? = nil) + { + self.remainingRequests = remainingRequests + self.limitRequests = limitRequests + self.resetTime = resetTime + self.updatedAt = updatedAt + self.apiKeyValid = apiKeyValid + self.totalTokens = totalTokens + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double + let resetDescription: String + + if self.limitRequests > 0 { + let used = max(0, self.limitRequests - self.remainingRequests) + usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100)) + resetDescription = "\(used)/\(self.limitRequests) requests" + } else if self.apiKeyValid { + usedPercent = 0 + resetDescription = "Active - check dashboard for details" + } else { + usedPercent = 0 + resetDescription = "No usage data" + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.resetTime, + resetDescription: resetDescription) + + let identity = ProviderIdentitySnapshot( + providerID: .doubao, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public enum DoubaoUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(Int, String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Doubao API key (ARK_API_KEY)." + case let .networkError(message): + "Doubao network error: \(message)" + case let .apiError(code, message): + "Doubao API error (\(code)): \(message)" + case let .parseFailed(message): + "Failed to parse Doubao response: \(message)" + } + } +} + +public struct DoubaoUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.doubaoUsage) + private static let apiURL = URL(string: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions")! + + /// Models to probe, ordered by likelihood. We try multiple models because + /// different key types may not have access to every model. + private static let probeModels = [ + "doubao-seed-2.0-code", + "doubao-1.5-pro-32k", + "doubao-lite-32k", + ] + + public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DoubaoUsageError.missingCredentials + } + + var lastError: Error? + for model in self.probeModels { + do { + return try await self.probe(apiKey: apiKey, model: model) + } catch let error as DoubaoUsageError { + if case let .apiError(code, _) = error, code == 404 || code == 403 { + Self.log.debug("Doubao probe model \(model) unavailable (\(code)), trying next") + lastError = error + continue + } + throw error + } + } + throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed") + } + + private static func probe(apiKey: String, model: String) async throws -> DoubaoUsageSnapshot { + var request = URLRequest(url: self.apiURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let body: [String: Any] = [ + "model": model, + "max_tokens": 1, + "messages": [ + ["role": "user", "content": "hi"], + ] as [[String: Any]], + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw DoubaoUsageError.networkError("Invalid response") + } + + // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers. + guard httpResponse.statusCode == 200 || httpResponse.statusCode == 429 else { + let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data) + Self.log.error("Doubao API returned \(httpResponse.statusCode): \(summary)") + throw DoubaoUsageError.apiError(httpResponse.statusCode, summary) + } + + let headers = httpResponse.allHeaderFields + let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests") + let limit = Self.intHeader(headers, "x-ratelimit-limit-requests") + let resetString = Self.stringHeader(headers, "x-ratelimit-reset-requests") + + let resetTime: Date? = resetString.flatMap(Self.parseResetTime) + + var totalTokens: Int? + if remaining == nil, limit == nil, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let usage = json["usage"] as? [String: Any] + { + totalTokens = usage["total_tokens"] as? Int + } + + // 429 means the key is valid but rate-limited; treat it as valid so the UI + // shows "Active" instead of "No usage data" when headers are absent. + let keyValid = httpResponse.statusCode == 200 || httpResponse.statusCode == 429 + + let snapshot = DoubaoUsageSnapshot( + remainingRequests: remaining ?? 0, + limitRequests: limit ?? 0, + resetTime: resetTime, + updatedAt: Date(), + apiKeyValid: keyValid, + totalTokens: totalTokens) + + Self.log.debug( + """ + Doubao usage parsed remaining=\(snapshot.remainingRequests) \ + limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) + """) + + return snapshot + } + + private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? { + if let value = headers[name] as? String { return value } + for (key, val) in headers { + if let keyStr = key as? String, + keyStr.caseInsensitiveCompare(name) == .orderedSame, + let valStr = val as? String + { + return valStr + } + } + return nil + } + + private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? { + if let value = headers[name] as? String, let int = Int(value) { + return int + } + if let value = headers[name.lowercased()] as? String, let int = Int(value) { + return int + } + for (key, val) in headers { + if let keyStr = key as? String, + keyStr.lowercased() == name.lowercased(), + let valStr = val as? String, + let int = Int(valStr) + { + return int + } + } + return nil + } + + private static func parseResetTime(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = isoFormatter.date(from: trimmed) { return date } + let isoFallback = ISO8601DateFormatter() + isoFallback.formatOptions = [.withInternetDateTime] + if let date = isoFallback.date(from: trimmed) { return date } + + var seconds: TimeInterval = 0 + let pattern = /(\d+)([dhms])/ + for match in trimmed.matches(of: pattern) { + guard let num = Double(match.1) else { continue } + switch match.2 { + case "d": seconds += num * 86400 + case "h": seconds += num * 3600 + case "m": seconds += num * 60 + case "s": seconds += num + default: break + } + } + if seconds > 0 { + return Date().addingTimeInterval(seconds) + } + + if let secs = TimeInterval(trimmed) { + return Date().addingTimeInterval(secs) + } + + return nil + } + + private static func apiErrorSummary(statusCode: Int, data: Data) -> String { + guard let root = try? JSONSerialization.jsonObject(with: data), + let json = root as? [String: Any] + else { + if let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + { + return self.compactText(text) + } + return "Unexpected response body (\(data.count) bytes)." + } + + if let error = json["error"] as? [String: Any], + let message = error["message"] as? String + { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return Self.compactText(trimmed) } + } + + if let message = json["message"] as? String { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return Self.compactText(trimmed) } + } + + return "HTTP \(statusCode) (\(data.count) bytes)." + } + + private static func compactText(_ text: String, maxLength: Int = 200) -> String { + let collapsed = text + .components(separatedBy: .newlines) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if collapsed.count <= maxLength { return collapsed } + let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength) + return "\(collapsed[.. String? { + self.doubaoResolution(environment: environment)?.token + } + public static func deepseekResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -126,6 +130,12 @@ public enum ProviderTokenResolver { self.resolveEnv(StepFunSettingsReader.token(environment: environment)) } + public static func doubaoResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DoubaoSettingsReader.apiKey(environment: environment)) + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 22c6f5bdaed..dc84122b73a 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -29,6 +29,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case openrouter case windsurf case perplexity + case doubao case abacus case mistral case deepseek @@ -68,6 +69,7 @@ public enum IconStyle: Sendable, CaseIterable { case openrouter case windsurf case perplexity + case doubao case abacus case mistral case deepseek diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 6c8379ec0c3..87b93ffc75c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -235,8 +235,8 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus, - .mistral, .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .doubao, + .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index a28a0e7419b..4d0f8b08216 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -77,6 +77,7 @@ enum ProviderChoice: String, AppEnum { case .warp: return nil // Warp not yet supported in widgets case .windsurf: return nil // Windsurf not yet supported in widgets case .perplexity: return nil // Perplexity not yet supported in widgets + case .doubao: return nil // Doubao not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets case .mistral: return nil // Mistral not yet supported in widgets case .deepseek: return nil // DeepSeek not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index b6fd42a318e..415afd0ef5c 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -283,6 +283,7 @@ private struct ProviderSwitchChip: View { case .warp: "Warp" case .windsurf: "Windsurf" case .perplexity: "Pplx" + case .doubao: "Doubao" case .abacus: "Abacus" case .mistral: "Mistral" case .deepseek: "DeepSeek" @@ -652,6 +653,8 @@ enum WidgetColors { Color(red: 52 / 255, green: 232 / 255, blue: 187 / 255) // Windsurf #34e8bb case .perplexity: Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal + case .doubao: + Color(red: 45 / 255, green: 136 / 255, blue: 255 / 255) // Doubao blue case .abacus: Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) case .mistral: diff --git a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift index 8c685e37c86..006e7b5627c 100644 --- a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift +++ b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift @@ -355,46 +355,27 @@ struct CodexUsageFetcherFallbackTests { private func makeHungRateLimitsStubCodexCLI() throws -> String { let script = """ - #!/usr/bin/python3 - import json - import sys - import time - - args = sys.argv[1:] - if "app-server" in args: - for line in sys.stdin: - if not line.strip(): - continue - message = json.loads(line) - method = message.get("method") - if method == "initialized": - continue - - identifier = message.get("id") - if method == "initialize": - payload = {"id": identifier, "result": {}} - print(json.dumps(payload), flush=True) - elif method == "account/rateLimits/read": - time.sleep(30) - elif method == "account/read": - payload = { - "id": identifier, - "result": { - "account": { - "type": "chatgpt", - "email": "stub@example.com", - "planType": "plus" - }, - "requiresOpenaiAuth": False - } - } - print(json.dumps(payload), flush=True) - else: - payload = {"id": identifier, "result": {}} - print(json.dumps(payload), flush=True) - else: - sys.stderr.write("unexpected non app-server Codex invocation\\n") - sys.exit(92) + #!/bin/sh + case " $* " in + *" app-server "*) ;; + *) printf '%s\\n' "unexpected non app-server Codex invocation" >&2; exit 92 ;; + esac + + while IFS= read -r line; do + case "$line" in + *'"method":"initialized"'*|*'"method": "initialized"'*) + ;; + *'"method":"initialize"'*|*'"method": "initialize"'*) + printf '%s\\n' '{"id":1,"result":{}}' + ;; + *'"method":"account/rateLimits/read"'*|*'"method": "account/rateLimits/read"'*) + sleep 30 + ;; + *) + printf '%s\\n' '{"id":1,"result":{}}' + ;; + esac + done """ let url = FileManager.default.temporaryDirectory .appendingPathComponent("codex-hung-stub-\(UUID().uuidString)", isDirectory: false) diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift new file mode 100644 index 00000000000..9ce12c25ebf --- /dev/null +++ b/Tests/CodexBarTests/DoubaoProviderTests.swift @@ -0,0 +1,39 @@ +import CodexBarCore +import Foundation +import Testing + +struct DoubaoProviderTests { + @Test + func `usage snapshot exposes request usage window`() { + let resetDate = Date(timeIntervalSince1970: 1_742_771_200) + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 80, + limitRequests: 100, + resetTime: resetDate, + updatedAt: resetDate, + apiKeyValid: true) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 20) + #expect(usage.primary?.resetDescription == "20/100 requests") + #expect(usage.primary?.resetsAt == resetDate) + #expect(usage.identity?.providerID == .doubao) + } + + @Test + func `usage snapshot shows active key when headers are absent`() { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: now, + apiKeyValid: true) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 78ff9184e8d..6539cb6cd31 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -39,6 +39,18 @@ struct ProviderConfigEnvironmentTests { #expect(env[OpenRouterSettingsReader.envKey] == "or-token") } + @Test + func `applies API key override for doubao`() { + let config = ProviderConfig(id: .doubao, apiKey: "db-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "db-token") + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") + } + @Test func `ignores legacy API key override for deepseek`() { let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index 52e9402b081..9477d0c312a 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -40,6 +40,20 @@ struct ProviderTokenResolverTests { #expect(resolution == nil) } + @Test + func `doubao resolution uses first supported environment token`() { + let env = ["ARK_API_KEY": "ark-token"] + let resolution = ProviderTokenResolver.doubaoResolution(environment: env) + #expect(resolution?.token == "ark-token") + #expect(resolution?.source == .environment) + } + + @Test + func `doubao settings reader trims quoted token`() { + let env = ["DOUBAO_API_KEY": " 'doubao-token' "] + #expect(DoubaoSettingsReader.apiKey(environment: env) == "doubao-token") + } + @Test func `kilo resolution prefers environment over auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{"kilo":{"access":"file-token"}}"#) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 3c4e4b8e243..b86ba072163 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1079,6 +1079,7 @@ struct SettingsStoreTests { .openrouter, .windsurf, .perplexity, + .doubao, .abacus, .mistral, .deepseek, diff --git a/docs/configuration.md b/docs/configuration.md index 30b62e33c6e..a2aae098c8b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -111,7 +111,7 @@ and never paste real cookie values or readable DevTools screenshots into public ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `ollama`, `synthetic`, `warp`, `openrouter`, `perplexity`, `abacus`, `mistral`, `deepseek`, `codebuff`, `commandcode`. +`codex`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `ollama`, `synthetic`, `warp`, `openrouter`, `windsurf`, `perplexity`, `doubao`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `stepfun`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/doubao.md b/docs/doubao.md new file mode 100644 index 00000000000..40c7cfa2d49 --- /dev/null +++ b/docs/doubao.md @@ -0,0 +1,22 @@ +--- +summary: "Doubao provider notes: API-key auth and Volcengine Ark request-limit tracking." +read_when: + - Adding or modifying the Doubao provider + - Debugging Doubao API-key setup + - Explaining Doubao usage display +--- + +# Doubao Provider + +Doubao tracks Volcengine Ark request-limit headers by probing the chat-completions endpoint with a configured API key. + +## Setup +1. Enable **Doubao** in Settings → Providers. +2. Paste an API key in the provider settings, or set `ARK_API_KEY`, `VOLCENGINE_API_KEY`, or `DOUBAO_API_KEY`. +3. Refresh provider usage. + +## Behavior +- Endpoint: `POST https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions` +- Probe models: `doubao-seed-2.0-code`, `doubao-1.5-pro-32k`, `doubao-lite-32k` +- Reads `x-ratelimit-remaining-requests`, `x-ratelimit-limit-requests`, and `x-ratelimit-reset-requests` when returned. +- If the key is valid but rate-limit headers are missing, CodexBar shows the key as active and links to the dashboard for details. diff --git a/docs/providers.md b/docs/providers.md index 2f82140aaa7..aa0d34008c0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -43,6 +43,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Synthetic | API key from config/env → quota API (`api`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | | Perplexity | Browser cookies/manual cookie/env session token → credits API (`web`). | +| Doubao | API key from config/env → Volcengine Ark chat-completions probe (`api`). | | Abacus AI | Browser cookies → compute points + billing API (`web`). | | Mistral | Console billing API via Ory Kratos session cookies (`web`). | | DeepSeek | API key from env or token accounts → balance endpoint (`api`). | @@ -214,6 +215,12 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Tracks recurring credits, bonus/promotional credits, purchased credits, and renewal date when present. - Status: `https://status.perplexity.com/` (link only, no auto-polling). +## Doubao +- API key via `ARK_API_KEY`, `VOLCENGINE_API_KEY`, `DOUBAO_API_KEY`, or provider config. +- Probes Volcengine Ark chat completions and reads request rate-limit headers when present. +- Status: none yet. +- Details: `docs/doubao.md`. + ## Abacus AI - Browser cookies (`abacus.ai`, `apps.abacus.ai`) via automatic import or manual header. - Reads organization compute points and billing data.