From 44f0949b69bb705c43c3685ef23d7a16f1539763 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 2 Jul 2026 07:45:55 -0700 Subject: [PATCH] feat: add Doubao Coding Plan usage (#1841) * fix(sakana): parse weekly/5-hour reset time as UTC, not device-local console.sakana.ai/billing always server-renders "Resets on " in UTC (confirmed via the raw HTML's embedded "timeZone":"UTC" hydration payload marker, and Sakana's own "Weekly usage resets every Monday at 00:00 UTC" copy elsewhere on the page). The browser only corrects that text to the viewer's local timezone client-side, after JS hydration -- which this HTML-only fetcher never runs. SakanaUsageFetcher.parseResetDate parsed the raw string using TimeZone.current instead, so every non-UTC user got a reset time off by exactly their UTC offset -- 5.5h early on an IST machine, verified by building main into the codexbar CLI and running it against a real account: the tool's own JSON output landed 5.5h before the true reset instant shown in a browser on the same page at the same moment. Removes the timeZone parameter entirely (it no longer has a legitimate caller-supplied value; the source data is always UTC) and hardcodes UTC in parseResetDate. Updates the existing fixture-based tests, which previously asserted the buggy local-timezone interpretation as if it were correct, and adds a regression test pinned to a literal Unix timestamp independent of the date-building helper. Fixes #1826. * address clawsweeper review: strengthen regression test, fix stale docs - The UTC regression test didn't actually pin the fix: on a UTC CI runner, the pre-fix TimeZone.current code would coincidentally still produce the correct instant, so the test would pass either way. Force NSTimeZone.default to UTC+14 for the test's duration (same pattern as BedrockUsageStatsTests) so it only passes when the parser genuinely ignores the device timezone. Verified by temporarily reverting the fix locally and confirming the test now fails with the expected 5.5h-off result before restoring it. - docs/sakana.md still said reset dates use the device's local time zone (TimeZone.current) -- exactly the behavior this PR removes. Updated to describe the actual UTC parsing and why, linking #1826. * test(sakana): serialize suite with process-global timezone mutation clawsweeper's re-review flagged that the new NSTimeZone.default override test lives in a non-serialized suite, unlike the existing BedrockUsageStatsTests that uses the same pattern -- unrelated parallel Swift Testing cases could observe UTC+14 and fail nondeterministically. Mark @Suite(.serialized), matching Bedrock's precedent exactly. * Localize provider menu strings Move remaining provider UI copy through localization helpers and add Traditional Chinese translations. Preserve existing personal-info redaction behavior while localizing the visible placeholders and usage labels. * fix(codex): keep cached error envelopes parseable Keep stored OpenAI dashboard and Codex credits cached failure envelopes in English so CodexUIErrorMapper can parse the Cached values from marker. Add zh-Hant regression coverage for localized user-facing cached error messages. * fix(claude): localize Claude Code sign-in action Route the Claude Code sign-in menu label through localization and add English, zh-Hant, and strict-locale catalog entries. Add a zh-Hant regression test for the Claude account action. * fix(localization): complete Claude sign-in catalog parity Add the Claude Code sign-in label to localization catalogs covered by strict parity tests so CI passes after the menu action was localized. * Add CrossModel provider CrossModel (https://crossmodel.ai) is a multi-provider, OpenAI- and Anthropic-compatible API aggregation platform billed against a prepaid USD wallet. This adds it as an API-key usage provider, modeled on the OpenRouter provider since the data shape is nearly identical (wallet balance + daily/weekly/monthly spend). Data source (two read-only endpoints, integer micro units): - GET /v1/credits -> balance_micro, uncollected_micro - GET /v1/usage -> daily/weekly/monthly cost_micro + tokens + counts Display: balance in the identity line (and optional menu bar), today/ this week/this month spend in the menu card, and a shared inline dashboard. No quota meter (prepaid wallet, no per-key limit). Auth: CROSSMODEL_API_KEY env var or Settings/CLI config. Base URL override via CROSSMODEL_API_URL (loopback HTTP allowed for local testing). Tests: CrossModelUsageStatsTests (decode, micro->USD, best-effort usage, 401, redaction, round-trip) and a ProviderConfigEnvironment regression covering Settings/CLI-saved keys. * Fix CrossModel menu metrics and diagnostics * fix: harden CrossModel provider integration * fix: suppress generic credits bar for CrossModel menu card CrossModel's balance lives in crossModelUsage, not the generic credits path, so the fallback creditsHint text ("Wallet balance from the CrossModel API") rendered as a misleading Credits row. Suppress it the same way OpenRouter's balance-only card does. * fix: repair CrossModel currency handling * fix: normalize CrossModel redirect ports * docs: update CrossModel provider count * fix: respect CrossModel optional usage setting * fix: render CrossModel CLI usage * docs: note Sakana reset timezone fix * test: align Sakana fixture with raw UTC HTML * fix: preserve hidden identity behavior * docs: note localization completion * fix: preserve CrossModel balance without usage * feat: add Doubao Coding Plan usage Co-authored-by: LeoLin * fix: expose CrossModel diagnostics --------- Co-authored-by: ss251 Co-authored-by: Shun Min Chang Co-authored-by: hujuncheng Co-authored-by: LeoLin --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelHelpers.swift | 2 + Sources/CodexBar/MenuDescriptor.swift | 10 +- .../Doubao/DoubaoProviderImplementation.swift | 30 ++- .../Doubao/DoubaoSettingsStore.swift | 20 ++ .../CodexBar/UsageStore+WidgetSnapshot.swift | 5 + .../Config/CodexBarConfigValidation.swift | 7 +- .../Config/ProviderConfigEnvironment.swift | 60 ++++++ .../Doubao/DoubaoProviderDescriptor.swift | 87 +++++++-- .../Doubao/DoubaoSettingsReader.swift | 76 ++++++-- .../Providers/Doubao/DoubaoUsageFetcher.swift | 181 +++++++++++++++++- .../Doubao/DoubaoVolcengineSigner.swift | 153 +++++++++++++++ .../CodexBarTests/ConfigValidationTests.swift | 14 ++ Tests/CodexBarTests/DoubaoProviderTests.swift | 114 ++++++++++- .../DoubaoUsageFetcherTests.swift | 175 +++++++++++++++++ .../ProviderConfigEnvironmentTests.swift | 176 +++++++++++++++++ 16 files changed, 1075 insertions(+), 36 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Doubao/DoubaoVolcengineSigner.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index eed3653df..0a0a4682c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- Doubao: add signed Volcengine AK/SK support for Coding Plan session, weekly, and monthly usage. Thanks @LeoLin990405! - CrossModel: add API-key wallet balance and UTC daily, weekly, and monthly spend tracking. Thanks @hujuncheng! - Localization: complete Traditional Chinese provider and menu coverage, and route remaining provider UI copy through localized formatters. Thanks @jack24254029! - Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git! diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index b46308b92..02123b776 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -228,6 +228,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 3bc927c42..af1f551a9 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -717,9 +717,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/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift index ba91a2fc5..182ff06cc 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 4d69a273f..7313926b2 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/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index c80c1f74f..ea2c35db7 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -146,6 +146,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/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index fb45dad3d..da7bd46dd 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -180,7 +180,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 } @@ -190,7 +191,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 validateZaiTeamContext(_ entry: ProviderConfig, issues: inout [CodexBarConfigIssue]) { @@ -277,7 +278,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 b344ce085..75326b56f 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) case .sakana: self.applySakanaOverrides(base: base, config: config) default: @@ -289,6 +291,42 @@ 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, 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 { + 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 + } + + if let region = config.sanitizedRegion { + env[DoubaoSettingsReader.regionEnvironmentKeys[0]] = region + } + return env + } + private static func applySakanaOverrides( base: [String: String], config: ProviderConfig?) -> [String: String] @@ -301,6 +339,28 @@ 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 } + return value + } + return nil + } + 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 c221f530d..386c039be 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -3,16 +3,25 @@ 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, 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 +39,72 @@ 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 + 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 || + 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") + } + } + + guard let apiKey else { + throw DoubaoUsageError.missingCredentials + } + 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/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift index fc5942e54..479d2816a 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift @@ -6,31 +6,81 @@ public struct DoubaoSettingsReader: Sendable { "VOLCENGINE_API_KEY", "DOUBAO_API_KEY", ] + 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" 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 d1273b24d..cc5b7a06d 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,61 @@ 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: nil) + } +} + public enum DoubaoUsageError: LocalizedError, Sendable { case missingCredentials case networkError(String) @@ -89,6 +151,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 +207,71 @@ 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: 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 confirmAmbiguousZeroRemaining( initial: ProbeResult, apiKey: String, @@ -342,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 { @@ -366,4 +513,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/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 2d7437a33..1e1c0a500 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 when zai team token account is missing BigModel context`() { let accounts = ProviderTokenAccountData( diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift index bc69da7b7..ab753fe31 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,97 @@ struct DoubaoProviderTests { #expect(usage.primary == nil) #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) + 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) + #expect(DoubaoProviderDescriptor.primaryLabel(window: result.usage.primary) == "Requests") + } + + @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) + } } diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index ce727088a..dab7fa8c8 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -97,6 +97,152 @@ 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 == nil) + #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 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: [ + .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=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: [ @@ -217,12 +363,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 { + 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 +389,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 +418,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 edf81c4f1..43f80958c 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -77,6 +77,182 @@ 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 `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 `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") + 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( + 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 `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 cookie header override for sakana`() { let config = ProviderConfig(id: .sakana, cookieHeader: "Cookie: session=abc")