Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct OpenCodeGoProviderImplementation: ProviderImplementation {
_ = settings.opencodegoCookieSource
_ = settings.opencodegoCookieHeader
_ = settings.opencodegoWorkspaceID
_ = settings[providerConfig: .opencodego, field: .apiKey]
}

@MainActor
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -106,17 +119,22 @@ public enum OpenCodeGoProviderDescriptor {
}

private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
if context.sourceMode == .api {
return [OpenCodeGoAPIUsageFetchStrategy()]
}
if context.sourceMode == .web {
return [OpenCodeGoUsageFetchStrategy()]
}
if self.requiresScopedWebStrategy(context: context) {
return [
OpenCodeGoUsageFetchStrategy(),
OpenCodeGoLocalUsageFetchStrategy(),
OpenCodeGoAPIUsageFetchStrategy(),
]
}
return [
OpenCodeGoLocalUsageFetchStrategy(),
OpenCodeGoAPIUsageFetchStrategy(),
OpenCodeGoUsageFetchStrategy(),
]
}
Expand Down Expand Up @@ -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
Expand All @@ -163,18 +184,24 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy {

private struct SnapshotResult {
let snapshot: OpenCodeGoUsageSnapshot
let webUsageApplied: Bool
let sourceLabel: String
let quotaIsAuthoritative: Bool
}

init(
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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"]
Expand All @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
Loading