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 @@ -Doubao + + Doubao + + + + + 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(