From 30acfec624ac735f6c87408969915220cb2af73d Mon Sep 17 00:00:00 2001
From: start3015 <61035179+start3015@users.noreply.github.com>
Date: Thu, 16 Jul 2026 19:42:05 +0800
Subject: [PATCH 01/13] Doubao: query Coding/Agent Plan usage via arkcli CLI
Replace the Volcengine AK/SK signed API approach with arkcli CLI
(SSO-based). The signed API was unreliable and required users to
obtain AK/SK credentials separately; arkcli uses the same SSO session
the user already has, making setup trivial.
Changes:
- DoubaoUsageFetcher: new fetchCodingPlanUsage(runArkcli:) runs
`arkcli usage plan` and decodes the JSON response (items[].periods[]
with label/percent/reset_at fields). Old Volcengine signer code
removed.
- DoubaoAPIFetchStrategy: try arkcli CLI first, fall back to Ark API
key rate-limit probe. Removed AK/SK fallback path.
- MenuCardView: split Doubao metrics into "Coding Plan" and
"Agent Plan" groups with section headers for visual clarity.
Other providers are unaffected (guard on provider == .doubao).
- ProviderIcon-doubao.svg: replace placeholder smiley with official
Volcengine logo SVG (MIT-licensed, from thesvg.org).
- Tests: updated to match new arkcli JSON format and strategy interface.
Closes #1724.
---
Sources/CodexBar/MenuCardView.swift | 53 +++-
.../Doubao/DoubaoProviderImplementation.swift | 6 +-
.../Resources/ProviderIcon-doubao.svg | 8 +-
.../Doubao/DoubaoProviderDescriptor.swift | 48 +--
.../Providers/Doubao/DoubaoUsageFetcher.swift | 293 +++++++++++++-----
Tests/CodexBarTests/DoubaoProviderTests.swift | 14 +-
.../DoubaoUsageFetcherTests.swift | 214 +++++++------
7 files changed, 422 insertions(+), 214 deletions(-)
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 6da522607f..82529fd178 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -586,13 +586,56 @@ private struct UsageMenuCardUsageContentView: View {
let showBottomDivider: Bool
@Environment(\.menuItemHighlighted) private var isHighlighted
+ /// Doubao ships two subscriptions (Coding Plan + Agent Plan) whose windows
+ /// share the same period labels. Rendering them as a flat list is confusing,
+ /// so split by the "doubao-agent-" id prefix and surface two group headers.
+ private var doubaoSplitMetrics: (coding: [UsageMenuCardView.Model.Metric],
+ agent: [UsageMenuCardView.Model.Metric])?
+ {
+ guard self.model.provider == .doubao else { return nil }
+ let agent = self.model.metrics.filter { $0.id.hasPrefix("doubao-agent-") }
+ guard !agent.isEmpty else { return nil }
+ let coding = self.model.metrics.filter { !$0.id.hasPrefix("doubao-agent-") }
+ return (coding, agent)
+ }
+
+ @ViewBuilder
+ private func groupHeader(_ title: String) -> some View {
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ .textCase(.uppercase)
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 12) {
- ForEach(self.model.metrics, id: \.id) { metric in
- MetricRow(
- metric: metric,
- title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
- progressColor: self.model.progressColor)
+ if let split = self.doubaoSplitMetrics {
+ if !split.coding.isEmpty {
+ self.groupHeader("Coding Plan")
+ ForEach(split.coding, id: \.id) { metric in
+ MetricRow(
+ metric: metric,
+ title: UsageMenuCardView
+ .popupMetricTitle(provider: self.model.provider, metric: metric),
+ progressColor: self.model.progressColor)
+ }
+ }
+ Divider()
+ self.groupHeader("Agent Plan")
+ ForEach(split.agent, id: \.id) { metric in
+ MetricRow(
+ metric: metric,
+ title: UsageMenuCardView
+ .popupMetricTitle(provider: self.model.provider, metric: metric),
+ progressColor: self.model.progressColor)
+ }
+ } else {
+ ForEach(self.model.metrics, id: \.id) { metric in
+ MetricRow(
+ metric: metric,
+ title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
+ progressColor: self.model.progressColor)
+ }
}
if let resetCredits = self.model.codexResetCredits {
if !self.model.metrics.isEmpty {
diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
index 182ff06cc3..cf2dbb0f3d 100644
--- a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
@@ -18,8 +18,8 @@ struct DoubaoProviderImplementation: ProviderImplementation {
ProviderSettingsFieldDescriptor(
id: "doubao-api-token",
title: "API key / Access key ID",
- subtitle: "Use a Volcengine access key ID with the secret field for Coding Plan usage, "
- + "or leave the secret blank to use an Ark API key.",
+ subtitle: "Install and authenticate 'arkcli' for Coding/Agent Plan usage (preferred), "
+ + "or use an Ark API key for rate-limit probing.",
kind: .secure,
placeholder: "ark-... or AKLT...",
binding: context.stringBinding(\.doubaoAPIToken),
@@ -40,7 +40,7 @@ struct DoubaoProviderImplementation: ProviderImplementation {
ProviderSettingsFieldDescriptor(
id: "doubao-secret-access-key",
title: "Secret access key",
- subtitle: "Volcengine secret access key for the signed Coding Plan usage API.",
+ subtitle: "Optional. Only needed if arkcli is unavailable and you use Volcengine AK/SK signing.",
kind: .secure,
placeholder: "",
binding: context.stringBinding(\.doubaoSecretAccessKey),
diff --git a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
index 9c20430a1c..c5205ce6ff 100644
--- a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
+++ b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
@@ -1 +1,7 @@
-
+
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
index 386c039be6..8044282d48 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -54,45 +54,41 @@ public enum DoubaoProviderDescriptor {
struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
let id: String = "doubao.api"
let kind: ProviderFetchKind = .apiToken
- private let codingPlanUsageLoader: @Sendable (DoubaoCodingPlanCredentials) async throws -> DoubaoUsageSnapshot
+ private let cliUsageLoader: @Sendable () async throws -> DoubaoUsageSnapshot
private let arkUsageLoader: @Sendable (String) async throws -> DoubaoUsageSnapshot
init(
- codingPlanUsageLoader: @escaping @Sendable (DoubaoCodingPlanCredentials) async throws
- -> DoubaoUsageSnapshot = { credentials in
- try await DoubaoUsageFetcher.fetchCodingPlanUsage(credentials: credentials)
- },
+ cliUsageLoader: @escaping @Sendable () async throws -> DoubaoUsageSnapshot = {
+ try await DoubaoUsageFetcher.fetchCodingPlanUsage()
+ },
arkUsageLoader: @escaping @Sendable (String) async throws -> DoubaoUsageSnapshot = { apiKey in
try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey)
})
{
- self.codingPlanUsageLoader = codingPlanUsageLoader
+ self.cliUsageLoader = cliUsageLoader
self.arkUsageLoader = arkUsageLoader
}
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
- DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil ||
+ DoubaoAPIFetchStrategy.arkcliInstalled(environment: context.env) ||
ProviderTokenResolver.doubaoToken(environment: context.env) != nil
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
- let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
- if let credentials = DoubaoSettingsReader.codingPlanCredentials(environment: context.env) {
- do {
- let usage = try await self.codingPlanUsageLoader(credentials)
- return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
- } catch {
- if Self.isCancellation(error) {
- throw error
- }
- guard let apiKey else {
- throw error
- }
- let usage = try await self.arkUsageLoader(apiKey)
- return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
+ // 1) Try arkcli CLI (SSO-based, no credentials needed).
+ // The loader throws quickly if arkcli is not installed.
+ do {
+ let usage = try await self.cliUsageLoader()
+ return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "cli")
+ } catch {
+ if Self.isCancellation(error) {
+ throw error
}
+ // Fall through to API key probe
}
+ // 2) Fall back to Ark API key probe (rate-limit headers)
+ let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
guard let apiKey else {
throw DoubaoUsageError.missingCredentials
}
@@ -107,4 +103,14 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
private static func isCancellation(_ error: Error) -> Bool {
error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled
}
+
+ private static func arkcliInstalled(environment: [String: String]) -> Bool {
+ if let envPath = environment["ARKCLI_PATH"],
+ FileManager.default.isExecutableFile(atPath: envPath)
+ {
+ return true
+ }
+ let candidates = ["/usr/local/bin/arkcli", "/opt/homebrew/bin/arkcli"]
+ return candidates.contains { FileManager.default.isExecutableFile(atPath: $0) }
+ }
}
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index cc5b7a06df..054508f2a8 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -67,9 +67,35 @@ public struct DoubaoUsageSnapshot: Sendable {
primary: primary,
secondary: nil,
tertiary: nil,
+ extraRateWindows: nil,
+ kiroUsage: nil,
+ ampUsage: nil,
providerCost: nil,
+ zaiUsage: nil,
+ minimaxUsage: nil,
+ deepseekUsage: nil,
+ mimoUsage: nil,
+ openRouterUsage: nil,
+ sakanaPayAsYouGo: nil,
+ crossModelUsage: nil,
+ clawRouterUsage: nil,
+ sub2APIUsage: nil,
+ wayfinderUsage: nil,
+ openAIAPIUsage: nil,
+ codexResetCredits: nil,
+ claudeAdminAPIUsage: nil,
+ mistralUsage: nil,
+ deepgramUsage: nil,
+ poeUsage: nil,
+ cursorRequests: nil,
+ commandCodeSubscriptionEnrichmentUnavailable: false,
+ commandCodeHasSubscriptionPlan: false,
+ commandCodeMonthlyGrantDepleted: false,
+ subscriptionExpiresAt: nil,
+ subscriptionRenewsAt: nil,
updatedAt: self.updatedAt,
- identity: identity)
+ identity: identity,
+ dataConfidence: .unknown)
}
}
@@ -97,9 +123,32 @@ public struct DoubaoCodingPlanUsage: Sendable, Equatable {
}
public func toUsageSnapshot(updatedAt: Date) -> UsageSnapshot {
- let primary = self.rateWindow(levels: ["session", "5-hour", "five_hour"], minutes: 5 * 60)
- let secondary = self.rateWindow(levels: ["weekly", "week"], minutes: 7 * 24 * 60)
- let tertiary = self.rateWindow(levels: ["monthly", "month"], minutes: 30 * 24 * 60)
+ let codingPrimary = self.rateWindow(levels: ["session", "5-hour", "five_hour", "5h"], minutes: 5 * 60)
+ let codingSecondary = self.rateWindow(levels: ["weekly", "week"], minutes: 7 * 24 * 60)
+ let codingTertiary = self.rateWindow(levels: ["monthly", "month"], minutes: 30 * 24 * 60)
+
+ let agentPrimary = self.rateWindow(
+ levels: ["agent_session", "agent_5-hour", "agent_five_hour", "agent_5h"], minutes: 5 * 60)
+ let agentSecondary = self.rateWindow(levels: ["agent_weekly", "agent_week"], minutes: 7 * 24 * 60)
+ let agentTertiary = self.rateWindow(levels: ["agent_monthly", "agent_month"], minutes: 30 * 24 * 60)
+
+ let primary = codingPrimary ?? agentPrimary
+ let secondary = codingSecondary ?? agentSecondary
+ let tertiary = codingTertiary ?? agentTertiary
+
+ var extraRateWindows: [NamedRateWindow] = []
+ if codingPrimary != nil, let a = agentPrimary {
+ extraRateWindows.append(NamedRateWindow(id: "doubao-agent-session", title: "5-hour", window: a))
+ }
+ if codingSecondary != nil, let a = agentSecondary {
+ extraRateWindows.append(NamedRateWindow(id: "doubao-agent-weekly", title: "Weekly", window: a))
+ }
+ if codingTertiary != nil, let a = agentTertiary {
+ extraRateWindows.append(NamedRateWindow(id: "doubao-agent-monthly", title: "Monthly", window: a))
+ }
+
+ let finalExtraWindows = extraRateWindows.isEmpty ? nil : extraRateWindows
+
let identity = ProviderIdentitySnapshot(
providerID: .doubao,
accountEmail: nil,
@@ -110,9 +159,35 @@ public struct DoubaoCodingPlanUsage: Sendable, Equatable {
primary: primary,
secondary: secondary,
tertiary: tertiary,
+ extraRateWindows: finalExtraWindows,
+ kiroUsage: nil,
+ ampUsage: nil,
providerCost: nil,
+ zaiUsage: nil,
+ minimaxUsage: nil,
+ deepseekUsage: nil,
+ mimoUsage: nil,
+ openRouterUsage: nil,
+ sakanaPayAsYouGo: nil,
+ crossModelUsage: nil,
+ clawRouterUsage: nil,
+ sub2APIUsage: nil,
+ wayfinderUsage: nil,
+ openAIAPIUsage: nil,
+ codexResetCredits: nil,
+ claudeAdminAPIUsage: nil,
+ mistralUsage: nil,
+ deepgramUsage: nil,
+ poeUsage: nil,
+ cursorRequests: nil,
+ commandCodeSubscriptionEnrichmentUnavailable: false,
+ commandCodeHasSubscriptionPlan: false,
+ commandCodeMonthlyGrantDepleted: false,
+ subscriptionExpiresAt: nil,
+ subscriptionRenewsAt: nil,
updatedAt: self.updateTime ?? updatedAt,
- identity: identity)
+ identity: identity,
+ dataConfidence: .unknown)
}
private func rateWindow(levels: Set, minutes: Int) -> RateWindow? {
@@ -151,8 +226,9 @@ public enum DoubaoUsageError: LocalizedError, Sendable {
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")!
- private static let codingPlanAPIURL = URL(
- string: "https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01")!
+
+ /// Closure that runs `arkcli usage plan` and returns raw stdout.
+ public typealias ArkcliRunner = @Sendable () async throws -> Data
/// Models to probe, ordered by likelihood. We try multiple models because
/// different key types may not have access to every model.
@@ -208,68 +284,141 @@ public struct DoubaoUsageFetcher: Sendable {
}
public static func fetchCodingPlanUsage(
- credentials: DoubaoCodingPlanCredentials,
- session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
+ runArkcli: ArkcliRunner? = nil,
date: Date = Date()) async throws -> DoubaoUsageSnapshot
{
- guard !credentials.accessKeyID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
- !credentials.secretAccessKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- else {
- throw DoubaoUsageError.missingCredentials
+ let stdoutData: Data
+ if let runArkcli {
+ stdoutData = try await runArkcli()
+ } else {
+ stdoutData = try await Self.runArkcliUsagePlan()
}
- let body = Data()
- var request = URLRequest(url: self.codingPlanAPIURL)
- request.httpMethod = "POST"
- request.timeoutInterval = 15
- request.httpBody = body
- request.setValue("application/json", forHTTPHeaderField: "Accept")
- DoubaoVolcengineSigner.sign(
- request: &request,
- body: body,
- credentials: credentials,
- date: date)
+ let usage = try Self.decodeArkcliUsage(from: stdoutData, date: date)
- let response = try await transport.response(for: request)
- guard response.statusCode == 200 else {
- let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: response.data)
- Self.log.error("Doubao coding plan API returned \(response.statusCode): \(summary)")
- throw DoubaoUsageError.apiError(response.statusCode, summary)
- }
-
- let codingPlanUsage = try self.decodeCodingPlanUsage(from: response.data)
return DoubaoUsageSnapshot(
remainingRequests: 0,
limitRequests: 0,
resetTime: nil,
- updatedAt: codingPlanUsage.updateTime ?? date,
+ updatedAt: usage.updateTime ?? date,
apiKeyValid: true,
- codingPlanUsage: codingPlanUsage)
+ codingPlanUsage: usage)
}
- static func decodeCodingPlanUsage(from data: Data) throws -> DoubaoCodingPlanUsage {
- let response: CodingPlanUsageResponse
+ static func decodeArkcliUsage(from data: Data, date: Date = Date()) throws -> DoubaoCodingPlanUsage {
+ let response: ArkcliUsageResponse
do {
- response = try JSONDecoder().decode(CodingPlanUsageResponse.self, from: data)
+ response = try JSONDecoder().decode(ArkcliUsageResponse.self, from: data)
} catch {
throw DoubaoUsageError.parseFailed(error.localizedDescription)
}
- let usage = response.result
- let quotas = usage.quotaUsage.map { quota in
- DoubaoCodingPlanUsage.Quota(
- level: quota.level,
- percent: quota.percent,
- resetTime: self.date(fromEpoch: quota.resetTimestamp))
- }
- return DoubaoCodingPlanUsage(
- status: usage.status,
- updateTime: self.date(fromEpoch: usage.updateTimestamp),
- quotas: quotas)
+
+ var allQuotas: [DoubaoCodingPlanUsage.Quota] = []
+ var updateTime: Date?
+ var status: String?
+
+ for item in response.items {
+ let isAgent = item.product == "agent-plan"
+ if let updatedAt = item.updatedAt, updatedAt > 0 {
+ updateTime = updateTime ?? Date(timeIntervalSince1970: updatedAt)
+ }
+ if item.subscribed == true {
+ status = status ?? "subscribed"
+ }
+ for period in item.periods {
+ let level = isAgent ? "agent_" + period.label : period.label
+ let resetTime = period.resetAt.flatMap(Self.parseISO8601)
+ allQuotas.append(DoubaoCodingPlanUsage.Quota(
+ level: level,
+ percent: period.percent,
+ resetTime: resetTime))
+ }
+ }
+
+ return DoubaoCodingPlanUsage(status: status, updateTime: updateTime, quotas: allQuotas)
+ }
+
+ private static func runArkcliUsagePlan() async throws -> Data {
+ guard let arkcliPath = Self.findArkcli() else {
+ throw DoubaoUsageError.missingCredentials
+ }
+
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: arkcliPath)
+ process.arguments = ["usage", "plan"]
+
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+ process.standardOutput = stdoutPipe
+ process.standardError = stderrPipe
+
+ do {
+ try process.run()
+ } catch {
+ throw DoubaoUsageError.networkError("Failed to launch arkcli: \(error.localizedDescription)")
+ }
+
+ process.waitUntilExit()
+
+ guard process.terminationStatus == 0 else {
+ let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
+ let stderrText = String(data: stderrData, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown error"
+ throw DoubaoUsageError.apiError(Int(process.terminationStatus), stderrText)
+ }
+
+ return stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}
- private static func date(fromEpoch timestamp: TimeInterval?) -> Date? {
- guard let timestamp, timestamp > 0 else { return nil }
- return Date(timeIntervalSince1970: timestamp)
+ private static func findArkcli() -> String? {
+ if let envPath = ProcessInfo.processInfo.environment["ARKCLI_PATH"],
+ FileManager.default.isExecutableFile(atPath: envPath)
+ {
+ return envPath
+ }
+
+ let candidates = [
+ "/usr/local/bin/arkcli",
+ "/opt/homebrew/bin/arkcli",
+ ]
+ for path in candidates {
+ if FileManager.default.isExecutableFile(atPath: path) {
+ return path
+ }
+ }
+ return Self.which("arkcli")
+ }
+
+ private static func which(_ tool: String) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
+ process.arguments = [tool]
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ try? process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else { return nil }
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ guard let path = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !path.isEmpty
+ else { return nil }
+ return path
+ }
+
+ private static func parseISO8601(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: trimmed) { return date }
+
+ let fallback = ISO8601DateFormatter()
+ fallback.formatOptions = [.withInternetDateTime]
+ if let date = fallback.date(from: trimmed) { return date }
+
+ return nil
}
private static func confirmAmbiguousZeroRemaining(
@@ -514,35 +663,35 @@ public struct DoubaoUsageFetcher: Sendable {
return "\(collapsed[..
Date: Fri, 17 Jul 2026 09:12:26 +0800
Subject: [PATCH 02/13] Address Codex review: arkcli plan error item, ms
timestamp, team agent plan, explicit api source
---
.../Doubao/DoubaoProviderDescriptor.swift | 12 +++
.../Providers/Doubao/DoubaoUsageFetcher.swift | 18 ++++-
.../DoubaoUsageFetcherTests.swift | 73 ++++++++++++++++++-
3 files changed, 97 insertions(+), 6 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
index 8044282d48..e69ca5f8ec 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -75,6 +75,18 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ // When the user explicitly selects the API source, go straight to the
+ // Ark API-key probe and skip arkcli entirely: arkcli may be logged into
+ // a different account or only carry SSO plan data the user does not want.
+ if context.sourceMode == .api {
+ let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
+ guard let apiKey else {
+ throw DoubaoUsageError.missingCredentials
+ }
+ let usage = try await self.arkUsageLoader(apiKey)
+ return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
+ }
+
// 1) Try arkcli CLI (SSO-based, no credentials needed).
// The loader throws quickly if arkcli is not installed.
do {
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index 054508f2a8..2765ae725d 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -318,14 +318,24 @@ public struct DoubaoUsageFetcher: Sendable {
var status: String?
for item in response.items {
- let isAgent = item.product == "agent-plan"
+ // Both personal and team Agent Plan ids map to the agent windows;
+ // comparing only `agent-plan` would mis-file `agent-plan-team`
+ // quotas under the Coding Plan primary/secondary/tertiary slots.
+ let isAgent = item.product == "agent-plan" || item.product == "agent-plan-team"
if let updatedAt = item.updatedAt, updatedAt > 0 {
- updateTime = updateTime ?? Date(timeIntervalSince1970: updatedAt)
+ // The arkcli usage-plan reference documents `updated_at` as epoch
+ // *milliseconds*; convert before constructing the Date so the
+ // timestamp stays in the present instead of thousands of years out.
+ updateTime = updateTime ?? Date(timeIntervalSince1970: updatedAt / 1000)
}
if item.subscribed == true {
status = status ?? "subscribed"
}
- for period in item.periods {
+ // A per-bucket failure is reported as an item with no `periods`
+ // (often an `error` field). Keep `periods` optional so one failed
+ // product bucket does not reject the entire stdout and hide the
+ // otherwise valid subscribed plan usage.
+ for period in item.periods ?? [] {
let level = isAgent ? "agent_" + period.label : period.label
let resetTime = period.resetAt.flatMap(Self.parseISO8601)
allQuotas.append(DoubaoCodingPlanUsage.Quota(
@@ -672,7 +682,7 @@ public struct DoubaoUsageFetcher: Sendable {
private struct ArkcliUsageItem: Decodable {
let product: String
let subscribed: Bool?
- let periods: [ArkcliPeriod]
+ let periods: [ArkcliPeriod]?
let updatedAt: TimeInterval?
enum CodingKeys: String, CodingKey {
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 0767911b49..89450ffdbd 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -124,7 +124,7 @@ struct DoubaoUsageFetcherTests {
{"label": "weekly", "percent": 2.71, "reset_at": "2026-07-20T00:00:00+08:00"},
{"label": "monthly", "percent": 1.36, "reset_at": "2026-08-15T23:59:59+08:00"}
],
- "updated_at": 1784191193
+ "updated_at": 1784191193000
}
]
}
@@ -214,6 +214,75 @@ struct DoubaoUsageFetcherTests {
#expect(usage.extraRateWindows == nil || usage.extraRateWindows?.isEmpty == true)
}
+ @Test
+ func `team agent plan product is classified as agent windows`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "agent-plan-team",
+ "subscribed": true,
+ "periods": [
+ {"label": "5h", "percent": 5.0, "reset_at": "2026-07-16T19:12:07+08:00"},
+ {"label": "weekly", "percent": 15.0, "reset_at": "2026-07-20T00:00:00+08:00"},
+ {"label": "monthly", "percent": 25.0, "reset_at": "2026-08-15T23:59:59+08:00"}
+ ]
+ },
+ {
+ "product": "coding-plan-team",
+ "subscribed": true,
+ "periods": [
+ {"label": "session", "percent": 7.48, "reset_at": "2026-07-16T19:12:07+08:00"}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ // Team Agent Plan becomes primary/secondary/tertiary (no coding-plan windows).
+ #expect(usage.primary?.usedPercent == 5.0)
+ #expect(usage.primary?.windowMinutes == 300)
+ #expect(usage.secondary?.usedPercent == 15.0)
+ #expect(usage.tertiary?.usedPercent == 25.0)
+ // No extra windows when no personal coding-plan is present to pair with.
+ #expect(usage.extraRateWindows == nil || usage.extraRateWindows?.isEmpty == true)
+ }
+
+ @Test
+ func `arkcli response with an error-only item still decodes valid buckets`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "error": "failed to query usage",
+ "subscribed": false
+ },
+ {
+ "product": "agent-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "5h", "percent": 5.0, "reset_at": "2026-07-16T19:12:07+08:00"}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ // The error-only coding item is skipped; the agent item still decodes.
+ #expect(usage.primary?.usedPercent == 5.0)
+ #expect(usage.primary?.windowMinutes == 300)
+ #expect(usage.identity?.loginMethod == "subscribed")
+ }
+
@Test
func `arkcli fetch via injected runner returns parsed snapshot`() async throws {
let jsonData = Data(
@@ -226,7 +295,7 @@ struct DoubaoUsageFetcherTests {
"periods": [
{"label": "session", "percent": 42.0, "reset_at": "2026-07-16T19:12:07+08:00"}
],
- "updated_at": 1784191193
+ "updated_at": 1784191193000
}
]
}
From fb70306bf20531d4027f15f492b8bc881c0df548 Mon Sep 17 00:00:00 2001
From: start3015 <61035179+start3015@users.noreply.github.com>
Date: Fri, 17 Jul 2026 09:18:32 +0800
Subject: [PATCH 03/13] Doubao: fall back to arkcli when API source has no key
---
.../Doubao/DoubaoProviderDescriptor.swift | 15 ++++++---------
1 file changed, 6 insertions(+), 9 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
index e69ca5f8ec..697b6f0917 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -75,14 +75,12 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
- // When the user explicitly selects the API source, go straight to the
- // Ark API-key probe and skip arkcli entirely: arkcli may be logged into
- // a different account or only carry SSO plan data the user does not want.
- if context.sourceMode == .api {
- let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
- guard let apiKey else {
- throw DoubaoUsageError.missingCredentials
- }
+ let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
+
+ // Only skip arkcli when the user explicitly selected the API source
+ // *and* actually supplied a key. Without a key we still fall through
+ // to arkcli so SSO users are not blocked by a missing ARK_API_KEY.
+ if context.sourceMode == .api, let apiKey {
let usage = try await self.arkUsageLoader(apiKey)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}
@@ -100,7 +98,6 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
}
// 2) Fall back to Ark API key probe (rate-limit headers)
- let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
guard let apiKey else {
throw DoubaoUsageError.missingCredentials
}
From 4d0bd73b97af613fb7a0715dd17d54438a642562 Mon Sep 17 00:00:00 2001
From: start3015 <61035179+start3015@users.noreply.github.com>
Date: Fri, 17 Jul 2026 09:22:24 +0800
Subject: [PATCH 04/13] Doubao: fall back to arkcli when API key probe fails
---
.../Doubao/DoubaoProviderDescriptor.swift | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
index 697b6f0917..15060c0f02 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -77,12 +77,20 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
- // Only skip arkcli when the user explicitly selected the API source
- // *and* actually supplied a key. Without a key we still fall through
- // to arkcli so SSO users are not blocked by a missing ARK_API_KEY.
+ // When the user explicitly selected the API source and supplied a key,
+ // prefer the Ark API-key probe. If it fails (e.g. invalid key, network,
+ // wrong account), still fall through to arkcli so SSO users are not
+ // left with a hard error when a working CLI login is available.
if context.sourceMode == .api, let apiKey {
- let usage = try await self.arkUsageLoader(apiKey)
- return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
+ do {
+ let usage = try await self.arkUsageLoader(apiKey)
+ return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
+ } catch {
+ if Self.isCancellation(error) {
+ throw error
+ }
+ // Fall through to arkcli
+ }
}
// 1) Try arkcli CLI (SSO-based, no credentials needed).
From 2c54574a7c0b6364155a5d5a3b2f8ed037ed83ee Mon Sep 17 00:00:00 2001
From: start3015 <61035179+start3015@users.noreply.github.com>
Date: Fri, 17 Jul 2026 16:45:28 +0800
Subject: [PATCH 05/13] Split arkcli into CLI strategy, restore AK/SK fallback,
fix timestamp
- Split DoubaoAPIFetchStrategy into DoubaoCLIFetchStrategy (kind .cli,
arkcli SSO) and DoubaoAPIFetchStrategy (kind .apiToken, AK/SK signed
+ Ark API key probe) so --source cli selects arkcli and --source api
never silently falls through to SSO
- Restore fetchCodingPlanUsage(credentials:) and decodeCodingPlanUsage
to preserve the shipped AK/SK signed Volcengine API path
- Auto-detect updated_at unit (seconds vs milliseconds) by magnitude
so real arkcli output (epoch seconds) doesn't render as 1970
- Classify coding-plan-team as agent windows alongside agent-plan-team
- Prefix agent extra windows with 'Agent ' for clear menu separation
- Surface signed error (not generic missing-key) when AK/SK fails and
no API key is configured
- Add 20+ tests covering routing, fallback, cancellation, signed decode
Co-Authored-By: Claude
---
Sources/CodexBar/MenuCardView.swift | 6 +-
.../Doubao/DoubaoProviderDescriptor.swift | 115 +++++---
.../Providers/Doubao/DoubaoUsageFetcher.swift | 159 ++++++++++--
Tests/CodexBarTests/DoubaoProviderTests.swift | 245 +++++++++++++++++-
.../DoubaoUsageFetcherTests.swift | 141 +++++++++-
5 files changed, 583 insertions(+), 83 deletions(-)
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 82529fd178..4ede871735 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -589,8 +589,9 @@ private struct UsageMenuCardUsageContentView: View {
/// Doubao ships two subscriptions (Coding Plan + Agent Plan) whose windows
/// share the same period labels. Rendering them as a flat list is confusing,
/// so split by the "doubao-agent-" id prefix and surface two group headers.
- private var doubaoSplitMetrics: (coding: [UsageMenuCardView.Model.Metric],
- agent: [UsageMenuCardView.Model.Metric])?
+ private var doubaoSplitMetrics: (
+ coding: [UsageMenuCardView.Model.Metric],
+ agent: [UsageMenuCardView.Model.Metric])?
{
guard self.model.provider == .doubao else { return nil }
let agent = self.model.metrics.filter { $0.id.hasPrefix("doubao-agent-") }
@@ -599,7 +600,6 @@ private struct UsageMenuCardUsageContentView: View {
return (coding, agent)
}
- @ViewBuilder
private func groupHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
index 15060c0f02..102e38711d 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -40,94 +40,127 @@ public enum DoubaoProviderDescriptor {
supportsTokenCost: false,
noDataMessage: { "Doubao cost summary is not available." }),
fetchPlan: ProviderFetchPlan(
- sourceModes: [.auto, .api],
- pipeline: ProviderFetchPipeline(resolveStrategies: { _ in
- [DoubaoAPIFetchStrategy()]
- })),
+ sourceModes: [.auto, .cli, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),
cli: ProviderCLIConfig(
name: "doubao",
aliases: ["volcengine", "ark", "bytedance"],
versionDetector: nil))
}
+
+ static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
+ switch context.sourceMode {
+ case .auto:
+ // arkcli SSO first (preferred, no credentials needed), then API fallback.
+ [DoubaoCLIFetchStrategy(), DoubaoAPIFetchStrategy()]
+ case .cli:
+ // Explicit CLI source: arkcli only, no API fallback.
+ [DoubaoCLIFetchStrategy()]
+ case .api:
+ // Explicit API source: AK/SK signed or API key probe only, no SSO fallback.
+ [DoubaoAPIFetchStrategy()]
+ case .web, .oauth:
+ []
+ }
+ }
}
+// MARK: - CLI strategy (arkcli SSO)
+
+struct DoubaoCLIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "doubao.cli"
+ let kind: ProviderFetchKind = .cli
+ private let cliUsageLoader: @Sendable () async throws -> DoubaoUsageSnapshot
+
+ init(
+ cliUsageLoader: @escaping @Sendable () async throws -> DoubaoUsageSnapshot = {
+ try await DoubaoUsageFetcher.fetchCodingPlanUsage()
+ })
+ {
+ self.cliUsageLoader = cliUsageLoader
+ }
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ DoubaoUsageFetcher.findArkcli(environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let usage = try await self.cliUsageLoader()
+ return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "cli")
+ }
+
+ func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
+ // Only allow fallback to API in auto mode; explicit CLI mode stays strict.
+ guard context.sourceMode == .auto else { return false }
+ if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
+ return false
+ }
+ return true
+ }
+}
+
+// MARK: - API strategy (AK/SK signed + Ark API key probe)
+
struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
let id: String = "doubao.api"
let kind: ProviderFetchKind = .apiToken
- private let cliUsageLoader: @Sendable () async throws -> DoubaoUsageSnapshot
+ private let signedUsageLoader: @Sendable (DoubaoCodingPlanCredentials) async throws -> DoubaoUsageSnapshot
private let arkUsageLoader: @Sendable (String) async throws -> DoubaoUsageSnapshot
init(
- cliUsageLoader: @escaping @Sendable () async throws -> DoubaoUsageSnapshot = {
- try await DoubaoUsageFetcher.fetchCodingPlanUsage()
- },
+ signedUsageLoader: @escaping @Sendable (DoubaoCodingPlanCredentials) async throws
+ -> DoubaoUsageSnapshot = { credentials in
+ try await DoubaoUsageFetcher.fetchCodingPlanUsage(credentials: credentials)
+ },
arkUsageLoader: @escaping @Sendable (String) async throws -> DoubaoUsageSnapshot = { apiKey in
try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey)
})
{
- self.cliUsageLoader = cliUsageLoader
+ self.signedUsageLoader = signedUsageLoader
self.arkUsageLoader = arkUsageLoader
}
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
- DoubaoAPIFetchStrategy.arkcliInstalled(environment: context.env) ||
+ // Explicit API mode always runs so a missing key surfaces an error.
+ // Auto mode only tries API when credentials are resolvable.
+ context.sourceMode == .api ||
+ DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil ||
ProviderTokenResolver.doubaoToken(environment: context.env) != nil
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
+ var signedError: Error?
- // When the user explicitly selected the API source and supplied a key,
- // prefer the Ark API-key probe. If it fails (e.g. invalid key, network,
- // wrong account), still fall through to arkcli so SSO users are not
- // left with a hard error when a working CLI login is available.
- if context.sourceMode == .api, let apiKey {
+ // 1) Try AK/SK signed Coding Plan usage (legacy Volcengine API).
+ if let credentials = DoubaoSettingsReader.codingPlanCredentials(environment: context.env) {
do {
- let usage = try await self.arkUsageLoader(apiKey)
+ let usage = try await self.signedUsageLoader(credentials)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
} catch {
if Self.isCancellation(error) {
throw error
}
- // Fall through to arkcli
- }
- }
-
- // 1) Try arkcli CLI (SSO-based, no credentials needed).
- // The loader throws quickly if arkcli is not installed.
- do {
- let usage = try await self.cliUsageLoader()
- return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "cli")
- } catch {
- if Self.isCancellation(error) {
- throw error
+ // Preserve the signed error so it surfaces when there is no API key to fall back to.
+ signedError = error
}
- // Fall through to API key probe
}
- // 2) Fall back to Ark API key probe (rate-limit headers)
+ // 2) Fall back to Ark API key probe (rate-limit headers).
guard let apiKey else {
- throw DoubaoUsageError.missingCredentials
+ // If the signed request failed, surface that error instead of a generic "missing key".
+ throw signedError ?? DoubaoUsageError.missingCredentials
}
let usage = try await self.arkUsageLoader(apiKey)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ // API strategy never falls back to CLI; explicit API mode stays strict.
false
}
private static func isCancellation(_ error: Error) -> Bool {
error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled
}
-
- private static func arkcliInstalled(environment: [String: String]) -> Bool {
- if let envPath = environment["ARKCLI_PATH"],
- FileManager.default.isExecutableFile(atPath: envPath)
- {
- return true
- }
- let candidates = ["/usr/local/bin/arkcli", "/opt/homebrew/bin/arkcli"]
- return candidates.contains { FileManager.default.isExecutableFile(atPath: $0) }
- }
}
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index 2765ae725d..28dd0013b8 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -137,14 +137,16 @@ public struct DoubaoCodingPlanUsage: Sendable, Equatable {
let tertiary = codingTertiary ?? agentTertiary
var extraRateWindows: [NamedRateWindow] = []
+ // Prefix agent-plan extra windows with "Agent " so the menu section
+ // clearly separates them from the primary Coding Plan rows.
if codingPrimary != nil, let a = agentPrimary {
- extraRateWindows.append(NamedRateWindow(id: "doubao-agent-session", title: "5-hour", window: a))
+ extraRateWindows.append(NamedRateWindow(id: "doubao-agent-session", title: "Agent 5h", window: a))
}
if codingSecondary != nil, let a = agentSecondary {
- extraRateWindows.append(NamedRateWindow(id: "doubao-agent-weekly", title: "Weekly", window: a))
+ extraRateWindows.append(NamedRateWindow(id: "doubao-agent-weekly", title: "Agent Weekly", window: a))
}
if codingTertiary != nil, let a = agentTertiary {
- extraRateWindows.append(NamedRateWindow(id: "doubao-agent-monthly", title: "Monthly", window: a))
+ extraRateWindows.append(NamedRateWindow(id: "doubao-agent-monthly", title: "Agent Monthly", window: a))
}
let finalExtraWindows = extraRateWindows.isEmpty ? nil : extraRateWindows
@@ -226,6 +228,8 @@ public enum DoubaoUsageError: LocalizedError, Sendable {
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")!
+ private static let codingPlanAPIURL = URL(
+ string: "https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01")!
/// Closure that runs `arkcli usage plan` and returns raw stdout.
public typealias ArkcliRunner = @Sendable () async throws -> Data
@@ -287,11 +291,10 @@ public struct DoubaoUsageFetcher: Sendable {
runArkcli: ArkcliRunner? = nil,
date: Date = Date()) async throws -> DoubaoUsageSnapshot
{
- let stdoutData: Data
- if let runArkcli {
- stdoutData = try await runArkcli()
+ let stdoutData: Data = if let runArkcli {
+ try await runArkcli()
} else {
- stdoutData = try await Self.runArkcliUsagePlan()
+ try await Self.runArkcliUsagePlan()
}
let usage = try Self.decodeArkcliUsage(from: stdoutData, date: date)
@@ -318,15 +321,22 @@ public struct DoubaoUsageFetcher: Sendable {
var status: String?
for item in response.items {
- // Both personal and team Agent Plan ids map to the agent windows;
- // comparing only `agent-plan` would mis-file `agent-plan-team`
- // quotas under the Coding Plan primary/secondary/tertiary slots.
- let isAgent = item.product == "agent-plan" || item.product == "agent-plan-team"
+ // Team plans (both `agent-plan-team` and `coding-plan-team`) are
+ // grouped under the agent windows: the personal Coding Plan slots
+ // are reserved for the individual `coding-plan` subscription so a
+ // team session doesn't preempt a personal 5-hour window.
+ let isAgent = item.product == "agent-plan"
+ || item.product == "agent-plan-team"
+ || item.product == "coding-plan-team"
if let updatedAt = item.updatedAt, updatedAt > 0 {
- // The arkcli usage-plan reference documents `updated_at` as epoch
- // *milliseconds*; convert before constructing the Date so the
- // timestamp stays in the present instead of thousands of years out.
- updateTime = updateTime ?? Date(timeIntervalSince1970: updatedAt / 1000)
+ // arkcli has shipped `updated_at` as both epoch milliseconds and
+ // epoch seconds across versions/plans; detect the unit by
+ // magnitude so a seconds payload isn't divided into 1970 and a
+ // milliseconds payload isn't multiplied into the far future.
+ // 1e11 seconds ≈ year 5138, well past any real "seconds" value,
+ // and 1e11 milliseconds ≈ 1973, well before any real "ms" value.
+ let seconds = updatedAt >= 1e11 ? updatedAt / 1000 : updatedAt
+ updateTime = updateTime ?? Date(timeIntervalSince1970: seconds)
}
if item.subscribed == true {
status = status ?? "subscribed"
@@ -348,8 +358,75 @@ public struct DoubaoUsageFetcher: Sendable {
return DoubaoCodingPlanUsage(status: status, updateTime: updateTime, quotas: allQuotas)
}
+ // MARK: - AK/SK signed Coding Plan usage (legacy Volcengine API)
+
+ public static func fetchCodingPlanUsage(
+ credentials: DoubaoCodingPlanCredentials,
+ session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
+ date: Date = Date()) async throws -> DoubaoUsageSnapshot
+ {
+ guard !credentials.accessKeyID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ !credentials.secretAccessKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ else {
+ throw DoubaoUsageError.missingCredentials
+ }
+
+ let body = Data()
+ var request = URLRequest(url: self.codingPlanAPIURL)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 15
+ request.httpBody = body
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ DoubaoVolcengineSigner.sign(
+ request: &request,
+ body: body,
+ credentials: credentials,
+ date: date)
+
+ let response = try await transport.response(for: request)
+ guard response.statusCode == 200 else {
+ let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: response.data)
+ Self.log.error("Doubao coding plan API returned \(response.statusCode): \(summary)")
+ throw DoubaoUsageError.apiError(response.statusCode, summary)
+ }
+
+ let codingPlanUsage = try Self.decodeCodingPlanUsage(from: response.data)
+ return DoubaoUsageSnapshot(
+ remainingRequests: 0,
+ limitRequests: 0,
+ resetTime: nil,
+ updatedAt: codingPlanUsage.updateTime ?? date,
+ apiKeyValid: true,
+ codingPlanUsage: codingPlanUsage)
+ }
+
+ static func decodeCodingPlanUsage(from data: Data) throws -> DoubaoCodingPlanUsage {
+ let response: CodingPlanUsageResponse
+ do {
+ response = try JSONDecoder().decode(CodingPlanUsageResponse.self, from: data)
+ } catch {
+ throw DoubaoUsageError.parseFailed(error.localizedDescription)
+ }
+ let usage = response.result
+ let quotas = usage.quotaUsage.map { quota in
+ DoubaoCodingPlanUsage.Quota(
+ level: quota.level,
+ percent: quota.percent,
+ resetTime: Self.date(fromEpoch: quota.resetTimestamp))
+ }
+ return DoubaoCodingPlanUsage(
+ status: usage.status,
+ updateTime: Self.date(fromEpoch: usage.updateTimestamp),
+ quotas: quotas)
+ }
+
+ private static func date(fromEpoch timestamp: TimeInterval?) -> Date? {
+ guard let timestamp, timestamp > 0 else { return nil }
+ return Date(timeIntervalSince1970: timestamp)
+ }
+
private static func runArkcliUsagePlan() async throws -> Data {
- guard let arkcliPath = Self.findArkcli() else {
+ guard let arkcliPath = findArkcli() else {
throw DoubaoUsageError.missingCredentials
}
@@ -380,8 +457,14 @@ public struct DoubaoUsageFetcher: Sendable {
return stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}
- private static func findArkcli() -> String? {
- if let envPath = ProcessInfo.processInfo.environment["ARKCLI_PATH"],
+ /// Resolves the arkcli executable path from the given environment (or the
+ /// process environment when omitted). Shared by the CLI strategy's
+ /// availability check and the fetcher's process launcher so both use the
+ /// same path resolution logic.
+ static func findArkcli(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ if let envPath = environment["ARKCLI_PATH"],
FileManager.default.isExecutableFile(atPath: envPath)
{
return envPath
@@ -391,10 +474,8 @@ public struct DoubaoUsageFetcher: Sendable {
"/usr/local/bin/arkcli",
"/opt/homebrew/bin/arkcli",
]
- for path in candidates {
- if FileManager.default.isExecutableFile(atPath: path) {
- return path
- }
+ for path in candidates where FileManager.default.isExecutableFile(atPath: path) {
+ return path
}
return Self.which("arkcli")
}
@@ -704,4 +785,38 @@ public struct DoubaoUsageFetcher: Sendable {
case resetAt = "reset_at"
}
}
+
+ // MARK: - Volcengine signed API response
+
+ private struct CodingPlanUsageResponse: Decodable {
+ let result: ResultPayload
+
+ private enum CodingKeys: String, CodingKey {
+ case result = "Result"
+ }
+ }
+
+ private struct ResultPayload: Decodable {
+ let status: String?
+ let updateTimestamp: TimeInterval?
+ let quotaUsage: [QuotaPayload]
+
+ private enum CodingKeys: String, CodingKey {
+ case status = "Status"
+ case updateTimestamp = "UpdateTimestamp"
+ case quotaUsage = "QuotaUsage"
+ }
+ }
+
+ private struct QuotaPayload: Decodable {
+ let level: String
+ let percent: Double
+ let resetTimestamp: TimeInterval?
+
+ private enum CodingKeys: String, CodingKey {
+ case level = "Level"
+ case percent = "Percent"
+ case resetTimestamp = "ResetTimestamp"
+ }
+ }
}
diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift
index 3c18be78e0..cf4ac4a572 100644
--- a/Tests/CodexBarTests/DoubaoProviderTests.swift
+++ b/Tests/CodexBarTests/DoubaoProviderTests.swift
@@ -79,14 +79,122 @@ struct DoubaoProviderTests {
#expect(DoubaoProviderDescriptor.primaryLabel(window: unavailableWindow) == nil)
}
+ // MARK: - CLI strategy tests
+
@Test
- func `cli failure falls back to ark API key`() async throws {
+ func `cli strategy returns usage from arkcli`() async throws {
let expectedDate = Date(timeIntervalSince1970: 42)
- let context = Self.makeContext(environment: [
- DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
- ])
- let strategy = DoubaoAPIFetchStrategy(
+ let context = Self.makeContext(sourceMode: .cli)
+ let strategy = DoubaoCLIFetchStrategy(
+ cliUsageLoader: {
+ DoubaoUsageSnapshot(
+ remainingRequests: 0,
+ limitRequests: 0,
+ resetTime: nil,
+ updatedAt: expectedDate,
+ apiKeyValid: true,
+ codingPlanUsage: DoubaoCodingPlanUsage(
+ status: "subscribed",
+ updateTime: expectedDate,
+ quotas: [
+ DoubaoCodingPlanUsage.Quota(level: "session", percent: 42.0, resetTime: nil),
+ ]))
+ })
+
+ let result = try await strategy.fetch(context)
+
+ #expect(result.sourceLabel == "cli")
+ #expect(result.strategyID == "doubao.cli")
+ #expect(result.strategyKind == .cli)
+ #expect(result.usage.primary?.usedPercent == 42.0)
+ }
+
+ @Test
+ func `cli strategy falls back to api in auto mode`() {
+ let context = Self.makeContext(sourceMode: .auto)
+ let strategy = DoubaoCLIFetchStrategy(
+ cliUsageLoader: {
+ throw DoubaoProviderTestError.signedFailed
+ })
+
+ #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == true)
+ }
+
+ @Test
+ func `cli strategy does not fall back in explicit cli mode`() {
+ let context = Self.makeContext(sourceMode: .cli)
+ let strategy = DoubaoCLIFetchStrategy(
+ cliUsageLoader: {
+ throw DoubaoProviderTestError.signedFailed
+ })
+
+ #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false)
+ }
+
+ @Test
+ func `cli cancellation does not fall back to api`() {
+ let context = Self.makeContext(sourceMode: .auto)
+ let strategy = DoubaoCLIFetchStrategy(
cliUsageLoader: {
+ throw CancellationError()
+ })
+
+ #expect(strategy.shouldFallback(on: CancellationError(), context: context) == false)
+ }
+
+ // MARK: - API strategy tests
+
+ @Test
+ func `api strategy uses ak/sk signed credentials when available`() async throws {
+ let expectedDate = Date(timeIntervalSince1970: 99)
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { credentials in
+ #expect(credentials.accessKeyID == "AKLTtest")
+ #expect(credentials.secretAccessKey == "secret123")
+ return DoubaoUsageSnapshot(
+ remainingRequests: 0,
+ limitRequests: 0,
+ resetTime: nil,
+ updatedAt: expectedDate,
+ apiKeyValid: true,
+ codingPlanUsage: DoubaoCodingPlanUsage(
+ status: "subscribed",
+ updateTime: expectedDate,
+ quotas: [
+ DoubaoCodingPlanUsage.Quota(level: "session", percent: 15.0, resetTime: nil),
+ ]))
+ },
+ arkUsageLoader: { _ in
+ Issue.record("Ark probe should not run when signed credentials succeed")
+ throw DoubaoProviderTestError.arkShouldNotRun
+ })
+
+ let result = try await strategy.fetch(context)
+
+ #expect(result.sourceLabel == "api")
+ #expect(result.strategyID == "doubao.api")
+ #expect(result.strategyKind == .apiToken)
+ #expect(result.usage.primary?.usedPercent == 15.0)
+ }
+
+ @Test
+ func `api strategy falls back to ark key probe when signed credentials fail`() async throws {
+ let expectedDate = Date(timeIntervalSince1970: 42)
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { _ in
throw DoubaoProviderTestError.signedFailed
},
arkUsageLoader: { apiKey in
@@ -102,19 +210,63 @@ struct DoubaoProviderTests {
let result = try await strategy.fetch(context)
#expect(result.sourceLabel == "api")
- #expect(result.strategyID == "doubao.api")
- #expect(result.usage.updatedAt == expectedDate)
#expect(result.usage.primary?.usedPercent == 30)
#expect(DoubaoProviderDescriptor.primaryLabel(window: result.usage.primary) == "Requests")
}
@Test
- func `cli cancellation does not fall back to ark API key`() async {
- let context = Self.makeContext(environment: [
- DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
- ])
+ func `api strategy does not fall back to cli on failure`() {
+ let context = Self.makeContext(sourceMode: .api)
let strategy = DoubaoAPIFetchStrategy(
- cliUsageLoader: {
+ signedUsageLoader: { _ in
+ throw DoubaoProviderTestError.signedFailed
+ },
+ arkUsageLoader: { _ in
+ throw DoubaoProviderTestError.signedFailed
+ })
+
+ #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false)
+ }
+
+ @Test
+ func `api strategy uses ark key probe when no ak/sk credentials`() async throws {
+ let expectedDate = Date(timeIntervalSince1970: 42)
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { _ in
+ Issue.record("Signed loader should not run without AK/SK credentials")
+ throw DoubaoProviderTestError.signedFailed
+ },
+ arkUsageLoader: { apiKey in
+ #expect(apiKey == "ark-env")
+ return DoubaoUsageSnapshot(
+ remainingRequests: 7,
+ limitRequests: 10,
+ resetTime: expectedDate,
+ updatedAt: expectedDate,
+ apiKeyValid: true)
+ })
+
+ let result = try await strategy.fetch(context)
+
+ #expect(result.sourceLabel == "api")
+ #expect(result.usage.primary?.usedPercent == 30)
+ }
+
+ @Test
+ func `api strategy cancellation does not fall back to ark key`() async {
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { _ in
throw CancellationError()
},
arkUsageLoader: { _ in
@@ -127,11 +279,76 @@ struct DoubaoProviderTests {
}
}
- private static func makeContext(environment: [String: String]) -> ProviderFetchContext {
+ @Test
+ func `api strategy surfaces signed error when no api key available`() async {
+ // AK/SK credentials present but signed request fails, and no Ark API key
+ // is configured. The signed error (not a generic "missing key") should surface.
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { _ in
+ throw DoubaoUsageError.apiError(403, "SignatureExpired")
+ },
+ arkUsageLoader: { _ in
+ Issue.record("Ark probe should not run when no API key is configured")
+ throw DoubaoProviderTestError.arkShouldNotRun
+ })
+
+ await #expect {
+ try await strategy.fetch(context)
+ } throws: { error in
+ guard case let DoubaoUsageError.apiError(code, _) = error else { return false }
+ return code == 403
+ }
+ }
+
+ // MARK: - resolveStrategies routing tests
+
+ @Test
+ func `auto mode returns cli then api strategies`() async {
+ let context = Self.makeContext(sourceMode: .auto)
+ let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context)
+
+ #expect(strategies.count == 2)
+ #expect(strategies[0].id == "doubao.cli")
+ #expect(strategies[0].kind == .cli)
+ #expect(strategies[1].id == "doubao.api")
+ #expect(strategies[1].kind == .apiToken)
+ }
+
+ @Test
+ func `explicit cli mode returns only cli strategy`() async {
+ let context = Self.makeContext(sourceMode: .cli)
+ let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context)
+
+ #expect(strategies.count == 1)
+ #expect(strategies[0].id == "doubao.cli")
+ #expect(strategies[0].kind == .cli)
+ }
+
+ @Test
+ func `explicit api mode returns only api strategy`() async {
+ let context = Self.makeContext(sourceMode: .api)
+ let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context)
+
+ #expect(strategies.count == 1)
+ #expect(strategies[0].id == "doubao.api")
+ #expect(strategies[0].kind == .apiToken)
+ }
+
+ private static func makeContext(
+ sourceMode: ProviderSourceMode = .api,
+ environment: [String: String] = [:])
+ -> ProviderFetchContext
+ {
let browserDetection = BrowserDetection(cacheTTL: 0)
return ProviderFetchContext(
runtime: .app,
- sourceMode: .api,
+ sourceMode: sourceMode,
includeCredits: false,
webTimeout: 1,
webDebugDumpHTML: false,
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 89450ffdbd..13e0258d2a 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -112,8 +112,14 @@ struct DoubaoUsageFetcherTests {
"subscribed": true,
"periods": [
{"label": "5h", "total": 2000, "percent": 0},
- {"label": "weekly", "used": 2009.33, "total": 7000, "percent": 28.7, "reset_at": "2026-07-20T00:00:00+08:00"},
- {"label": "monthly", "used": 2009.33, "total": 20000, "percent": 10.05, "reset_at": "2026-08-14T23:59:59+08:00"}
+ {
+ "label": "weekly", "used": 2009.33, "total": 7000, "percent": 28.7,
+ "reset_at": "2026-07-20T00:00:00+08:00"
+ },
+ {
+ "label": "monthly", "used": 2009.33, "total": 20000, "percent": 10.05,
+ "reset_at": "2026-08-14T23:59:59+08:00"
+ }
]
},
{
@@ -283,6 +289,33 @@ struct DoubaoUsageFetcherTests {
#expect(usage.identity?.loginMethod == "subscribed")
}
+ @Test
+ func `arkcli response accepts updated_at in seconds`() throws {
+ // Real arkcli output (0.1.x) emits `updated_at` in epoch seconds, not
+ // milliseconds. Verify the auto-detection picks the right unit so the
+ // menu doesn't show a 1970 timestamp.
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "session", "percent": 27.3, "reset_at": "2026-07-17T19:22:45+08:00"}
+ ],
+ "updated_at": 1784270829
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_270_829))
+ }
+
@Test
func `arkcli fetch via injected runner returns parsed snapshot`() async throws {
let jsonData = Data(
@@ -317,7 +350,7 @@ struct DoubaoUsageFetcherTests {
_ = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
runArkcli: { Data("not json".utf8) })
} throws: { error in
- guard case let DoubaoUsageError.parseFailed(_) = error else { return false }
+ guard case DoubaoUsageError.parseFailed = error else { return false }
return true
}
}
@@ -437,6 +470,108 @@ struct DoubaoUsageFetcherTests {
}
#expect(await transport.requestCount() == 2)
}
+
+ // MARK: - AK/SK signed Coding Plan usage (legacy Volcengine API)
+
+ @Test
+ func `signed coding plan response decodes quota windows`() throws {
+ let data = Data(
+ """
+ {
+ "Result": {
+ "Status": "active",
+ "UpdateTimestamp": 1784191193.0,
+ "QuotaUsage": [
+ {"Level": "session", "Percent": 7.48, "ResetTimestamp": 1784192000.0},
+ {"Level": "weekly", "Percent": 2.71, "ResetTimestamp": 1784534400.0},
+ {"Level": "monthly", "Percent": 1.36, "ResetTimestamp": 1787040000.0}
+ ]
+ }
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data)
+
+ #expect(usage.status == "active")
+ #expect(usage.updateTime == Date(timeIntervalSince1970: 1_784_191_193))
+ #expect(usage.quotas.count == 3)
+ #expect(usage.quotas[0].level == "session")
+ #expect(usage.quotas[0].percent == 7.48)
+ #expect(usage.quotas[1].level == "weekly")
+ #expect(usage.quotas[1].percent == 2.71)
+ #expect(usage.quotas[2].level == "monthly")
+ #expect(usage.quotas[2].percent == 1.36)
+ }
+
+ @Test
+ func `signed coding plan fetch sends signed request and returns snapshot`() async throws {
+ let body = """
+ {
+ "Result": {
+ "Status": "active",
+ "UpdateTimestamp": 1784191193.0,
+ "QuotaUsage": [
+ {"Level": "session", "Percent": 42.0, "ResetTimestamp": 1784192000.0}
+ ]
+ }
+ }
+ """
+ let transport = DoubaoScriptedTransport(results: [
+ .rawResponse(statusCode: 200, body: body),
+ ])
+ let credentials = DoubaoCodingPlanCredentials(
+ accessKeyID: "AKLTtest",
+ secretAccessKey: "secret123",
+ region: "cn-beijing")
+
+ let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ credentials: credentials,
+ session: transport,
+ date: Date(timeIntervalSince1970: 1_700_000_000))
+
+ #expect(snapshot.codingPlanUsage != nil)
+ #expect(snapshot.codingPlanUsage?.quotas.first?.percent == 42.0)
+
+ // Verify the signed request headers were set.
+ let captured = await transport.lastCapturedRequest()
+ #expect(captured?.date != nil)
+ #expect(captured?.contentSHA256 != nil)
+ #expect(captured?.authorization?.hasPrefix("HMAC-SHA256 Credential=AKLTtest/") == true)
+ #expect(await transport.requestCount() == 1)
+ }
+
+ @Test
+ func `signed coding plan fetch surfaces non-200 error`() async {
+ let transport = DoubaoScriptedTransport(results: [
+ .rawResponse(
+ statusCode: 403,
+ body: #"{"ResponseMetadata":{"Error":{"Code":"SignatureExpired","Message":"signature expired"}}}"#),
+ ])
+ let credentials = DoubaoCodingPlanCredentials(
+ accessKeyID: "AKLTtest",
+ secretAccessKey: "secret123",
+ region: "cn-beijing")
+
+ await #expect {
+ _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ credentials: credentials,
+ session: transport)
+ } throws: { error in
+ guard case let DoubaoUsageError.apiError(code, _) = error else { return false }
+ return code == 403
+ }
+ #expect(await transport.requestCount() == 1)
+ }
+
+ @Test
+ func `signed coding plan decode fails on invalid JSON`() {
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: Data("not json".utf8))
+ } throws: { error in
+ guard case DoubaoUsageError.parseFailed = error else { return false }
+ return true
+ }
+ }
}
private actor DoubaoScriptedTransport: ProviderHTTPTransport {
From 1a18d015d8c9dc8eb9b26c7af0669edba08e687b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 17 Jul 2026 20:49:49 +0100
Subject: [PATCH 06/13] fix: support arkcli JSON variants
---
.../Providers/Doubao/DoubaoUsageFetcher.swift | 31 ++++++++++--
.../DoubaoUsageFetcherTests.swift | 49 +++++++++++++++++++
docs/doubao.md | 2 +-
3 files changed, 78 insertions(+), 4 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index 288d9394b2..9d1df73ef6 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -448,7 +448,7 @@ public struct DoubaoUsageFetcher: Sendable {
// otherwise valid subscribed plan usage.
for period in item.periods ?? [] {
let level = levelPrefix + period.label
- let resetTime = period.resetAt.flatMap(Self.parseISO8601)
+ let resetTime = period.resetAt?.date
allQuotas.append(DoubaoCodingPlanUsage.Quota(
level: level,
percent: period.percent,
@@ -472,7 +472,7 @@ public struct DoubaoUsageFetcher: Sendable {
do {
let result = try await SubprocessRunner.run(
binary: arkcliPath,
- arguments: ["usage", "plan"],
+ arguments: ["usage", "plan", "--format", "json"],
environment: environment,
timeout: 15,
label: "doubao arkcli usage plan")
@@ -810,7 +810,7 @@ public struct DoubaoUsageFetcher: Sendable {
private struct ArkcliPeriod: Decodable {
let label: String
let percent: Double
- let resetAt: String?
+ let resetAt: ArkcliResetAt?
enum CodingKeys: String, CodingKey {
case label
@@ -819,6 +819,31 @@ public struct DoubaoUsageFetcher: Sendable {
}
}
+ private enum ArkcliResetAt: Decodable {
+ case string(String)
+ case number(TimeInterval)
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if let number = try? container.decode(TimeInterval.self) {
+ self = .number(number)
+ } else {
+ self = try .string(container.decode(String.self))
+ }
+ }
+
+ var date: Date? {
+ switch self {
+ case let .string(value):
+ return DoubaoUsageFetcher.parseISO8601(value)
+ case let .number(value):
+ guard value > 0 else { return nil }
+ let seconds = value >= 1e11 ? value / 1000 : value
+ return Date(timeIntervalSince1970: seconds)
+ }
+ }
+ }
+
// MARK: - Volcengine signed API response
private struct CodingPlanUsageResponse: Decodable {
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 56c4b51613..6b5668558b 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -549,6 +549,32 @@ struct DoubaoUsageFetcherTests {
#expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_270_829))
}
+ @Test
+ func `arkcli response accepts numeric reset timestamps and sentinels`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "periods": [
+ {"label": "session", "percent": 10, "reset_at": 1784192000},
+ {"label": "weekly", "percent": 20, "reset_at": 1784534400000},
+ {"label": "monthly", "percent": 30, "reset_at": -1}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_784_192_000))
+ #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_784_534_400))
+ #expect(usage.tertiary?.resetsAt == nil)
+ }
+
@Test
func `arkcli fetch via injected runner returns parsed snapshot`() async throws {
let jsonData = Data(
@@ -577,6 +603,29 @@ struct DoubaoUsageFetcherTests {
#expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193))
}
+ @Test
+ func `arkcli subprocess explicitly requests JSON output`() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-arkcli-arguments-\(UUID().uuidString)", isDirectory: true)
+ let executable = root.appendingPathComponent("arkcli")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try """
+ #!/bin/sh
+ if [ "$*" != "usage plan --format json" ]; then
+ printf '%s\n' "unexpected arguments: $*" >&2
+ exit 2
+ fi
+ printf '%s\n' '{"items":[{"product":"coding-plan","periods":[{"label":"session","percent":42}]}]}'
+ """.write(to: executable, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
+
+ let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ environment: ["ARKCLI_PATH": executable.path])
+
+ #expect(snapshot.codingPlanUsage?.quotas.first?.percent == 42)
+ }
+
@Test
func `arkcli fetch surfaces parse error for invalid JSON`() async {
await #expect {
diff --git a/docs/doubao.md b/docs/doubao.md
index b091bbab05..aeef8310d4 100644
--- a/docs/doubao.md
+++ b/docs/doubao.md
@@ -18,7 +18,7 @@ Doubao reads Coding Plan and Agent Plan quota windows from the official `arkcli`
To keep using API credentials instead, paste an API key or AK/SK pair in provider settings. Environment variables `ARK_API_KEY`, `VOLCENGINE_API_KEY`, and `DOUBAO_API_KEY` remain supported.
## Behavior
-- Auto mode honors configured API credentials first so an ambient arkcli SSO session cannot silently switch accounts. Without configured credentials, it uses `arkcli usage plan`.
+- Auto mode honors configured API credentials first so an ambient arkcli SSO session cannot silently switch accounts. Without configured credentials, it uses `arkcli usage plan --format json`.
- CLI mode uses only `arkcli`; API mode uses only configured AK/SK or Ark API-key credentials.
- `arkcli` output provides distinct personal and team Coding Plan and Agent Plan 5-hour, weekly, and monthly windows when those subscriptions are present.
- Ark API-key endpoint: `POST https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions`
From 29d789c384bd52edf7b243c77a43158d130b4d8f Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 17 Jul 2026 21:37:16 +0100
Subject: [PATCH 07/13] fix: harden arkcli auth and runtime path
---
.../Providers/Doubao/DoubaoUsageFetcher.swift | 35 ++++++++++---
.../DoubaoUsageFetcherTests.swift | 49 ++++++++++++++++++-
2 files changed, 74 insertions(+), 10 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index 9d1df73ef6..ca391504a3 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -417,7 +417,11 @@ public struct DoubaoUsageFetcher: Sendable {
var allQuotas: [DoubaoCodingPlanUsage.Quota] = []
var updateTime: Date?
- var status: String?
+ let authMethod = response.viewer?.authMethod?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if authMethod?.lowercased() == "none" {
+ throw DoubaoUsageError.arkcliAuthenticationRequired
+ }
for item in response.items {
let product = item.product.lowercased()
@@ -439,9 +443,6 @@ public struct DoubaoUsageFetcher: Sendable {
let seconds = updatedAt >= 1e11 ? updatedAt / 1000 : updatedAt
updateTime = updateTime ?? Date(timeIntervalSince1970: seconds)
}
- if item.subscribed == true {
- status = status ?? "subscribed"
- }
// A per-bucket failure is reported as an item with no `periods`
// (often an `error` field). Keep `periods` optional so one failed
// product bucket does not reject the entire stdout and hide the
@@ -461,19 +462,28 @@ public struct DoubaoUsageFetcher: Sendable {
throw DoubaoUsageError.noPlanUsage(itemError.map { Self.compactText($0) })
}
- return DoubaoCodingPlanUsage(status: status, updateTime: updateTime, quotas: allQuotas)
+ return DoubaoCodingPlanUsage(status: authMethod, updateTime: updateTime, quotas: allQuotas)
}
- private static func runArkcliUsagePlan(environment: [String: String]) async throws -> Data {
- guard let arkcliPath = BinaryLocator.resolveArkcliBinary(env: environment) else {
+ static func runArkcliUsagePlan(
+ environment: [String: String],
+ loginPATH: [String]? = LoginShellPathCache.shared.current) async throws -> Data
+ {
+ guard let arkcliPath = BinaryLocator.resolveArkcliBinary(env: environment, loginPATH: loginPATH) else {
throw DoubaoUsageError.arkcliNotFound
}
+ var commandEnvironment = environment
+ commandEnvironment["PATH"] = PathBuilder.effectivePATH(
+ purposes: [.tty, .nodeTooling],
+ env: environment,
+ loginPATH: loginPATH)
+
do {
let result = try await SubprocessRunner.run(
binary: arkcliPath,
arguments: ["usage", "plan", "--format", "json"],
- environment: environment,
+ environment: commandEnvironment,
timeout: 15,
label: "doubao arkcli usage plan")
var output = BoundedOutputBuffer(maxBytes: 256 * 1024)
@@ -788,9 +798,18 @@ public struct DoubaoUsageFetcher: Sendable {
// MARK: - arkcli JSON response
private struct ArkcliUsageResponse: Decodable {
+ let viewer: ArkcliViewer?
let items: [ArkcliUsageItem]
}
+ private struct ArkcliViewer: Decodable {
+ let authMethod: String?
+
+ enum CodingKeys: String, CodingKey {
+ case authMethod = "auth_method"
+ }
+ }
+
private struct ArkcliUsageItem: Decodable {
let product: String
let subscribed: Bool?
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 6b5668558b..2ea48bbabc 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -306,7 +306,7 @@ struct DoubaoUsageFetcherTests {
// Update time from coding-plan's updated_at
#expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193))
#expect(usage.identity?.providerID == .doubao)
- #expect(usage.identity?.loginMethod == "subscribed")
+ #expect(usage.identity?.loginMethod == "sso")
}
@Test
@@ -464,7 +464,27 @@ struct DoubaoUsageFetcherTests {
// The error-only coding item is skipped; the agent item still decodes.
#expect(usage.primary == nil)
#expect(usage.extraRateWindows?.first?.window.usedPercent == 5.0)
- #expect(usage.identity?.loginMethod == "subscribed")
+ #expect(usage.identity?.loginMethod == nil)
+ }
+
+ @Test
+ func `arkcli viewer with no authentication requires login`() {
+ let data = Data(
+ """
+ {
+ "viewer": {"auth_method": "none"},
+ "items": [
+ {"product": "coding-plan", "periods": [{"label": "session", "percent": 5}]}
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case DoubaoUsageError.arkcliAuthenticationRequired = error else { return false }
+ return true
+ }
}
@Test
@@ -626,6 +646,31 @@ struct DoubaoUsageFetcherTests {
#expect(snapshot.codingPlanUsage?.quotas.first?.percent == 42)
}
+ @Test
+ func `arkcli subprocess uses discovery path for node interpreter`() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-arkcli-node-path-\(UUID().uuidString)", isDirectory: true)
+ let executable = root.appendingPathComponent("arkcli")
+ let node = root.appendingPathComponent("node")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try "#!/usr/bin/env node\n".write(to: executable, atomically: true, encoding: .utf8)
+ try """
+ #!/bin/sh
+ printf '%s\n' '{"items":[{"product":"coding-plan","periods":[{"label":"session","percent":42}]}]}'
+ """.write(to: node, atomically: true, encoding: .utf8)
+ for path in [executable.path, node.path] {
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path)
+ }
+
+ let data = try await DoubaoUsageFetcher.runArkcliUsagePlan(
+ environment: ["PATH": "/usr/bin:/bin"],
+ loginPATH: [root.path])
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+
+ #expect(usage.quotas.first?.percent == 42)
+ }
+
@Test
func `arkcli fetch surfaces parse error for invalid JSON`() async {
await #expect {
From 9e6b5c9d3bb349a525e06c5c7316b720646bc1dd Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 17 Jul 2026 22:00:15 +0100
Subject: [PATCH 08/13] fix: enforce arkcli result integrity
---
Sources/CodexBarCore/Hooks/HookRunner.swift | 1 +
.../Host/Process/SubprocessRunner.swift | 26 ++++++--
.../Providers/Doubao/DoubaoUsageFetcher.swift | 21 ++++++-
.../DoubaoUsageFetcherTests.swift | 62 +++++++++++++++++++
4 files changed, 103 insertions(+), 7 deletions(-)
diff --git a/Sources/CodexBarCore/Hooks/HookRunner.swift b/Sources/CodexBarCore/Hooks/HookRunner.swift
index 359ac38ca9..c318fd237d 100644
--- a/Sources/CodexBarCore/Hooks/HookRunner.swift
+++ b/Sources/CodexBarCore/Hooks/HookRunner.swift
@@ -101,6 +101,7 @@ public enum HookRunner {
case .binaryNotFound: return "executable not found"
case .launchFailed: return "launch failed"
case .timedOut: return "timed out"
+ case .outputTooLarge: return "output too large"
case let .nonZeroExit(code, _): return "exit \(code)"
}
}
diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
index 058befb693..506bb4111d 100644
--- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
+++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
@@ -11,6 +11,7 @@ public enum SubprocessRunnerError: LocalizedError, Sendable {
case binaryNotFound(String)
case launchFailed(String)
case timedOut(String)
+ case outputTooLarge(String)
case nonZeroExit(code: Int32, stderr: String)
public var errorDescription: String? {
@@ -21,6 +22,8 @@ public enum SubprocessRunnerError: LocalizedError, Sendable {
return "Failed to launch process: \(details)"
case let .timedOut(label):
return "Command timed out: \(label)"
+ case let .outputTooLarge(label):
+ return "Command produced too much output: \(label)"
case let .nonZeroExit(code, stderr):
let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
@@ -176,6 +179,7 @@ public enum SubprocessRunner {
arguments: [String],
environment: [String: String],
timeout: TimeInterval,
+ maxOutputBytes: Int = 1 * 1024 * 1024,
standardInput: Any? = nil,
currentDirectoryURL: URL? = nil,
acceptsNonZeroExit: Bool = false,
@@ -202,8 +206,12 @@ public enum SubprocessRunner {
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.standardInput = standardInput
- let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe)
- let stderrCapture = ProcessPipeCapture(pipe: stderrPipe)
+ let normalizedMaxOutputBytes = max(0, maxOutputBytes)
+ let captureMaxBytes = normalizedMaxOutputBytes == Int.max
+ ? Int.max
+ : normalizedMaxOutputBytes + 1
+ let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe, maxBytes: captureMaxBytes)
+ let stderrCapture = ProcessPipeCapture(pipe: stderrPipe, maxBytes: captureMaxBytes)
let termination = ProcessTermination()
process.terminationHandler = { process in
@@ -276,8 +284,18 @@ public enum SubprocessRunner {
async let stdoutData = stdoutCapture.finish(timeout: .seconds(1))
async let stderrData = stderrCapture.finish(timeout: .seconds(1))
- let stdout = await ProcessPipeCapture.decodeUTF8(stdoutData)
- let stderr = await ProcessPipeCapture.decodeUTF8(stderrData)
+ let capturedStdout = await stdoutData
+ let capturedStderr = await stderrData
+ guard capturedStdout.count <= normalizedMaxOutputBytes,
+ capturedStderr.count <= normalizedMaxOutputBytes
+ else {
+ self.log.warning(
+ "Subprocess output exceeded memory limit",
+ metadata: ["label": label, "binary": binaryName])
+ throw SubprocessRunnerError.outputTooLarge(label)
+ }
+ let stdout = ProcessPipeCapture.decodeUTF8(capturedStdout)
+ let stderr = ProcessPipeCapture.decodeUTF8(capturedStderr)
if exitCode != 0, !acceptsNonZeroExit {
let duration = Date().timeIntervalSince(start)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index ca391504a3..e85f105f26 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -226,6 +226,7 @@ public enum DoubaoUsageError: LocalizedError, Sendable {
case arkcliTimedOut
case arkcliOutputTooLarge
case arkcliFailed(Int32, String)
+ case incompletePlanUsage(String)
case noPlanUsage(String?)
public var errorDescription: String? {
@@ -248,6 +249,8 @@ public enum DoubaoUsageError: LocalizedError, Sendable {
"arkcli returned too much output. Update arkcli and try again."
case let .arkcliFailed(code, message):
"arkcli usage failed (\(code)): \(message)"
+ case let .incompletePlanUsage(message):
+ "arkcli returned incomplete Coding or Agent Plan usage: \(message)"
case let .noPlanUsage(message):
if let message, !message.isEmpty {
"arkcli returned no usable Coding or Agent Plan usage: \(message)"
@@ -422,6 +425,11 @@ public struct DoubaoUsageFetcher: Sendable {
if authMethod?.lowercased() == "none" {
throw DoubaoUsageError.arkcliAuthenticationRequired
}
+ if let failedSubscription = response.items.first(where: {
+ $0.subscribed == true && $0.periods?.isEmpty != false && $0.error?.isEmpty == false
+ }), let error = failedSubscription.error {
+ throw DoubaoUsageError.incompletePlanUsage(Self.compactText(error))
+ }
for item in response.items {
let product = item.product.lowercased()
@@ -433,7 +441,8 @@ public struct DoubaoUsageFetcher: Sendable {
default: nil
}
guard let levelPrefix else { continue }
- if let updatedAt = item.updatedAt, updatedAt > 0 {
+ let periods = item.periods ?? []
+ if !periods.isEmpty, let updatedAt = item.updatedAt, updatedAt > 0 {
// arkcli has shipped `updated_at` as both epoch milliseconds and
// epoch seconds across versions/plans; detect the unit by
// magnitude so a seconds payload isn't divided into 1970 and a
@@ -441,13 +450,16 @@ public struct DoubaoUsageFetcher: Sendable {
// 1e11 seconds ≈ year 5138, well past any real "seconds" value,
// and 1e11 milliseconds ≈ 1973, well before any real "ms" value.
let seconds = updatedAt >= 1e11 ? updatedAt / 1000 : updatedAt
- updateTime = updateTime ?? Date(timeIntervalSince1970: seconds)
+ let candidate = Date(timeIntervalSince1970: seconds)
+ if updateTime.map({ candidate > $0 }) ?? true {
+ updateTime = candidate
+ }
}
// A per-bucket failure is reported as an item with no `periods`
// (often an `error` field). Keep `periods` optional so one failed
// product bucket does not reject the entire stdout and hide the
// otherwise valid subscribed plan usage.
- for period in item.periods ?? [] {
+ for period in periods {
let level = levelPrefix + period.label
let resetTime = period.resetAt?.date
allQuotas.append(DoubaoCodingPlanUsage.Quota(
@@ -485,6 +497,7 @@ public struct DoubaoUsageFetcher: Sendable {
arguments: ["usage", "plan", "--format", "json"],
environment: commandEnvironment,
timeout: 15,
+ maxOutputBytes: 256 * 1024,
label: "doubao arkcli usage plan")
var output = BoundedOutputBuffer(maxBytes: 256 * 1024)
guard output.append(Data(result.stdout.utf8)) else {
@@ -493,6 +506,8 @@ public struct DoubaoUsageFetcher: Sendable {
return output.data
} catch SubprocessRunnerError.timedOut {
throw DoubaoUsageError.arkcliTimedOut
+ } catch SubprocessRunnerError.outputTooLarge {
+ throw DoubaoUsageError.arkcliOutputTooLarge
} catch let SubprocessRunnerError.nonZeroExit(code, stderr) {
let message = Self.compactText(stderr)
if Self.isArkcliAuthenticationError(message) {
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 2ea48bbabc..9ca5bcaa43 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -467,6 +467,34 @@ struct DoubaoUsageFetcherTests {
#expect(usage.identity?.loginMethod == nil)
}
+ @Test
+ func `arkcli subscribed bucket failure does not silently return partial usage`() {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "subscribed": true,
+ "periods": [{"label": "session", "percent": 5}]
+ },
+ {
+ "product": "agent-plan-team",
+ "subscribed": true,
+ "error": "no seat bound to caller"
+ }
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case let DoubaoUsageError.incompletePlanUsage(message) = error else { return false }
+ return message == "no seat bound to caller"
+ }
+ }
+
@Test
func `arkcli viewer with no authentication requires login`() {
let data = Data(
@@ -623,6 +651,40 @@ struct DoubaoUsageFetcherTests {
#expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193))
}
+ @Test
+ func `arkcli aggregate freshness uses newest contributing bucket`() throws {
+ let olderFirst = Data(
+ """
+ {"items":[
+ {
+ "product":"coding-plan", "updated_at":1784191193,
+ "periods":[{"label":"session","percent":1}]
+ },
+ {
+ "product":"agent-plan", "updated_at":1784191293000,
+ "periods":[{"label":"5h","percent":2}]
+ }
+ ]}
+ """.utf8)
+ let newerFirst = Data(
+ """
+ {"items":[
+ {
+ "product":"agent-plan", "updated_at":1784191293000,
+ "periods":[{"label":"5h","percent":2}]
+ },
+ {
+ "product":"coding-plan", "updated_at":1784191193,
+ "periods":[{"label":"session","percent":1}]
+ }
+ ]}
+ """.utf8)
+
+ let expected = Date(timeIntervalSince1970: 1_784_191_293)
+ #expect(try DoubaoUsageFetcher.decodeArkcliUsage(from: olderFirst).updateTime == expected)
+ #expect(try DoubaoUsageFetcher.decodeArkcliUsage(from: newerFirst).updateTime == expected)
+ }
+
@Test
func `arkcli subprocess explicitly requests JSON output`() async throws {
let root = FileManager.default.temporaryDirectory
From 40faf5c2d67eb28e20ebef04c4f3a5ddab75b067 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 17 Jul 2026 22:07:02 +0100
Subject: [PATCH 09/13] fix: ignore inactive arkcli plans
---
.../Providers/Doubao/DoubaoUsageFetcher.swift | 1 +
.../DoubaoUsageFetcherTests.swift | 24 +++++++++++++++++++
2 files changed, 25 insertions(+)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index e85f105f26..7c15af2faf 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -441,6 +441,7 @@ public struct DoubaoUsageFetcher: Sendable {
default: nil
}
guard let levelPrefix else { continue }
+ guard item.subscribed != false else { continue }
let periods = item.periods ?? []
if !periods.isEmpty, let updatedAt = item.updatedAt, updatedAt > 0 {
// arkcli has shipped `updated_at` as both epoch milliseconds and
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 9ca5bcaa43..2f187e7e59 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -467,6 +467,30 @@ struct DoubaoUsageFetcherTests {
#expect(usage.identity?.loginMethod == nil)
}
+ @Test
+ func `arkcli explicitly unsubscribed bucket does not contribute stale periods`() throws {
+ let data = Data(
+ """
+ {"items":[
+ {
+ "product":"coding-plan", "subscribed":false, "updated_at":1784199993,
+ "periods":[{"label":"session","percent":99}]
+ },
+ {
+ "product":"agent-plan", "subscribed":true, "updated_at":1784191193,
+ "periods":[{"label":"5h","percent":5}]
+ }
+ ]}
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary == nil)
+ #expect(usage.extraRateWindows?.first?.window.usedPercent == 5)
+ #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193))
+ }
+
@Test
func `arkcli subscribed bucket failure does not silently return partial usage`() {
let data = Data(
From ccfcfa0adbe91fd73d11d08b71d59d5ecc33cf01 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 17 Jul 2026 22:14:18 +0100
Subject: [PATCH 10/13] fix: isolate supported arkcli products
---
.../Providers/Doubao/DoubaoUsageFetcher.swift | 16 ++++++++++++--
.../DoubaoUsageFetcherTests.swift | 22 +++++++++++++++++++
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index 7c15af2faf..2230e4e983 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -425,8 +425,17 @@ public struct DoubaoUsageFetcher: Sendable {
if authMethod?.lowercased() == "none" {
throw DoubaoUsageError.arkcliAuthenticationRequired
}
+ let supportedProducts = Set([
+ "agent-plan",
+ "coding-plan",
+ "agent-plan-team",
+ "coding-plan-team",
+ ])
if let failedSubscription = response.items.first(where: {
- $0.subscribed == true && $0.periods?.isEmpty != false && $0.error?.isEmpty == false
+ supportedProducts.contains($0.product.lowercased())
+ && $0.subscribed == true
+ && $0.periods?.isEmpty != false
+ && $0.error?.isEmpty == false
}), let error = failedSubscription.error {
throw DoubaoUsageError.incompletePlanUsage(Self.compactText(error))
}
@@ -471,7 +480,10 @@ public struct DoubaoUsageFetcher: Sendable {
}
guard !allQuotas.isEmpty else {
- let itemError = response.items.lazy.compactMap(\.error).first { !$0.isEmpty }
+ let itemError = response.items.lazy
+ .filter { supportedProducts.contains($0.product.lowercased()) }
+ .compactMap(\.error)
+ .first { !$0.isEmpty }
throw DoubaoUsageError.noPlanUsage(itemError.map { Self.compactText($0) })
}
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 2f187e7e59..47de13238e 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -594,6 +594,28 @@ struct DoubaoUsageFetcherTests {
}
}
+ @Test
+ func `arkcli unrelated product failure does not poison valid plan usage`() throws {
+ let data = Data(
+ """
+ {"items":[
+ {
+ "product":"future-plan", "subscribed":true,
+ "error":"future product unavailable"
+ },
+ {
+ "product":"coding-plan", "subscribed":true,
+ "periods":[{"label":"session","percent":7}]
+ }
+ ]}
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary?.usedPercent == 7)
+ }
+
@Test
func `arkcli response accepts updated_at in seconds`() throws {
// Real arkcli output (0.1.x) emits `updated_at` in epoch seconds, not
From 0323a4bd36d2ea736fd6edb64e2be4866895a9c6 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 17 Jul 2026 23:38:44 +0100
Subject: [PATCH 11/13] fix: translate Italian team label
---
Sources/CodexBar/Resources/it.lproj/Localizable.strings | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings
index 7bd47433bd..e832c916fa 100644
--- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings
@@ -1269,4 +1269,4 @@
"Daily estimated spend" = "Spesa giornaliera stimata";
"Coding Plan" = "Piano di codifica";
"Agent Plan" = "Piano agente";
-"Team" = "Team";
+"Team" = "Squadra";
From f7bdbc4d6eabd3e1e5f4ed6109f18adc07cfa7ea Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Sat, 18 Jul 2026 00:40:36 +0100
Subject: [PATCH 12/13] fix: preserve subprocess output compatibility
---
.../Host/Process/SubprocessRunner.swift | 18 ++++++++-------
.../CodexBarTests/SubprocessRunnerTests.swift | 22 +++++++++++++++++++
2 files changed, 32 insertions(+), 8 deletions(-)
diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
index 506bb4111d..229fa74227 100644
--- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
+++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
@@ -179,7 +179,9 @@ public enum SubprocessRunner {
arguments: [String],
environment: [String: String],
timeout: TimeInterval,
- maxOutputBytes: Int = 1 * 1024 * 1024,
+ // Preserve the legacy bounded-prefix capture when omitted. Structured-output callers can opt into
+ // fail-closed rejection by supplying an explicit limit.
+ maxOutputBytes: Int? = nil,
standardInput: Any? = nil,
currentDirectoryURL: URL? = nil,
acceptsNonZeroExit: Bool = false,
@@ -206,10 +208,10 @@ public enum SubprocessRunner {
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.standardInput = standardInput
- let normalizedMaxOutputBytes = max(0, maxOutputBytes)
- let captureMaxBytes = normalizedMaxOutputBytes == Int.max
- ? Int.max
- : normalizedMaxOutputBytes + 1
+ let normalizedMaxOutputBytes = maxOutputBytes.map { max(0, $0) }
+ let captureMaxBytes = normalizedMaxOutputBytes.map { limit in
+ limit == Int.max ? Int.max : limit + 1
+ } ?? ProcessPipeCapture.defaultMaxBytes
let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe, maxBytes: captureMaxBytes)
let stderrCapture = ProcessPipeCapture(pipe: stderrPipe, maxBytes: captureMaxBytes)
@@ -286,9 +288,9 @@ public enum SubprocessRunner {
async let stderrData = stderrCapture.finish(timeout: .seconds(1))
let capturedStdout = await stdoutData
let capturedStderr = await stderrData
- guard capturedStdout.count <= normalizedMaxOutputBytes,
- capturedStderr.count <= normalizedMaxOutputBytes
- else {
+ if let normalizedMaxOutputBytes,
+ capturedStdout.count > normalizedMaxOutputBytes || capturedStderr.count > normalizedMaxOutputBytes
+ {
self.log.warning(
"Subprocess output exceeded memory limit",
metadata: ["label": label, "binary": binaryName])
diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift
index ae85d8bec2..4eb9184479 100644
--- a/Tests/CodexBarTests/SubprocessRunnerTests.swift
+++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift
@@ -35,6 +35,28 @@ struct SubprocessRunnerTests {
#expect(result.stderr.isEmpty)
}
+ @Test
+ func `rejects oversized output when strict limit is configured`() async throws {
+ do {
+ _ = try await SubprocessRunner.run(
+ binary: "/usr/bin/python3",
+ arguments: ["-c", "print('x' * 10_000)"],
+ environment: ProcessInfo.processInfo.environment,
+ timeout: 5,
+ maxOutputBytes: 1024,
+ label: "python strict output limit")
+ Issue.record("Expected strict output limit failure")
+ } catch let error as SubprocessRunnerError {
+ guard case let .outputTooLarge(label) = error else {
+ Issue.record("Expected outputTooLarge, got \(error)")
+ return
+ }
+ #expect(label == "python strict output limit")
+ } catch {
+ Issue.record("Expected SubprocessRunnerError, got \(error)")
+ }
+ }
+
@Test
func `preserves captured prefix when limit splits three byte scalar`() async throws {
let asciiCount = ProcessPipeCapture.defaultMaxBytes - 1
From 8544cae5298a28882a2b88f4f09b95ee7713af7f Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Sat, 18 Jul 2026 01:01:25 +0100
Subject: [PATCH 13/13] fix: reject incomplete arkcli plan usage
---
.../Providers/Doubao/DoubaoUsageFetcher.swift | 13 +++++----
.../DoubaoUsageFetcherTests.swift | 27 +++++++++++++++++++
2 files changed, 35 insertions(+), 5 deletions(-)
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index 2230e4e983..53d6a213ed 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -431,13 +431,16 @@ public struct DoubaoUsageFetcher: Sendable {
"agent-plan-team",
"coding-plan-team",
])
- if let failedSubscription = response.items.first(where: {
+ if let incompleteSubscription = response.items.first(where: {
supportedProducts.contains($0.product.lowercased())
- && $0.subscribed == true
+ && $0.subscribed != false
&& $0.periods?.isEmpty != false
- && $0.error?.isEmpty == false
- }), let error = failedSubscription.error {
- throw DoubaoUsageError.incompletePlanUsage(Self.compactText(error))
+ }) {
+ let error = incompleteSubscription.error?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let message = error.flatMap { $0.isEmpty ? nil : Self.compactText($0) }
+ ?? "\(incompleteSubscription.product.lowercased()) has no usage periods"
+ throw DoubaoUsageError.incompletePlanUsage(message)
}
for item in response.items {
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index 47de13238e..4524d25939 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -519,6 +519,33 @@ struct DoubaoUsageFetcherTests {
}
}
+ @Test
+ func `arkcli active empty bucket without error does not silently return partial usage`() {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "periods": [{"label": "session", "percent": 5}]
+ },
+ {
+ "product": "agent-plan",
+ "subscribed": true,
+ "periods": []
+ }
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case let DoubaoUsageError.incompletePlanUsage(message) = error else { return false }
+ return message == "agent-plan has no usage periods"
+ }
+ }
+
@Test
func `arkcli viewer with no authentication requires login`() {
let data = Data(