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 @@
+
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.