From eee464d01e5c635e99562edcea61b0bf33096eca Mon Sep 17 00:00:00 2001 From: LeoLin Date: Tue, 23 Jun 2026 23:07:28 +0800 Subject: [PATCH 01/10] Add Doubao coding plan usage --- .../Doubao/DoubaoProviderImplementation.swift | 30 +++- .../Doubao/DoubaoSettingsStore.swift | 20 +++ .../Config/ProviderConfigEnvironment.swift | 29 +++ .../Doubao/DoubaoProviderDescriptor.swift | 47 +++-- .../Doubao/DoubaoSettingsReader.swift | 72 ++++++-- .../Providers/Doubao/DoubaoUsageFetcher.swift | 166 +++++++++++++++++- .../Doubao/DoubaoVolcengineSigner.swift | 150 ++++++++++++++++ .../DoubaoUsageFetcherTests.swift | 109 ++++++++++++ .../ProviderConfigEnvironmentTests.swift | 20 +++ 9 files changed, 614 insertions(+), 29 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Doubao/DoubaoVolcengineSigner.swift diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift index ba91a2fc5f..182ff06cc3 100644 --- a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift @@ -8,6 +8,8 @@ struct DoubaoProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.doubaoAPIToken + _ = settings.doubaoSecretAccessKey + _ = settings.doubaoRegion } @MainActor @@ -15,11 +17,11 @@ struct DoubaoProviderImplementation: ProviderImplementation { [ ProviderSettingsFieldDescriptor( id: "doubao-api-token", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine " - + "Ark console.", + 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.", kind: .secure, - placeholder: "ark-...", + placeholder: "ark-... or AKLT...", binding: context.stringBinding(\.doubaoAPIToken), actions: [ ProviderSettingsActionDescriptor( @@ -35,6 +37,26 @@ struct DoubaoProviderImplementation: ProviderImplementation { ], isVisible: nil, onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "doubao-secret-access-key", + title: "Secret access key", + subtitle: "Volcengine secret access key for the signed Coding Plan usage API.", + kind: .secure, + placeholder: "", + binding: context.stringBinding(\.doubaoSecretAccessKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "doubao-region", + title: "Region", + subtitle: "Volcengine Ark region. Defaults to cn-beijing.", + kind: .plain, + placeholder: DoubaoSettingsReader.defaultRegion, + binding: context.stringBinding(\.doubaoRegion), + actions: [], + isVisible: nil, + onActivate: nil), ] } } diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift index 4d69a273f1..7313926b24 100644 --- a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift +++ b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift @@ -11,4 +11,24 @@ extension SettingsStore { self.logSecretUpdate(provider: .doubao, field: "apiKey", value: newValue) } } + + var doubaoSecretAccessKey: String { + get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedSecretKey ?? "" } + set { + self.updateProviderConfig(provider: .doubao) { entry in + entry.secretKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .doubao, field: "secretAccessKey", value: newValue) + } + } + + var doubaoRegion: String { + get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedRegion ?? "" } + set { + self.updateProviderConfig(provider: .doubao) { entry in + entry.region = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .doubao, field: "region", value: newValue) + } + } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index e157db62b0..1b92fa1b16 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -95,6 +95,8 @@ public enum ProviderConfigEnvironment { self.applyAzureOpenAIOverrides(base: base, config: config) case .kimi: self.applyKimiOverrides(base: base, config: config) + case .doubao: + self.applyDoubaoOverrides(base: base, config: config) default: nil } @@ -285,6 +287,33 @@ public enum ProviderConfigEnvironment { return env } + private static func applyDoubaoOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + guard let config else { return base } + var env = base + let apiKey = config.sanitizedAPIKey + let secretKey = config.sanitizedSecretKey + + if let apiKey, let secretKey { + env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] = apiKey + env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] = secretKey + if let region = config.sanitizedRegion { + env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region + } + return env + } + + if let apiKey { + env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] = apiKey + } + if let region = config.sanitizedRegion { + env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region + } + return env + } + private static func applyAzureOpenAIOverrides( base: [String: String], config: ProviderConfig?) -> [String: String] diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift index c221f530d3..021c76524f 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -9,10 +9,10 @@ public enum DoubaoProviderDescriptor { metadata: ProviderMetadata( id: .doubao, displayName: "Doubao", - sessionLabel: "Requests", - weeklyLabel: "Rate limit", - opusLabel: nil, - supportsOpus: false, + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: "Monthly", + supportsOpus: true, supportsCredits: false, creditsHint: "", toggleTitle: "Show Doubao usage", @@ -30,16 +30,41 @@ public enum DoubaoProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Doubao cost summary is not available." }), - fetchPlan: .apiToken( - strategyID: "doubao.api", - resolveToken: { ProviderTokenResolver.doubaoToken(environment: $0) }, - missingCredentialsError: { DoubaoUsageError.missingCredentials }, - loadUsage: { apiKey, _ in - try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() - }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [DoubaoAPIFetchStrategy()] + })), cli: ProviderCLIConfig( name: "doubao", aliases: ["volcengine", "ark", "bytedance"], versionDetector: nil)) } } + +struct DoubaoAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "doubao.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil || + ProviderTokenResolver.doubaoToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + if let credentials = DoubaoSettingsReader.codingPlanCredentials(environment: context.env) { + let usage = try await DoubaoUsageFetcher.fetchCodingPlanUsage(credentials: credentials) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + } + + guard let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env) else { + throw DoubaoUsageError.missingCredentials + } + let usage = try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift index fc5942e546..ff3de05ef6 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift @@ -6,31 +6,77 @@ public struct DoubaoSettingsReader: Sendable { "VOLCENGINE_API_KEY", "DOUBAO_API_KEY", ] + public static let accessKeyIDEnvironmentKeys = [ + "VOLCENGINE_ACCESS_KEY_ID", + "VOLCENGINE_ACCESS_KEY", + "DOUBAO_ACCESS_KEY_ID", + ] + public static let secretAccessKeyEnvironmentKeys = [ + "VOLCENGINE_SECRET_ACCESS_KEY", + "VOLCENGINE_ACCESS_KEY_SECRET", + "DOUBAO_SECRET_ACCESS_KEY", + ] + public static let regionEnvironmentKeys = [ + "VOLCENGINE_REGION", + "VOLCENGINE_REGION_ID", + "DOUBAO_REGION", + ] + public static let defaultRegion = "cn-beijing" public static func apiKey( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - for key in self.apiKeyEnvironmentKeys { - guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty - else { - continue - } - let cleaned = Self.cleaned(raw) - if !cleaned.isEmpty { - return cleaned - } + self.firstValue(in: environment, keys: self.apiKeyEnvironmentKeys) + } + + public static func accessKeyID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.firstValue(in: environment, keys: self.accessKeyIDEnvironmentKeys) + } + + public static func secretAccessKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.firstValue(in: environment, keys: self.secretAccessKeyEnvironmentKeys) + } + + public static func region(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + self.firstValue(in: environment, keys: self.regionEnvironmentKeys) ?? self.defaultRegion + } + + public static func codingPlanCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> DoubaoCodingPlanCredentials? + { + guard let accessKeyID = self.accessKeyID(environment: environment), + let secretAccessKey = self.secretAccessKey(environment: environment) + else { + return nil + } + return DoubaoCodingPlanCredentials( + accessKeyID: accessKeyID, + secretAccessKey: secretAccessKey, + region: self.region(environment: environment)) + } + + private static func firstValue(in environment: [String: String], keys: [String]) -> String? { + for key in keys { + guard let cleaned = self.cleaned(environment[key]) else { continue } + return cleaned } return nil } - private static func cleaned(_ raw: String) -> String { - var value = raw + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { value = String(value.dropFirst().dropLast()) } - return value.trimmingCharacters(in: .whitespacesAndNewlines) + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value } } diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index d1273b24d6..742587f30a 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -11,6 +11,7 @@ public struct DoubaoUsageSnapshot: Sendable { public let apiKeyValid: Bool public let totalTokens: Int? public let requestLimitsReliable: Bool + public let codingPlanUsage: DoubaoCodingPlanUsage? public init( remainingRequests: Int, limitRequests: Int, @@ -18,7 +19,8 @@ public struct DoubaoUsageSnapshot: Sendable { updatedAt: Date, apiKeyValid: Bool = false, totalTokens: Int? = nil, - requestLimitsReliable: Bool = true) + requestLimitsReliable: Bool = true, + codingPlanUsage: DoubaoCodingPlanUsage? = nil) { self.remainingRequests = remainingRequests self.limitRequests = limitRequests @@ -27,9 +29,14 @@ public struct DoubaoUsageSnapshot: Sendable { self.apiKeyValid = apiKeyValid self.totalTokens = totalTokens self.requestLimitsReliable = requestLimitsReliable + self.codingPlanUsage = codingPlanUsage } public func toUsageSnapshot() -> UsageSnapshot { + if let codingPlanUsage { + return codingPlanUsage.toUsageSnapshot(updatedAt: self.updatedAt) + } + let primary: RateWindow? if self.limitRequests > 0, self.requestLimitsReliable { let used = max(0, self.limitRequests - self.remainingRequests) @@ -66,6 +73,69 @@ public struct DoubaoUsageSnapshot: Sendable { } } +public struct DoubaoCodingPlanUsage: Sendable, Equatable { + public struct Quota: Sendable, Equatable { + public let level: String + public let percent: Double + public let resetTime: Date? + + public init(level: String, percent: Double, resetTime: Date?) { + self.level = level + self.percent = percent + self.resetTime = resetTime + } + } + + public let status: String? + public let updateTime: Date? + public let quotas: [Quota] + + public init(status: String?, updateTime: Date?, quotas: [Quota]) { + self.status = status + self.updateTime = updateTime + self.quotas = quotas + } + + 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 identity = ProviderIdentitySnapshot( + providerID: .doubao, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.status) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + providerCost: nil, + updatedAt: self.updateTime ?? updatedAt, + identity: identity) + } + + private func rateWindow(levels: Set, minutes: Int) -> RateWindow? { + guard let quota = self.quotas.first(where: { levels.contains($0.level.lowercased()) }) else { + return nil + } + let percent = min(100, max(0, quota.percent)) + return RateWindow( + usedPercent: percent, + windowMinutes: minutes, + resetsAt: quota.resetTime, + resetDescription: "\(Self.formatPercent(percent))% used") + } + + private static func formatPercent(_ percent: Double) -> String { + let rounded = (percent * 100).rounded() / 100 + if rounded.rounded() == rounded { + return String(Int(rounded)) + } + return String(format: "%.2f", rounded) + } +} + public enum DoubaoUsageError: LocalizedError, Sendable { case missingCredentials case networkError(String) @@ -89,6 +159,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")! /// Models to probe, ordered by likelihood. We try multiple models because /// different key types may not have access to every model. @@ -143,6 +215,66 @@ public struct DoubaoUsageFetcher: Sendable { throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed") } + 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: quota.resetTimestamp.map(Date.init(timeIntervalSince1970:))) + } + return DoubaoCodingPlanUsage( + status: usage.status, + updateTime: usage.updateTimestamp.map(Date.init(timeIntervalSince1970:)), + quotas: quotas) + } + private static func confirmAmbiguousZeroRemaining( initial: ProbeResult, apiKey: String, @@ -366,4 +498,36 @@ public struct DoubaoUsageFetcher: Sendable { let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength) return "\(collapsed[.. String + { + let dateKey = Self.hmac(key: SymmetricKey(data: Data(secretAccessKey.utf8)), message: dateStamp) + let regionKey = Self.hmac(key: SymmetricKey(data: dateKey), message: region) + let serviceKey = Self.hmac(key: SymmetricKey(data: regionKey), message: Self.service) + let signingKey = Self.hmac(key: SymmetricKey(data: serviceKey), message: Self.terminator) + let signature = HMAC.authenticationCode( + for: Data(stringToSign.utf8), + using: SymmetricKey(data: signingKey)) + return Data(signature).map { String(format: "%02x", $0) }.joined() + } + + private static func hmac(key: SymmetricKey, message: String) -> Data { + let digest = HMAC.authenticationCode(for: Data(message.utf8), using: key) + return Data(digest) + } + + private static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func canonicalURI(_ url: URL) -> String { + let path = url.path.isEmpty ? "/" : url.path + return Self.percentEncode(path, encodeSlash: false) + } + + private static func canonicalQueryString(_ url: URL) -> String { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + !queryItems.isEmpty + else { + return "" + } + var pairs: [(key: String, value: String)] = queryItems.map { item in + ( + key: Self.percentEncode(item.name), + value: Self.percentEncode(item.value ?? "")) + } + pairs.sort { lhs, rhs in + lhs.key == rhs.key ? lhs.value < rhs.value : lhs.key < rhs.key + } + return pairs + .map { pair in "\(pair.key)=\(pair.value)" } + .joined(separator: "&") + } + + private static func percentEncode(_ value: String, encodeSlash: Bool = true) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-_.~") + if !encodeSlash { + allowed.insert("/") + } + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + return formatter + }() + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyyMMdd" + return formatter + }() +} diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index ce727088a9..b86f61c3db 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -97,6 +97,86 @@ struct DoubaoUsageSnapshotTests { } struct DoubaoUsageFetcherTests { + @Test + func `coding plan response maps session weekly and monthly windows`() throws { + let data = Data( + """ + { + "ResponseMetadata": { + "Action": "GetCodingPlanUsage", + "Version": "2024-01-01", + "Service": "ark", + "Region": "cn-beijing" + }, + "Result": { + "Status": "Running", + "UpdateTimestamp": 1782226444, + "QuotaUsage": [ + {"Level":"session","Percent":0.116,"ResetTimestamp":1782226478}, + {"Level":"weekly","Percent":3.182143,"ResetTimestamp":1782662400}, + {"Level":"monthly","Percent":7.5730535,"ResetTimestamp":1782403199} + ] + } + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data).toUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(usage.primary?.usedPercent == 0.116) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_782_226_478)) + #expect(usage.primary?.resetDescription == "0.12% used") + #expect(usage.secondary?.usedPercent == 3.182143) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 7.5730535) + #expect(usage.tertiary?.windowMinutes == 43200) + #expect(usage.identity?.providerID == .doubao) + #expect(usage.identity?.loginMethod == "Running") + } + + @Test + func `coding plan fetch signs volcengine request`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: """ + { + "Result": { + "Status": "Running", + "UpdateTimestamp": 1782226444, + "QuotaUsage": [ + {"Level":"session","Percent":12.5,"ResetTimestamp":1782226478} + ] + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + let date = Date(timeIntervalSince1970: 1_781_654_400) + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: date) + let request = await transport.lastCapturedRequest() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + #expect(request?.method == "POST") + #expect(request?.url == "https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01") + #expect(request?.host == "open.volcengineapi.com") + #expect(request?.date == "20260617T000000Z") + #expect(request?.contentSHA256 == + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + #expect(request?.authorization?.contains( + "HMAC-SHA256 Credential=AKLTTEST/20260617/cn-beijing/ark/request") == true) + #expect(request?.authorization?.contains( + "SignedHeaders=host;x-date;x-content-sha256;content-type") == true) + } + @Test func `repeated successful zero remaining responses omit unknown request limit`() async throws { let transport = DoubaoScriptedTransport(results: [ @@ -217,12 +297,23 @@ struct DoubaoUsageFetcherTests { private actor DoubaoScriptedTransport: ProviderHTTPTransport { enum Result { case response(statusCode: Int, limit: Int?, remaining: Int?) + case rawResponse(statusCode: Int, body: String) case failure(URLError) case cancellation } + struct CapturedRequest: Sendable { + let url: String? + let method: String? + let host: String? + let date: String? + let contentSHA256: String? + let authorization: String? + } + private var results: [Result] private var requests = 0 + private var capturedRequest: CapturedRequest? init(results: [Result]) { self.results = results @@ -232,8 +323,19 @@ private actor DoubaoScriptedTransport: ProviderHTTPTransport { self.requests } + func lastCapturedRequest() -> CapturedRequest? { + self.capturedRequest + } + func data(for request: URLRequest) throws -> (Data, URLResponse) { self.requests += 1 + self.capturedRequest = CapturedRequest( + url: request.url?.absoluteString, + method: request.httpMethod, + host: request.value(forHTTPHeaderField: "Host"), + date: request.value(forHTTPHeaderField: "X-Date"), + contentSHA256: request.value(forHTTPHeaderField: "X-Content-Sha256"), + authorization: request.value(forHTTPHeaderField: "Authorization")) let result = self.results.removeFirst() switch result { case let .response(statusCode, limit, remaining): @@ -250,6 +352,13 @@ private actor DoubaoScriptedTransport: ProviderHTTPTransport { httpVersion: "HTTP/1.1", headerFields: headers)! return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response) + case let .rawResponse(statusCode, body): + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: [:])! + return (Data(body.utf8), response) case let .failure(error): throw error case .cancellation: diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 084a591643..247ecfd431 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -63,6 +63,26 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") } + @Test + func `applies volcengine access key override for doubao coding plan`() { + let config = ProviderConfig( + id: .doubao, + apiKey: "AKLT-config", + secretKey: "sk-config", + region: "cn-shanghai") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == "AKLT-config") + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == "sk-config") + #expect(env[DoubaoSettingsReader.regionEnvironmentKeys[0]] == "cn-shanghai") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-shanghai") + } + @Test func `applies API key override for moonshot`() { let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") From eefcab4092d0bf5506c36fb736bd35f95411a7a5 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 01:57:30 +0800 Subject: [PATCH 02/10] fix(doubao): sort Volcengine V4 canonical headers alphabetically The signer emitted canonical headers / SignedHeaders as host;x-date;x-content-sha256;content-type. Volcengine V4 (AWS SigV4 derived) requires them sorted by lower-cased header name, and the server re-sorts + recomputes the signature, so the unsorted order would yield a signature mismatch (HTTP 403) on live GetCodingPlanUsage calls. Sort to content-type;host;x-content-sha256;x-date and update the test expectation. Note: the signing test asserts structure only (no golden vector), so this still needs a live AK/SK request to confirm the server accepts it. --- .../Providers/Doubao/DoubaoVolcengineSigner.swift | 9 ++++++--- Tests/CodexBarTests/DoubaoUsageFetcherTests.swift | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoVolcengineSigner.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoVolcengineSigner.swift index 882f774faf..ab6448957f 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoVolcengineSigner.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoVolcengineSigner.swift @@ -24,7 +24,10 @@ enum DoubaoVolcengineSigner { private static let algorithm = "HMAC-SHA256" private static let service = "ark" private static let terminator = "request" - private static let signedHeaders = "host;x-date;x-content-sha256;content-type" + /// Canonical/signed headers must be sorted alphabetically by lower-cased name + /// (Volcengine V4, like AWS SigV4); the server re-sorts and recomputes, so an + /// unsorted list yields a signature mismatch (HTTP 403). + private static let signedHeaders = "content-type;host;x-content-sha256;x-date" static func sign( request: inout URLRequest, @@ -48,10 +51,10 @@ enum DoubaoVolcengineSigner { request.httpMethod ?? "POST", Self.canonicalURI(url), Self.canonicalQueryString(url), + "content-type:\(contentType)", "host:\(host)", - "x-date:\(timestamp)", "x-content-sha256:\(payloadHash)", - "content-type:\(contentType)", + "x-date:\(timestamp)", "", Self.signedHeaders, payloadHash, diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index b86f61c3db..d8a0934a9c 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -174,7 +174,7 @@ struct DoubaoUsageFetcherTests { #expect(request?.authorization?.contains( "HMAC-SHA256 Credential=AKLTTEST/20260617/cn-beijing/ark/request") == true) #expect(request?.authorization?.contains( - "SignedHeaders=host;x-date;x-content-sha256;content-type") == true) + "SignedHeaders=content-type;host;x-content-sha256;x-date") == true) } @Test @@ -302,7 +302,7 @@ private actor DoubaoScriptedTransport: ProviderHTTPTransport { case cancellation } - struct CapturedRequest: Sendable { + struct CapturedRequest { let url: String? let method: String? let host: String? From 18346a878ab4fa9ffefd080b66091946b0fc9de2 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 14:44:24 +0800 Subject: [PATCH 03/10] fix(doubao): merge coding plan credentials --- .../Config/CodexBarConfigValidation.swift | 7 +-- .../Config/ProviderConfigEnvironment.swift | 18 ++++++-- .../CodexBarTests/ConfigValidationTests.swift | 14 ++++++ .../ProviderConfigEnvironmentTests.swift | 43 +++++++++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index 7d45cffc6c..c534baaa06 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -178,7 +178,8 @@ public enum CodexBarConfigValidator { private static func validateSecretKey(_ entry: ProviderConfig, issues: inout [CodexBarConfigIssue]) { guard let secretKey = entry.secretKey, !secretKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - entry.id != .bedrock + entry.id != .bedrock, + entry.id != .doubao else { return } @@ -188,7 +189,7 @@ public enum CodexBarConfigValidator { provider: entry.id, field: "secretKey", code: "secret_key_unused", - message: "secretKey is set but only bedrock uses secretKey.")) + message: "secretKey is set but only bedrock and doubao use secretKey.")) } private static func providerSupportsWorkspaceID(_ provider: UsageProvider) -> Bool { @@ -252,7 +253,7 @@ public enum CodexBarConfigValidator { isValid: MoonshotRegion(rawValue: region) != nil, displayName: "Moonshot", issues: &issues) - case .bedrock: + case .bedrock, .doubao: break default: issues.append(CodexBarConfigIssue( diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 1b92fa1b16..f7a685b4d0 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -295,11 +295,13 @@ public enum ProviderConfigEnvironment { var env = base let apiKey = config.sanitizedAPIKey let secretKey = config.sanitizedSecretKey + let accessKeyID = apiKey ?? DoubaoSettingsReader.accessKeyID(environment: base) + let secretAccessKey = secretKey ?? DoubaoSettingsReader.secretAccessKey(environment: base) - if let apiKey, let secretKey { - env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] = apiKey - env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] = secretKey - if let region = config.sanitizedRegion { + if let accessKeyID, let secretAccessKey { + env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] = accessKeyID + env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] = secretAccessKey + if let region = config.sanitizedRegion ?? self.firstDoubaoRegionValue(in: base) { env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region } return env @@ -314,6 +316,14 @@ public enum ProviderConfigEnvironment { return env } + private static func firstDoubaoRegionValue(in environment: [String: String]) -> String? { + for key in DoubaoSettingsReader.regionEnvironmentKeys { + guard let value = DoubaoSettingsReader.cleaned(environment[key]) else { continue } + return value + } + return nil + } + private static func applyAzureOpenAIOverrides( base: [String: String], config: ProviderConfig?) -> [String: String] diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 37c2ed90ab..7d9130b106 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -101,6 +101,20 @@ struct ConfigValidationTests { #expect(!issues.contains(where: { $0.provider == .openai && $0.code == "workspace_unused" })) } + @Test + func `allows doubao coding plan credential fields`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig( + id: .doubao, + apiKey: "AKLT-config", + secretKey: "sk-config", + region: "cn-shanghai")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(!issues.contains(where: { $0.provider == .doubao && $0.code == "secret_key_unused" })) + #expect(!issues.contains(where: { $0.provider == .doubao && $0.code == "region_unused" })) + } + @Test func `warns on unsupported workspace ID`() { var config = CodexBarConfig.makeDefault() diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 247ecfd431..05edf37eec 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -83,6 +83,49 @@ struct ProviderConfigEnvironmentTests { #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-shanghai") } + @Test + func `merges doubao config access key with environment secret key`() { + let config = ProviderConfig( + id: .doubao, + apiKey: "AKLT-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + DoubaoSettingsReader.regionEnvironmentKeys[2]: "cn-shanghai", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == "AKLT-config") + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == "sk-env") + #expect(env[DoubaoSettingsReader.regionEnvironmentKeys[0]] == "cn-shanghai") + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-shanghai") + } + + @Test + func `merges doubao environment access key with config secret key`() { + let config = ProviderConfig( + id: .doubao, + secretKey: "sk-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env", + DoubaoSettingsReader.regionEnvironmentKeys[1]: "cn-beijing", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == "AKLT-env") + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == "sk-config") + #expect(env[DoubaoSettingsReader.regionEnvironmentKeys[0]] == "cn-beijing") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-config") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-beijing") + } + @Test func `applies API key override for moonshot`() { let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") From decf7f4f668a3d7f77f808c5c46de1ccc07acb1e Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 15:41:01 +0800 Subject: [PATCH 04/10] fix(doubao): preserve ark bearer override --- .../Config/ProviderConfigEnvironment.swift | 26 ++++++++- .../ProviderConfigEnvironmentTests.swift | 54 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index f7a685b4d0..534b95da63 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -295,7 +295,17 @@ public enum ProviderConfigEnvironment { var env = base let apiKey = config.sanitizedAPIKey let secretKey = config.sanitizedSecretKey - let accessKeyID = apiKey ?? DoubaoSettingsReader.accessKeyID(environment: base) + + if let apiKey, self.doubaoAccessKeyID(from: apiKey) == nil { + self.clearDoubaoCodingPlanCredentialKeys(in: &env) + env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] = apiKey + if let region = config.sanitizedRegion { + env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region + } + return env + } + + let accessKeyID = self.doubaoAccessKeyID(from: apiKey) ?? DoubaoSettingsReader.accessKeyID(environment: base) let secretAccessKey = secretKey ?? DoubaoSettingsReader.secretAccessKey(environment: base) if let accessKeyID, let secretAccessKey { @@ -316,6 +326,20 @@ public enum ProviderConfigEnvironment { return env } + private static func doubaoAccessKeyID(from apiKey: String?) -> String? { + guard let apiKey, apiKey.hasPrefix("AKLT") else { return nil } + return apiKey + } + + private static func clearDoubaoCodingPlanCredentialKeys(in environment: inout [String: String]) { + for key in DoubaoSettingsReader.accessKeyIDEnvironmentKeys { + environment.removeValue(forKey: key) + } + for key in DoubaoSettingsReader.secretAccessKeyEnvironmentKeys { + environment.removeValue(forKey: key) + } + } + private static func firstDoubaoRegionValue(in environment: [String: String]) -> String? { for key in DoubaoSettingsReader.regionEnvironmentKeys { guard let value = DoubaoSettingsReader.cleaned(environment[key]) else { continue } diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 20ea635000..3e01d35693 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -65,6 +65,60 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") } + @Test + func `preserves doubao ark API key when environment secret key is present`() { + let config = ProviderConfig(id: .doubao, apiKey: "ark-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-config") + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-config") + } + + @Test + func `preserves doubao ark API key when config secret key is present`() { + let config = ProviderConfig( + id: .doubao, + apiKey: "ark-config", + secretKey: "sk-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-config") + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-config") + } + + @Test + func `doubao ark API key config overrides environment coding plan credentials`() { + let config = ProviderConfig(id: .doubao, apiKey: "ark-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + DoubaoSettingsReader.regionEnvironmentKeys[0]: "cn-shanghai", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-config") + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-config") + } + @Test func `applies volcengine access key override for doubao coding plan`() { let config = ProviderConfig( From b63582afd7dcd1f1c6ec5a16f2a9dfc7fd632dbb Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 16:15:04 +0800 Subject: [PATCH 05/10] fix(doubao): avoid incomplete access key fallback --- .../Config/ProviderConfigEnvironment.swift | 3 -- .../Doubao/DoubaoSettingsReader.swift | 1 + .../ProviderConfigEnvironmentTests.swift | 43 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 534b95da63..1b607ad13a 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -317,9 +317,6 @@ public enum ProviderConfigEnvironment { return env } - if let apiKey { - env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] = apiKey - } if let region = config.sanitizedRegion { env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region } diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift index ff3de05ef6..5a4caee351 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift @@ -13,6 +13,7 @@ public struct DoubaoSettingsReader: Sendable { ] public static let secretAccessKeyEnvironmentKeys = [ "VOLCENGINE_SECRET_ACCESS_KEY", + "VOLCENGINE_SECRET_KEY", "VOLCENGINE_ACCESS_KEY_SECRET", "DOUBAO_SECRET_ACCESS_KEY", ] diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 3e01d35693..bb76b569d4 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -119,6 +119,49 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-config") } + @Test + func `reads doubao volcengine secret key alias`() { + let env = [ + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[1]: "AKLT-env", + "VOLCENGINE_SECRET_KEY": "sk-env", + ] + + #expect(DoubaoSettingsReader.secretAccessKeyEnvironmentKeys.contains("VOLCENGINE_SECRET_KEY")) + #expect(DoubaoSettingsReader.secretAccessKey(environment: env) == "sk-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-env") + } + + @Test + func `does not project incomplete doubao access key as ark API key`() { + let config = ProviderConfig(id: .doubao, apiKey: "AKLT-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == nil) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == nil) + } + + @Test + func `keeps base doubao ark API key when config access key lacks secret`() { + let config = ProviderConfig(id: .doubao, apiKey: "AKLT-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env", + ], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]] == nil) + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "ark-env") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env) == nil) + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "ark-env") + } + @Test func `applies volcengine access key override for doubao coding plan`() { let config = ProviderConfig( From f17026c97bdd09b3752010cf647dfd3cfcb27f8c Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 17:03:44 +0800 Subject: [PATCH 06/10] fix(doubao): fall back to ark after signed failure --- .../Doubao/DoubaoProviderDescriptor.swift | 39 +++++++- Tests/CodexBarTests/DoubaoProviderTests.swift | 90 ++++++++++++++++++- 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift index 021c76524f..5dbcb3918c 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -45,6 +45,21 @@ 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 arkUsageLoader: @Sendable (String) async throws -> DoubaoUsageSnapshot + + init( + codingPlanUsageLoader: @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.codingPlanUsageLoader = codingPlanUsageLoader + self.arkUsageLoader = arkUsageLoader + } func isAvailable(_ context: ProviderFetchContext) async -> Bool { DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil || @@ -52,19 +67,35 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env) if let credentials = DoubaoSettingsReader.codingPlanCredentials(environment: context.env) { - let usage = try await DoubaoUsageFetcher.fetchCodingPlanUsage(credentials: credentials) - return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + 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") + } } - guard let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env) else { + guard let apiKey else { throw DoubaoUsageError.missingCredentials } - let usage = try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey) + let usage = try await self.arkUsageLoader(apiKey) return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } + + private static func isCancellation(_ error: Error) -> Bool { + error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled + } } diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift index bc69da7b76..35c869df22 100644 --- a/Tests/CodexBarTests/DoubaoProviderTests.swift +++ b/Tests/CodexBarTests/DoubaoProviderTests.swift @@ -1,6 +1,25 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore + +private enum DoubaoProviderTestError: Error { + case signedFailed + case arkShouldNotRun +} + +private struct DoubaoProviderTestClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw DoubaoProviderTestError.signedFailed + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} struct DoubaoProviderTests { @Test @@ -36,4 +55,73 @@ struct DoubaoProviderTests { #expect(usage.primary == nil) #expect(usage.rateLimitsUnavailable(for: .doubao)) } + + @Test + func `signed credential failure falls back to ark API key`() async throws { + let expectedDate = Date(timeIntervalSince1970: 42) + let context = Self.makeContext(environment: [ + DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env", + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + ]) + let strategy = DoubaoAPIFetchStrategy( + codingPlanUsageLoader: { credentials in + #expect(credentials.accessKeyID == "AKLT-env") + #expect(credentials.secretAccessKey == "sk-env") + 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.strategyID == "doubao.api") + #expect(result.usage.updatedAt == expectedDate) + #expect(result.usage.primary?.usedPercent == 30) + } + + @Test + func `signed credential cancellation does not fall back to ark API key`() async { + let context = Self.makeContext(environment: [ + DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env", + DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env", + DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env", + ]) + let strategy = DoubaoAPIFetchStrategy( + codingPlanUsageLoader: { _ in + throw CancellationError() + }, + arkUsageLoader: { _ in + Issue.record("Ark fallback should not run after cancellation") + throw DoubaoProviderTestError.arkShouldNotRun + }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(context) + } + } + + private static func makeContext(environment: [String: String]) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: DoubaoProviderTestClaudeFetcher(), + browserDetection: browserDetection) + } } From 9678247b1213743f04bfce816ab24887787f0c79 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 17:36:40 +0800 Subject: [PATCH 07/10] fix(doubao): accept volc sdk env aliases --- .../Providers/Doubao/DoubaoSettingsReader.swift | 3 +++ .../ProviderConfigEnvironmentTests.swift | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift index 5a4caee351..479d2816a9 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift @@ -9,17 +9,20 @@ public struct DoubaoSettingsReader: Sendable { public static let accessKeyIDEnvironmentKeys = [ "VOLCENGINE_ACCESS_KEY_ID", "VOLCENGINE_ACCESS_KEY", + "VOLC_ACCESSKEY", "DOUBAO_ACCESS_KEY_ID", ] public static let secretAccessKeyEnvironmentKeys = [ "VOLCENGINE_SECRET_ACCESS_KEY", "VOLCENGINE_SECRET_KEY", "VOLCENGINE_ACCESS_KEY_SECRET", + "VOLC_SECRETKEY", "DOUBAO_SECRET_ACCESS_KEY", ] public static let regionEnvironmentKeys = [ "VOLCENGINE_REGION", "VOLCENGINE_REGION_ID", + "VOLC_REGION", "DOUBAO_REGION", ] public static let defaultRegion = "cn-beijing" diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index bb76b569d4..d91122c9fe 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -132,6 +132,22 @@ struct ProviderConfigEnvironmentTests { #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-env") } + @Test + func `reads doubao volc sdk credential aliases`() { + let env = [ + "VOLC_ACCESSKEY": "AKLT-volc", + "VOLC_SECRETKEY": "sk-volc", + "VOLC_REGION": "cn-shanghai", + ] + + #expect(DoubaoSettingsReader.accessKeyIDEnvironmentKeys.contains("VOLC_ACCESSKEY")) + #expect(DoubaoSettingsReader.secretAccessKeyEnvironmentKeys.contains("VOLC_SECRETKEY")) + #expect(DoubaoSettingsReader.regionEnvironmentKeys.contains("VOLC_REGION")) + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.accessKeyID == "AKLT-volc") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.secretAccessKey == "sk-volc") + #expect(DoubaoSettingsReader.codingPlanCredentials(environment: env)?.region == "cn-shanghai") + } + @Test func `does not project incomplete doubao access key as ark API key`() { let config = ProviderConfig(id: .doubao, apiKey: "AKLT-config") From d4a1419065f95ae9d21471b0d7cf7d8fec558a30 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 18:14:09 +0800 Subject: [PATCH 08/10] fix(doubao): preserve ark request label --- .../CodexBar/MenuCardView+ModelHelpers.swift | 2 ++ Sources/CodexBar/MenuDescriptor.swift | 10 +++++--- .../CodexBar/UsageStore+WidgetSnapshot.swift | 5 ++++ .../Doubao/DoubaoProviderDescriptor.swift | 9 +++++++ Tests/CodexBarTests/DoubaoProviderTests.swift | 24 +++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index c604bf2f84..7dca9cab38 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -229,6 +229,8 @@ extension UsageMenuCardView.Model { "Requests" } else if input.provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel + } else if input.provider == .doubao { + DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel } else { input.metadata.sessionLabel } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index fb8a88ba24..ed8a1e81cf 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -707,9 +707,13 @@ struct MenuDescriptor { if provider == .factory, snapshot.tertiary != nil { return ("5-hour", L("Weekly"), L("Monthly"), true) } - let primaryLabel = provider == .grok - ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel - : metadata.sessionLabel + let primaryLabel = if provider == .grok { + GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else if provider == .doubao { + DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else { + metadata.sessionLabel + } return ( L(primaryLabel), L(metadata.weeklyLabel), diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 7e943bddb1..2e20d4b7c7 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -140,6 +140,11 @@ extension UsageStore { { return dyn } + if provider == .doubao, + let dyn = DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) + { + return dyn + } return metadata?.sessionLabel ?? "Session" }() diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift index 5dbcb3918c..386c039be6 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -3,6 +3,15 @@ import Foundation public enum DoubaoProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + public static func primaryLabel(window: RateWindow?) -> String? { + guard window?.windowMinutes == nil, + window?.resetDescription?.localizedCaseInsensitiveContains("request") == true + else { + return nil + } + return "Requests" + } + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .doubao, diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift index 35c869df22..ab753fe31f 100644 --- a/Tests/CodexBarTests/DoubaoProviderTests.swift +++ b/Tests/CodexBarTests/DoubaoProviderTests.swift @@ -56,6 +56,29 @@ struct DoubaoProviderTests { #expect(usage.rateLimitsUnavailable(for: .doubao)) } + @Test + func `primary label preserves ark request windows`() { + let arkWindow = RateWindow( + usedPercent: 30, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "3/10 requests") + let codingPlanWindow = RateWindow( + usedPercent: 30, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: "30% used") + let unavailableWindow = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "No usage data") + + #expect(DoubaoProviderDescriptor.primaryLabel(window: arkWindow) == "Requests") + #expect(DoubaoProviderDescriptor.primaryLabel(window: codingPlanWindow) == nil) + #expect(DoubaoProviderDescriptor.primaryLabel(window: unavailableWindow) == nil) + } + @Test func `signed credential failure falls back to ark API key`() async throws { let expectedDate = Date(timeIntervalSince1970: 42) @@ -86,6 +109,7 @@ struct DoubaoProviderTests { #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 From 41fc3235746f38543988192b801ad1fe2a86cad1 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Wed, 24 Jun 2026 18:27:31 +0800 Subject: [PATCH 09/10] fix(doubao): sanitize coding plan resets --- .../Providers/Doubao/DoubaoUsageFetcher.swift | 19 +++++------- .../DoubaoUsageFetcherTests.swift | 31 ++++++++++++++++++- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 742587f30a..fd6487a7df 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -124,15 +124,7 @@ public struct DoubaoCodingPlanUsage: Sendable, Equatable { usedPercent: percent, windowMinutes: minutes, resetsAt: quota.resetTime, - resetDescription: "\(Self.formatPercent(percent))% used") - } - - private static func formatPercent(_ percent: Double) -> String { - let rounded = (percent * 100).rounded() / 100 - if rounded.rounded() == rounded { - return String(Int(rounded)) - } - return String(format: "%.2f", rounded) + resetDescription: nil) } } @@ -267,14 +259,19 @@ public struct DoubaoUsageFetcher: Sendable { DoubaoCodingPlanUsage.Quota( level: quota.level, percent: quota.percent, - resetTime: quota.resetTimestamp.map(Date.init(timeIntervalSince1970:))) + resetTime: self.date(fromEpoch: quota.resetTimestamp)) } return DoubaoCodingPlanUsage( status: usage.status, - updateTime: usage.updateTimestamp.map(Date.init(timeIntervalSince1970:)), + 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 confirmAmbiguousZeroRemaining( initial: ProbeResult, apiKey: String, diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index d8a0934a9c..1f892dc9ed 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -126,7 +126,7 @@ struct DoubaoUsageFetcherTests { #expect(usage.primary?.usedPercent == 0.116) #expect(usage.primary?.windowMinutes == 300) #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_782_226_478)) - #expect(usage.primary?.resetDescription == "0.12% used") + #expect(usage.primary?.resetDescription == nil) #expect(usage.secondary?.usedPercent == 3.182143) #expect(usage.secondary?.windowMinutes == 10080) #expect(usage.tertiary?.usedPercent == 7.5730535) @@ -135,6 +135,35 @@ struct DoubaoUsageFetcherTests { #expect(usage.identity?.loginMethod == "Running") } + @Test + func `coding plan response ignores missing reset sentinels`() throws { + let fallbackUpdatedAt = Date(timeIntervalSince1970: 42) + let data = Data( + """ + { + "Result": { + "Status": "Running", + "UpdateTimestamp": 0, + "QuotaUsage": [ + {"Level":"session","Percent":12.5,"ResetTimestamp":0}, + {"Level":"weekly","Percent":24,"ResetTimestamp":-1} + ] + } + } + """.utf8) + + let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data).toUsageSnapshot( + updatedAt: fallbackUpdatedAt) + + #expect(usage.updatedAt == fallbackUpdatedAt) + #expect(usage.primary?.usedPercent == 12.5) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.primary?.resetDescription == nil) + #expect(usage.secondary?.usedPercent == 24) + #expect(usage.secondary?.resetsAt == nil) + #expect(usage.secondary?.resetDescription == nil) + } + @Test func `coding plan fetch signs volcengine request`() async throws { let transport = DoubaoScriptedTransport(results: [ From 970b938750ed45628bb13caf9ad3caff1f4c3382 Mon Sep 17 00:00:00 2001 From: LeoLin Date: Thu, 2 Jul 2026 16:09:25 +0800 Subject: [PATCH 10/10] fix(doubao): surface Volcengine API error code/message on Coding Plan failures apiErrorSummary only parsed `error.message`/`message`, so Volcengine's `ResponseMetadata.Error.{Code,Message}` shape fell through to an opaque "HTTP 403 (322 bytes)". Users hitting an IAM AccessDenied on ark:GetCodingPlanUsage now see the real reason instead of a byte count. Add a regression test asserting a 403 ResponseMetadata.Error body is surfaced as "AccessDenied: User is not authorized to perform: ark:GetCodingPlanUsage". --- .../Providers/Doubao/DoubaoUsageFetcher.swift | 18 +++++++++ .../DoubaoUsageFetcherTests.swift | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index fd6487a7df..cc5b7a06df 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -471,6 +471,24 @@ public struct DoubaoUsageFetcher: Sendable { return "Unexpected response body (\(data.count) bytes)." } + // Volcengine Top OpenAPI error shape: { "ResponseMetadata": { "Error": { "Code": ..., "Message": ... } } } + if let metadata = json["ResponseMetadata"] as? [String: Any], + let volcError = metadata["Error"] as? [String: Any] + { + let code = (volcError["Code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let message = (volcError["Message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + switch (code?.isEmpty == false ? code : nil, message?.isEmpty == false ? message : nil) { + case let (code?, message?): + return Self.compactText("\(code): \(message)") + case let (code?, nil): + return Self.compactText(code) + case let (nil, message?): + return Self.compactText(message) + case (nil, nil): + break + } + } + if let error = json["error"] as? [String: Any], let message = error["message"] as? String { diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index 1f892dc9ed..dab7fa8c80 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -206,6 +206,43 @@ struct DoubaoUsageFetcherTests { "SignedHeaders=content-type;host;x-content-sha256;x-date") == true) } + @Test + func `coding plan fetch surfaces volcengine access denied error`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 403, + body: """ + { + "ResponseMetadata": { + "Action": "GetCodingPlanUsage", + "Error": { + "CodeN": 100013, + "Code": "AccessDenied", + "Message": "User is not authorized to perform: ark:GetCodingPlanUsage" + } + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: Date(timeIntervalSince1970: 1_781_654_400)) + } throws: { error in + guard case let DoubaoUsageError.apiError(code, message) = error else { return false } + return code == 403 + && message.contains("AccessDenied") + && message.contains("ark:GetCodingPlanUsage") + && !message.contains("bytes") + } + } + @Test func `repeated successful zero remaining responses omit unknown request limit`() async throws { let transport = DoubaoScriptedTransport(results: [