diff --git a/README.md b/README.md index 2d3508c4eb..4eb6c373fa 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available. - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. - [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage. -- [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows. +- [OpenCode Go](docs/opencode.md) — Usage API, browser fallback, and local SQLite cost history. - [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas. - [Alibaba Token Plan](docs/alibaba-token-plan.md) — Bailian browser/manual cookies for token-plan credits. - [Qwen Cloud](docs/qwen-cloud.md) — 5-hour and weekly individual Token Plan usage via browser/manual cookies. diff --git a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift index 6fbc8a57a0..af49ef9273 100644 --- a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoProviderImplementation.swift @@ -16,6 +16,7 @@ struct OpenCodeGoProviderImplementation: ProviderImplementation { _ = settings.opencodegoCookieSource _ = settings.opencodegoCookieHeader _ = settings.opencodegoWorkspaceID + _ = settings[providerConfig: .opencodego, field: .apiKey] } @MainActor @@ -87,6 +88,16 @@ struct OpenCodeGoProviderImplementation: ProviderImplementation { @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ + ProviderSettingsFieldDescriptor( + id: "opencodego-api-key", + title: "API key", + subtitle: "Preferred for Go usage limits. Also reads OPENCODE_API_KEY.", + kind: .secure, + placeholder: "OpenCode API key", + binding: context.providerConfigBinding(.apiKey), + actions: [], + isVisible: nil, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "opencodego-workspace-id", title: "Workspace ID", diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index 33231bf017..b911c22831 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -2,13 +2,26 @@ import Foundation public enum OpenCodeGoProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() - private static let credentials = ProviderCredentialAdapter(tokenAccountSupport: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store multiple OpenCode Go Cookie headers.", - placeholder: "Cookie: …", - injection: .cookieHeader, - requiresManualCookieSource: true, - cookieName: nil)) + private static let credentials = ProviderCredentialAdapter( + supportsAPIKeyOverride: true, + apiKeyDebugLabel: OpenCodeGoSettingsReader.apiKeyEnvironmentKey, + environmentProjections: [.apiKey(OpenCodeGoSettingsReader.apiKeyEnvironmentKey)], + tokenResolver: { kind, environment, _ in + guard kind == .primary, + let token = OpenCodeGoSettingsReader.apiKey(environment: environment) + else { return nil } + return ProviderTokenResolution(token: token, source: .environment) + }, + tokenAccountSupport: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple OpenCode Go Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), + authDetector: { environment, _ in + OpenCodeGoSettingsReader.apiKey(environment: environment) == nil ? [] : ["api"] + }) static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( @@ -95,7 +108,7 @@ public enum OpenCodeGoProviderDescriptor { }, supportsInlineTokenCostDashboard: true)), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web], + sourceModes: [.auto, .api, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "opencodego", @@ -106,6 +119,9 @@ public enum OpenCodeGoProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + if context.sourceMode == .api { + return [OpenCodeGoAPIUsageFetchStrategy()] + } if context.sourceMode == .web { return [OpenCodeGoUsageFetchStrategy()] } @@ -113,10 +129,12 @@ public enum OpenCodeGoProviderDescriptor { return [ OpenCodeGoUsageFetchStrategy(), OpenCodeGoLocalUsageFetchStrategy(), + OpenCodeGoAPIUsageFetchStrategy(), ] } return [ OpenCodeGoLocalUsageFetchStrategy(), + OpenCodeGoAPIUsageFetchStrategy(), OpenCodeGoUsageFetchStrategy(), ] } @@ -152,9 +170,12 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { typealias LocalSnapshotLoader = @Sendable (ProviderFetchContext) throws -> OpenCodeGoUsageSnapshot typealias WebUsageOverlayFetcher = @Sendable (ProviderFetchContext, String) async throws -> OpenCodeGoUsageSnapshot? + typealias APIUsageOverlayFetcher = @Sendable (ProviderFetchContext, String) async throws + -> OpenCodeGoUsageSnapshot private let localSnapshotLoader: LocalSnapshotLoader private let webUsageOverlayFetcher: WebUsageOverlayFetcher + private let apiUsageOverlayFetcher: APIUsageOverlayFetcher private struct OverlayCookie { let header: String @@ -163,7 +184,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { private struct SnapshotResult { let snapshot: OpenCodeGoUsageSnapshot - let webUsageApplied: Bool + let sourceLabel: String let quotaIsAuthoritative: Bool } @@ -171,10 +192,16 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { localSnapshotLoader: @escaping LocalSnapshotLoader = { context in try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) }, - webUsageOverlayFetcher: @escaping WebUsageOverlayFetcher = Self.liveWebUsageOverlay) + webUsageOverlayFetcher: @escaping WebUsageOverlayFetcher = Self.liveWebUsageOverlay, + apiUsageOverlayFetcher: @escaping APIUsageOverlayFetcher = { context, apiKey in + try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: apiKey, + timeout: context.webTimeout) + }) { self.localSnapshotLoader = localSnapshotLoader self.webUsageOverlayFetcher = webUsageOverlayFetcher + self.apiUsageOverlayFetcher = apiUsageOverlayFetcher } func isAvailable(_: ProviderFetchContext) async -> Bool { @@ -186,7 +213,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { let usage = result.snapshot.toUsageSnapshot() return self.makeResult( usage: result.quotaIsAuthoritative ? usage : usage.withDataConfidence(.estimated), - sourceLabel: result.webUsageApplied ? "local+web" : "local") + sourceLabel: result.sourceLabel) } func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { @@ -195,10 +222,26 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { private func snapshot(context: ProviderFetchContext) async throws -> SnapshotResult { let snapshot = try self.localSnapshotLoader(context) + if let apiKey = OpenCodeGoSettingsReader.apiKey(environment: context.env) { + do { + let apiSnapshot = try await self.apiUsageOverlayFetcher(context, apiKey) + let apiOverlay = snapshot.applyingWebUsage(apiSnapshot) + return try await SnapshotResult( + snapshot: self.preservingCookieBalance(in: apiOverlay, context: context), + sourceLabel: "local+api", + quotaIsAuthoritative: true) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + // Keep the existing cookie path as a compatibility fallback. + } + } guard context.settings?.opencodego?.cookieSource != .off, let cookie = Self.cachedOrManualCookie(context: context) else { - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } // The server knows the real billing-cycle anchors; the local monthly window is only an @@ -213,7 +256,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { #if os(macOS) if let cached = cookie.cachedEntry { _ = CookieHeaderCache.clearIfCurrent(provider: .opencodego, expected: cached) - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } #endif // A manually configured credential is an explicit account selection. Do not hide its @@ -224,17 +267,17 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } catch let error as URLError where error.code == .cancelled { throw CancellationError() } catch { - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } if let webSnapshot { return SnapshotResult( snapshot: snapshot.applyingWebUsage(webSnapshot), - webUsageApplied: true, + sourceLabel: "local+web", quotaIsAuthoritative: !webSnapshot.isBalanceOnly) } guard context.includeOptionalUsage else { - return SnapshotResult(snapshot: snapshot, webUsageApplied: false, quotaIsAuthoritative: false) + return SnapshotResult(snapshot: snapshot, sourceLabel: "local", quotaIsAuthoritative: false) } let workspaceOverride = context.settings?.opencodego?.workspaceID ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] @@ -258,10 +301,39 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { waitForZenBalance: OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(context: context))) return SnapshotResult( snapshot: snapshot.withZenBalanceUSD(zenBalance), - webUsageApplied: false, + sourceLabel: "local", quotaIsAuthoritative: false) } + private func preservingCookieBalance( + in snapshot: OpenCodeGoUsageSnapshot, + context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot + { + guard context.settings?.opencodego?.cookieSource != .off, + let cookie = Self.cachedOrManualCookie(context: context) + else { return snapshot } + + do { + guard let webSnapshot = try await self.webUsageOverlayFetcher(context, cookie.header) else { + return snapshot + } + return snapshot.withZenBalanceUSD(webSnapshot.zenBalanceUSD ?? snapshot.zenBalanceUSD) + } catch OpenCodeGoUsageError.invalidCredentials { + #if os(macOS) + if let cached = cookie.cachedEntry { + _ = CookieHeaderCache.clearIfCurrent(provider: .opencodego, expected: cached) + } + #endif + return snapshot + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + return snapshot + } + } + static func liveWebUsageOverlay( context: ProviderFetchContext, cookieHeader: String) async throws -> OpenCodeGoUsageSnapshot? @@ -309,6 +381,32 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } } +struct OpenCodeGoAPIUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = OpenCodeGoSettingsReader.apiKey(environment: context.env) else { + throw OpenCodeGoSettingsError.missingAPIKey + } + let snapshot = try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: apiKey, + timeout: context.webTimeout) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + if error is CancellationError { return false } + if let urlError = error as? URLError, urlError.code == .cancelled { return false } + return true + } +} + struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { let id: String = "opencodego.web" let kind: ProviderFetchKind = .web @@ -392,11 +490,14 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { } enum OpenCodeGoSettingsError: LocalizedError { + case missingAPIKey case missingCookie case invalidCookie var errorDescription: String? { switch self { + case .missingAPIKey: + "No OpenCode Go API key configured. Set OPENCODE_API_KEY or add apiKey to the CodexBar config." case .missingCookie: "No OpenCode Go session cookies found in browsers." case .invalidCookie: diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoSettingsReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoSettingsReader.swift new file mode 100644 index 0000000000..6df7e2f763 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoSettingsReader.swift @@ -0,0 +1,20 @@ +import Foundation + +public enum OpenCodeGoSettingsReader { + public static let apiKeyEnvironmentKey = "OPENCODE_API_KEY" + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + guard var value = environment[self.apiKeyEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { return nil } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index b01ee89144..a96c399a0c 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -12,7 +12,7 @@ public enum OpenCodeGoUsageError: LocalizedError { public var errorDescription: String? { switch self { case .invalidCredentials: - "OpenCode Go session cookie is invalid or expired." + "OpenCode Go credentials are invalid or expired." case let .networkError(message): "OpenCode Go network error: \(message)" case let .apiError(message): @@ -28,6 +28,7 @@ public struct OpenCodeGoUsageFetcher: Sendable { private static let baseURL = URL(string: "https://opencode.ai")! private static let authURL = URL(string: "https://opencode.ai/auth")! private static let serverURL = URL(string: "https://opencode.ai/_server")! + private static let usageAPIURL = URL(string: "https://opencode.ai/zen/go/v1/usage")! private static let workspacesServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" private static let billingServerID = "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d" @@ -204,6 +205,41 @@ public struct OpenCodeGoUsageFetcher: Sendable { return snapshot.withZenBalanceUSD(zenBalance) } + public static func fetchAPIUsage( + apiKey: String, + timeout: TimeInterval, + now: Date = Date(), + session: URLSession? = nil) async throws -> OpenCodeGoUsageSnapshot + { + let token = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + throw OpenCodeGoSettingsError.missingAPIKey + } + + var request = URLRequest(url: self.usageAPIURL) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + + let response = try await (session ?? self.redirectGuardSession).response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw OpenCodeGoUsageError.invalidCredentials + } + let body = String(data: response.data, encoding: .utf8) ?? "" + if let message = self.extractServerErrorMessage(from: body) { + throw OpenCodeGoUsageError.apiError("HTTP \(response.statusCode): \(message)") + } + throw OpenCodeGoUsageError.apiError("HTTP \(response.statusCode)") + } + guard let text = String(data: response.data, encoding: .utf8) else { + throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") + } + return try self.parseSubscription(text: text, now: now) + } + static func requiredZenBalanceFallback( from task: Task?, for error: OpenCodeGoUsageError, diff --git a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift index 52c822b7d7..b3c4415b9a 100644 --- a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift @@ -46,7 +46,7 @@ struct OpenCodeGoProviderStrategyTests { let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext()) - #expect(strategies.map(\.id) == ["opencodego.local", "opencodego.web"]) + #expect(strategies.map(\.id) == ["opencodego.local", "opencodego.api", "opencodego.web"]) } @Test @@ -55,7 +55,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(selectedTokenAccountID: UUID())) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -68,7 +68,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(settings: settings)) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -81,7 +81,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(settings: settings)) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -90,7 +90,7 @@ struct OpenCodeGoProviderStrategyTests { let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(env: ["CODEXBAR_OPENCODEGO_WORKSPACE_ID": "wrk_env"])) - #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local", "opencodego.api"]) } @Test @@ -105,8 +105,8 @@ struct OpenCodeGoProviderStrategyTests { let environmentStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( self.makeContext(env: ["CODEXBAR_OPENCODEGO_WORKSPACE_ID": " \t "])) - #expect(settingsStrategies.map(\.id) == ["opencodego.local", "opencodego.web"]) - #expect(environmentStrategies.map(\.id) == ["opencodego.local", "opencodego.web"]) + #expect(settingsStrategies.map(\.id) == ["opencodego.local", "opencodego.api", "opencodego.web"]) + #expect(environmentStrategies.map(\.id) == ["opencodego.local", "opencodego.api", "opencodego.web"]) } @Test @@ -117,6 +117,14 @@ struct OpenCodeGoProviderStrategyTests { #expect(strategies.map(\.id) == ["opencodego.web"]) } + @Test + func `api source uses only the public usage endpoint`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext(sourceMode: .api)) + + #expect(strategies.map(\.id) == ["opencodego.api"]) + } + @Test func `local strategy falls through to web when local history is unavailable`() { let strategy = OpenCodeGoLocalUsageFetchStrategy() diff --git a/Tests/CodexBarTests/OpenCodeGoSettingsReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoSettingsReaderTests.swift new file mode 100644 index 0000000000..c12c5a070a --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoSettingsReaderTests.swift @@ -0,0 +1,19 @@ +import CodexBarCore +import Testing + +struct OpenCodeGoSettingsReaderTests { + @Test + func `reads and normalizes API key`() { + #expect(OpenCodeGoSettingsReader.apiKey(environment: ["OPENCODE_API_KEY": " go_test "]) == "go_test") + #expect(OpenCodeGoSettingsReader.apiKey(environment: ["OPENCODE_API_KEY": "'go_quoted'"]) == "go_quoted") + #expect(OpenCodeGoSettingsReader.apiKey(environment: ["OPENCODE_API_KEY": " "]) == nil) + } + + @Test + func `descriptor exposes API source and config override`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .opencodego) + + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api, .web]) + #expect(descriptor.credentials?.supportsAPIKeyOverride == true) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index fa35f750eb..7e9258f990 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -43,6 +43,70 @@ private final class OpenCodeGoContinuationBox: @unchecked Senda @Suite(.serialized) struct OpenCodeGoUsageFetcherErrorTests { + @Test + func `public usage API sends bearer token and parses all windows`() async throws { + defer { OpenCodeGoStubURLProtocol.handler = nil } + let requests = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + requests.append(request) + let body = """ + { + "usage": { + "rolling": {"percent": 12, "resetsAt": "2026-08-12T02:00:00.000Z"}, + "weekly": {"percent": 8, "resetsAt": "2026-08-18T00:00:00.000Z"}, + "monthly": {"percent": 35, "resetsAt": "2026-09-01T00:00:00.000Z"} + } + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json") + } + + let now = Date(timeIntervalSince1970: 1_786_493_600) + let snapshot = try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: "go_secret", + timeout: 2, + now: now, + session: self.makeSession()) + + #expect(requests.values.count == 1) + #expect(requests.values.first?.url?.path == "/zen/go/v1/usage") + #expect(requests.values.first?.value(forHTTPHeaderField: "Authorization") == "Bearer go_secret") + #expect(snapshot.rollingUsagePercent == 12) + #expect(snapshot.weeklyUsagePercent == 8) + #expect(snapshot.monthlyUsagePercent == 35) + #expect(snapshot.hasWeeklyUsage) + #expect(snapshot.hasMonthlyUsage) + } + + @Test + func `public usage API maps unauthorized response to invalid credentials`() async { + defer { OpenCodeGoStubURLProtocol.handler = nil } + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse( + url: url, + body: #"{"error":"unauthorized"}"#, + statusCode: 401, + contentType: "application/json") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchAPIUsage( + apiKey: "bad", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected invalidCredentials") + } catch let error as OpenCodeGoUsageError { + guard case .invalidCredentials = error else { + Issue.record("Expected invalidCredentials, got \(error)") + return + } + } catch { + Issue.record("Expected OpenCodeGoUsageError, got \(error)") + } + } + @Test func `dashboard URL uses normalized workspace ID`() { #expect( diff --git a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift index 2b70252916..113420d7fb 100644 --- a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift @@ -83,6 +83,7 @@ struct OpenCodeGoWebOverlayTests { private func makeContext( includeOptionalUsage: Bool = true, settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:], selectedTokenAccountID: UUID? = nil) -> ProviderFetchContext { ProviderFetchContext( @@ -93,9 +94,9 @@ struct OpenCodeGoWebOverlayTests { webTimeout: 1, webDebugDumpHTML: false, verbose: false, - env: [:], + env: env, settings: settings, - fetcher: UsageFetcher(environment: [:]), + fetcher: UsageFetcher(environment: env), claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), selectedTokenAccountID: selectedTokenAccountID) @@ -246,6 +247,43 @@ struct OpenCodeGoWebOverlayTests { } } + @Test + func `local strategy prefers API windows while preserving local history and web balance`() async throws { + let observedKeys = Recorder() + let webCalls = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + webCalls.append(cookieHeader) + return Self.webUsage(zenBalanceUSD: 42.5) + }, + apiUsageOverlayFetcher: { _, apiKey in + observedKeys.append(apiKey) + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 11, + weeklyUsagePercent: 22, + monthlyUsagePercent: 33, + rollingResetInSec: 18100, + weeklyResetInSec: 266_500, + monthlyResetInSec: 1_539_100, + updatedAt: Self.updatedAt.addingTimeInterval(3)) + }) + + let result = try await strategy.fetch(self.makeContext( + settings: self.makeManualCookieSettings(), + env: [OpenCodeGoSettingsReader.apiKeyEnvironmentKey: "go_test"])) + + #expect(result.sourceLabel == "local+api") + #expect(observedKeys.values == ["go_test"]) + #expect(webCalls.values == ["auth=test"]) + #expect(result.usage.primary?.usedPercent == 11) + #expect(result.usage.secondary?.usedPercent == 22) + #expect(result.usage.tertiary?.usedPercent == 33) + #expect(result.usage.opencodegoUsage?.daily.count == 1) + #expect(result.usage.providerCost?.used == 42.5) + } + @Test func `local strategy keeps local estimate when web overlay is unavailable`() async throws { let strategy = OpenCodeGoLocalUsageFetchStrategy( diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index fd16b9a2eb..4fa3e089f0 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -213,7 +213,7 @@ struct ProviderArchitectureGatekeeperTests { ]) #expect(descriptors.compactMap { descriptor in descriptor.credentials?.apiKeyDebugLabel.map { (descriptor.id, $0) } - }.map(\.0) == [.openai, .azureopenai, .openrouter, .elevenlabs]) + }.map(\.0) == [.openai, .azureopenai, .opencodego, .openrouter, .elevenlabs]) #expect(CodexProviderDescriptor.descriptor.tokenCost.menuHintLines == [.localized("codex_api_estimate_hint")]) #expect(ClaudeProviderDescriptor.descriptor.tokenCost.menuHintLines == [.estimate]) diff --git a/docs/cli.md b/docs/cli.md index 9f3b906ffe..b6c03277e2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -140,7 +140,7 @@ See `docs/configuration.md` for the schema. - `web`: web-only where that provider exposes an explicit web source; no CLI/API fallback. Browser import is macOS-only, while supported providers can use configured manual cookies on Linux. - `cli`: CLI/local-helper source where the provider exposes one (for example Codex RPC/PTy, Claude PTY, Kilo CLI fallback, Kiro CLI, local probes). - `oauth`: OAuth-backed source where supported (Codex, Claude, Vertex AI). - - `api`: API-key/token flow when the provider supports it (OpenAI, Claude Admin API, z.ai, Gemini, Alibaba, Copilot, Kilo, Kimi, MiniMax, Ollama, Warp, OpenRouter, ElevenLabs, Deepgram, Synthetic, DeepSeek, DeepInfra, Moonshot, Doubao, Codebuff, Crof, Venice, AWS Bedrock). + - `api`: API-key/token flow when the provider supports it (OpenAI, Claude Admin API, z.ai, Gemini, Alibaba, Copilot, OpenCode Go, Kilo, Kimi, MiniMax, Ollama, Warp, OpenRouter, ElevenLabs, Deepgram, Synthetic, DeepSeek, DeepInfra, Moonshot, Doubao, Codebuff, Crof, Venice, AWS Bedrock). - Output `source` reflects the strategy actually used (`openai-web`, `web`, `oauth`, `api`, `local`, `cli`, or provider CLI label). - Codex web: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown). - `--web-timeout ` (default: 60) @@ -151,7 +151,8 @@ See `docs/configuration.md` for the schema. command delegates authentication to Claude Code; the app keeps its stricter prompt-free background availability gate for scheduled refreshes. - Command Code web: commandcode.ai browser session cookies on macOS, or a configured manual cookie on Linux, for monthly credit usage. - - OpenCode Go auto: local SQLite usage on macOS and Linux, with optional manual-cookie web enrichment. + - OpenCode Go auto: local SQLite cost history on macOS and Linux with API usage-window enrichment when + `OPENCODE_API_KEY` is configured, plus legacy manual-cookie web fallback. - Kilo auto: app.kilo.ai API first, then CLI auth fallback (`~/.local/share/kilo/auth.json`) on missing/unauthorized API credentials. - Linux: browser-backed `auto`/`web` modes are not supported; local sources and configured manual-cookie paths remain available where documented. - Global flags: `-h/--help`, `-V/--version`, `-v/--verbose`, `--no-color`, `--log-level `, `--json-output`, `--json-only`. diff --git a/docs/opencode.md b/docs/opencode.md index d76472a6d1..d66cce4a30 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -9,6 +9,8 @@ read_when: ## Data sources - Browser cookies from `opencode.ai`. +- OpenCode Go usage API at `GET https://opencode.ai/zen/go/v1/usage`, authenticated by `OPENCODE_API_KEY` or + `providers[].apiKey`. - OpenCode Go local history from `~/.local/share/opencode/opencode.db` on macOS and Linux. - `POST https://opencode.ai/_server` with server function IDs: - `workspaces` (`def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f`) @@ -29,13 +31,14 @@ read_when: - Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL. - Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.opencode`, source + timestamp). Browser import only runs when the cached cookie fails. -- OpenCode Go unscoped Auto mode tries quota windows and daily cost history derived from local `opencode-go` assistant - costs first, then falls back to web usage when local history is unavailable. Auto stays web-first when a token account, - manual cookie, or workspace override scopes the request, because local history is device-wide. +- OpenCode Go unscoped Auto mode tries daily cost history derived from local `opencode-go` assistant costs first, + overlays authoritative API windows when an API key is configured, then falls back through the API and legacy web + sources when local history is unavailable. Auto stays web-first when a token account, manual cookie, or workspace + override scopes the request, because local history is device-wide. - The local monthly window is an estimate anchored at the earliest local row and can drift from the real billing - cycle. When a cached or manual session cookie is available, the local strategy overlays the server-reported - rolling/weekly/monthly percentages and reset countdowns (plus Zen balance) onto the local snapshot, keeping the - local daily cost history. This path never triggers a fresh browser import. When no authoritative overlay is + cycle. The local strategy prefers API-reported rolling/weekly/monthly percentages and reset timestamps. When no API + key is configured, a cached or manual session cookie can still overlay the legacy web values (plus Zen balance). + Both paths keep local daily cost history and never trigger a fresh browser import. When no authoritative overlay is available, the menu and text CLI label the quota as estimated, and JSON includes `dataConfidence: "estimated"`. - OpenCode Go cost history chart: `opencode.ai` has no daily-granularity endpoint, so per-day cost/request buckets come from local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. Successful web diff --git a/docs/providers.md b/docs/providers.md index 65ca3d8891..d528b2e96d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -55,7 +55,7 @@ complete when the available scan window covers fewer days. | Antigravity | Local LSP/HTTP probe (`local`). | | Cursor | Web API via cookies → legacy stored session → Cursor.app local auth (`web`). | | OpenCode | Web dashboard via cookies (`web`). | -| OpenCode Go | Unscoped Auto: local SQLite usage (`local`) → web dashboard (`web`). Scoped Auto (selected account/manual cookie/workspace): web → local. Explicit Web: web only. | +| OpenCode Go | Unscoped Auto: local SQLite cost history with API overlay (`local+api`) → usage API (`api`) → web dashboard (`web`). Scoped Auto (selected account/manual cookie/workspace): web → local → API. Explicit API/Web: selected source only. | | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | | Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | | Qwen Cloud | Qwen Cloud 5-hour/weekly Token Plan APIs via browser or manual cookies (`web`). | @@ -218,12 +218,15 @@ complete when the available scan window covers fewer days. - Details: `docs/opencode.md`. ## OpenCode Go +- Preferred usage source: `GET https://opencode.ai/zen/go/v1/usage` with an API key from Settings, + `providers[].apiKey`, or `OPENCODE_API_KEY`. - Web dashboard via browser or manual cookies (`opencode.ai`). -- Unscoped Auto mode prefers local usage from `~/.local/share/opencode/opencode.db` on macOS and Linux, then falls back - to web when local history is unavailable. +- Unscoped Auto mode prefers local cost history from `~/.local/share/opencode/opencode.db` on macOS and Linux, + enriches it with API quota windows when configured, then falls back to standalone API and legacy web sources. - Auto mode stays web-first for selected token accounts, manual cookies, and workspace overrides; explicit Web mode does not include local fallback. -- Uses the workspace Go page/server data for rolling 5-hour, weekly, and optional monthly usage windows. +- Uses the public usage API for rolling 5-hour, weekly, and monthly usage windows, with the workspace Go page/server + data retained as a compatibility fallback. - Optional workspace ID comes from `~/.codexbar/config.json` (`providers[].workspaceID`) or `CODEXBAR_OPENCODEGO_WORKSPACE_ID`. - Status: none yet. - Details: `docs/opencode.md`.