diff --git a/CHANGELOG.md b/CHANGELOG.md index ee843a9c8..61098e09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ on Columbus Labs QuotaKit releases and product-facing changes. (`daba50847..0a18391e`), adding CrossModel API spend parsing, CLI diagnostics, widget/menu display, provider docs, and tests while preserving QuotaKit public docs and config ownership. +- Synced upstream Doubao Coding Plan usage through `44f0949b` + (`0a18391e..44f0949b`), adding signed Volcengine AK/SK support for + session, weekly, and monthly usage while preserving QuotaKit config + ownership. ### Fixed diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 7ebe15532..9f0d4379f 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -227,6 +227,8 @@ extension UsageMenuCardView.Model { Self.cursorPrimaryRateWindowLabel(snapshot: snapshot, fallback: input.metadata.sessionLabel) } 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 6998a34e0..697c35eca 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -721,6 +721,8 @@ struct MenuDescriptor { Self.cursorPrimaryRateWindowLabel(snapshot: snapshot, fallback: metadata.sessionLabel) } else if provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + } else if provider == .doubao { + DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else { metadata.sessionLabel } diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift index e32a15b88..e8fe69e4b 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 ~/.quotakit/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. Stored in ~/.quotakit/config.json.", 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 543800e43..681855035 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -186,6 +186,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 11fdc7f52..027796f7b 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -187,7 +187,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 } @@ -197,7 +198,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]) { @@ -284,7 +285,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 446e52391..8c6014108 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -129,6 +129,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") diff --git a/version.env b/version.env index cb627a29c..ee3775290 100644 --- a/version.env +++ b/version.env @@ -10,4 +10,4 @@ UPSTREAM_SYNC_DATE=2026-07-02 # Advance this when an upstream sync PR lands. It is independent of shipped # release tracking above, so the monitor does not reopen stale issues while a # merged upstream sync has not yet shipped to users. -UPSTREAM_MONITOR_BASE=0a18391e1838ee9d110395f3409f785bfbca7c99 +UPSTREAM_MONITOR_BASE=44f0949b6b0503f88fc69ae8d3aaac4f4f53f739