Add OpenCode Go usage API support (#2879)

* Add OpenCode Go usage API support

* Preserve OpenCode Go Zen balance with API usage

* Update OpenCode Go capability gate

* Format OpenCode Go API overlay
This commit is contained in:
Akshay Prabhu
2026-08-19 08:51:02 -07:00
committed by GitHub
parent 46eacaf2e6
commit 05a7a449f0
13 changed files with 345 additions and 41 deletions
+1 -1
View File
@@ -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.
@@ -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",
@@ -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:
@@ -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
}
}
@@ -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<Double?, Error>?,
for error: OpenCodeGoUsageError,
@@ -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()
@@ -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)
}
}
@@ -43,6 +43,70 @@ private final class OpenCodeGoContinuationBox<Value: Sendable>: @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<URLRequest>()
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(
@@ -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<String>()
let webCalls = Recorder<String>()
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(
@@ -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])
+3 -2
View File
@@ -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 <seconds>` (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 <trace|verbose|debug|info|warning|error|critical>`, `--json-output`, `--json-only`.
+9 -6
View File
@@ -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
+7 -4
View File
@@ -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`.