From 5802b545794c1903ba38fe0ea3d58192f4dcf2fc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:09:02 -0700 Subject: [PATCH] fix(rate-limits): read OpenCode Go usage with the Go API key (#22551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rate-limits): read OpenCode Go usage with the account API key Since OpenCode's console migration (upstream fe51b0b19a, "fix(console): restrict legacy access to Black"), an account with no Black subscription is redirected from the legacy console to /console/login, so Orca's cookie-based workspace lookup returns nothing and the Go bar stays empty. Fetch usage from GET https://opencode.ai/zen/go/v1/usage instead, which authenticates with `Authorization: Bearer ` and needs no console session. The key resolves in order: Orca settings override, OPENCODE_API_KEY, then whatever OpenCode itself stored on /connect -- auth.json for 1.x, the credential table for 2.x. The cookie path stays as the fallback so Black/legacy accounts keep working. A 403 EntitlementError now reads as "no OpenCode Go subscription" in the status bar instead of a generic refresh failure (#22257's reporter was misled by exactly that). * fix(rate-limits): prefer OpenCode's stored Go key over OPENCODE_API_KEY OpenCode applies the key saved on /connect after the environment, so the stored key is the one its own Go requests use. OPENCODE_API_KEY is also the Zen provider's variable, so ranking it first could read a key that OpenCode itself is not using for Go. Co-Authored-By: Claude * fix(rate-limits): name the API key when OpenCode Go usage lands on sign-in A redirected usage request arrives as a 200 sign-in page because Electron follows redirects; report it as a rejected key instead of a parse failure. The cookie path's empty workspace lookup is what non-Black accounts now hit after the console migration, so its message points at the API key rather than only the workspace override. Co-Authored-By: Claude * chore(i18n): add the OpenCode Go API key strings to the English catalog Co-Authored-By: Claude * docs(rate-limits): stop calling the credential table an OpenCode 2 marker Verified on two real Windows hosts running OpenCode 1.18.16: the `credential` table exists there too (empty, same columns), so its presence does not identify a 2.x install. Neither host had an `auth.json` at all. The resolution already probes both stores on every version, so only the comments were wrong. Says so now, and records that a 2.x install which never ran the legacy import has no `auth.json` either — which is why both tiers exist. * refactor(shared): move GhosttyImportPreview out of global-settings-types Adding `opencodeGoApiKey` pushed global-settings-types.ts one line past the 300-line ceiling, failing `oxlint` in CI. AGENTS.md forbids a max-lines suppression, so split instead: the Ghostty import preview is a distinct concern that never belonged in the settings-shape file. Re-exported from the original module so no importer changes. 293 code lines now. --------- Co-authored-by: Jinwoo-H Co-authored-by: Claude --- .../applying-settings/settings-update.ts | 3 + .../leasing-ssh-ptys/secret-validation.ts | 7 + .../loading-store/loaded-state-parsing.ts | 8 + .../state-serialization-secret-handling.ts | 4 + src/main/protected-secret-persistence.ts | 1 + .../opencode-go-api-key-source.test.ts | 154 ++++++++++++++ .../rate-limits/opencode-go-api-key-source.ts | 195 ++++++++++++++++++ .../opencode-go-status-parsing.test.ts | 37 +++- .../rate-limits/opencode-go-status-parsing.ts | 45 ++++ .../rate-limits/opencode-go-usage-api.test.ts | 152 ++++++++++++++ src/main/rate-limits/opencode-go-usage-api.ts | 113 ++++++++++ .../opencode-go-usage-fetcher.test.ts | 2 +- .../rate-limits/opencode-go-usage-fetcher.ts | 3 +- ...opencode-go-usage-source-selection.test.ts | 157 ++++++++++++++ .../opencode-go-usage-source-selection.ts | 132 ++++++++++++ .../rate-limit-service-test-harness.ts | 6 +- .../service-account-target-selection.test.ts | 4 +- .../service-antigravity-usage.test.ts | 4 +- .../service-inactive-account-previews.test.ts | 4 +- .../service-live-claude-usage.test.ts | 4 +- .../rate-limits/service-minimax-usage.test.ts | 4 +- .../service-refresh-orchestration.test.ts | 65 +++--- .../service-window-activation.test.ts | 14 +- .../service/service-configuration.ts | 1 + .../service/service-full-cycle-preparation.ts | 25 ++- src/main/rate-limits/service/service-state.ts | 1 + src/main/rate-limits/service/service-types.ts | 2 + .../startup/main-process-account-services.ts | 3 +- .../src/components/settings/AccountsPane.tsx | 6 +- ...ccounts-pane-provider-setting-sections.tsx | 53 +++++ .../settings/accounts-pane-types.ts | 2 +- .../status-bar-provider-visibility.test.ts | 11 + .../status-bar-provider-visibility.ts | 9 +- .../status-bar/usage-error-copy.test.ts | 28 ++- .../components/status-bar/usage-error-copy.ts | 14 ++ .../status-bar/use-status-bar-controller.ts | 1 + src/renderer/src/i18n/locales/en.json | 16 +- src/shared/default-global-settings.ts | 1 + src/shared/ghostty-import-preview.ts | 11 + src/shared/global-settings-types.ts | 13 +- src/shared/rate-limit-state-factory.ts | 1 + src/shared/rate-limit-types.test.ts | 1 + src/shared/rate-limit-types.ts | 9 + 43 files changed, 1246 insertions(+), 80 deletions(-) create mode 100644 src/main/rate-limits/opencode-go-api-key-source.test.ts create mode 100644 src/main/rate-limits/opencode-go-api-key-source.ts create mode 100644 src/main/rate-limits/opencode-go-usage-api.test.ts create mode 100644 src/main/rate-limits/opencode-go-usage-api.ts create mode 100644 src/main/rate-limits/opencode-go-usage-source-selection.test.ts create mode 100644 src/main/rate-limits/opencode-go-usage-source-selection.ts create mode 100644 src/shared/ghostty-import-preview.ts diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 26703ab4396..97190a6f890 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -60,6 +60,9 @@ export function updateSettings( if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) } + if ('opencodeGoApiKey' in updates && !updates.opencodeGoApiKey) { + operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeGoApiKey) + } if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) } diff --git a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts index 33303b660c7..8ead3d56fe3 100644 --- a/src/main/persistence/leasing-ssh-ptys/secret-validation.ts +++ b/src/main/persistence/leasing-ssh-ptys/secret-validation.ts @@ -6,6 +6,13 @@ export function isLegacyOpenCodeSessionCookie(value: string): boolean { ) } +// OpenCode Go keys are opaque bearer tokens; the console issues `sk-` +// (legacy) and `oc_sk_` (new console) prefixes. +export function isLegacyOpenCodeGoApiKey(value: string): boolean { + const trimmed = value.trim() + return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(trimmed) +} + export function isLegacySshPtyOwnerLease(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index e4034fa2b17..d2a31419a03 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -18,6 +18,7 @@ import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { + isLegacyOpenCodeGoApiKey, isLegacyOpenCodeSessionCookie, isLegacySshPtyOwnerLease } from '../leasing-ssh-ptys/secret-validation' @@ -107,6 +108,13 @@ export class LoadedStateParsingOperations { isLegacyOpenCodeSessionCookie ) } + if (parsed.settings?.opencodeGoApiKey) { + parsed.settings.opencodeGoApiKey = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeGoApiKey, + parsed.settings.opencodeGoApiKey, + isLegacyOpenCodeGoApiKey + ) + } if (parsed.settings?.httpProxyUrl) { const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus( PROTECTED_SECRET_SLOT.httpProxyUrl, diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index c8557f846af..026afd12c01 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -128,6 +128,10 @@ export class StateSerializationSecretHandlingOperations { PROTECTED_SECRET_SLOT.opencodeSessionCookie, this.runtime.state.settings.opencodeSessionCookie ), + opencodeGoApiKey: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeGoApiKey, + this.runtime.state.settings.opencodeGoApiKey ?? '' + ), httpProxyUrl: encryptToSentinel( PROTECTED_SECRET_SLOT.httpProxyUrl, this.runtime.state.settings.httpProxyUrl ?? '' diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts index 8445e67680f..90bb2ee334d 100644 --- a/src/main/protected-secret-persistence.ts +++ b/src/main/protected-secret-persistence.ts @@ -2,6 +2,7 @@ import { getSecretStore } from '../shared/secret-store' export const PROTECTED_SECRET_SLOT = { opencodeSessionCookie: 'settings.opencodeSessionCookie', + opencodeGoApiKey: 'settings.opencodeGoApiKey', httpProxyUrl: 'settings.httpProxyUrl', browserKagiSessionLink: 'ui.browserKagiSessionLink' } as const diff --git a/src/main/rate-limits/opencode-go-api-key-source.test.ts b/src/main/rate-limits/opencode-go-api-key-source.test.ts new file mode 100644 index 00000000000..aadbf42a2ab --- /dev/null +++ b/src/main/rate-limits/opencode-go-api-key-source.test.ts @@ -0,0 +1,154 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { + getOpenCodeAuthFilePath, + readOpenCodeAuthFileGoKey, + resolveOpenCodeGoApiKey +} from './opencode-go-api-key-source' + +// Placeholder values only — a real key must never reach a fixture. +const SETTINGS_KEY = 'settings-placeholder-key' +const ENVIRONMENT_KEY = 'environment-placeholder-key' +const AUTH_FILE_KEY = 'auth-file-placeholder-key' +const DATABASE_KEY = 'database-placeholder-key' + +const ENVIRONMENT_KEYS = ['XDG_DATA_HOME', 'OPENCODE_API_KEY', 'OPENCODE_DB'] as const + +describe('resolveOpenCodeGoApiKey', () => { + let dataHome: string + let originalEnvironment: Partial> + + function writeAuthFile(contents: unknown): void { + mkdirSync(join(dataHome, 'opencode'), { recursive: true }) + writeFileSync(join(dataHome, 'opencode', 'auth.json'), JSON.stringify(contents)) + } + + function writeCredentialDatabase(rows: { value: string; active: number; created: number }[]): { + path: string + } { + const path = join(dataHome, 'opencode-credentials.db') + const database = new Database(path) + database.exec( + 'CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, ' + + 'value TEXT, active INTEGER, time_created INTEGER)' + ) + rows.forEach((row, index) => { + database + .prepare( + 'INSERT INTO credential (id, integration_id, label, value, active, time_created) ' + + "VALUES (?, 'opencode-go', 'API key', ?, ?, ?)" + ) + .run(`cred_${index}`, row.value, row.active, row.created) + }) + database.close() + return { path } + } + + beforeEach(() => { + originalEnvironment = Object.fromEntries(ENVIRONMENT_KEYS.map((key) => [key, process.env[key]])) + dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-go-key-')) + process.env.XDG_DATA_HOME = dataHome + delete process.env.OPENCODE_API_KEY + // Keeps the credential-database tier from touching the developer's own store. + process.env.OPENCODE_DB = ':memory:' + }) + + afterEach(() => { + for (const key of ENVIRONMENT_KEYS) { + const value = originalEnvironment[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + rmSync(dataHome, { recursive: true, force: true }) + }) + + it('reads auth.json from XDG_DATA_HOME, which OpenCode uses on every platform', () => { + expect(getOpenCodeAuthFilePath({ XDG_DATA_HOME: '/data' })).toBe('/data/opencode/auth.json') + // OpenCode's global-roots.ts falls back to os.homedir() + .local/share even on Windows. + expect(getOpenCodeAuthFilePath({}, '/home/person')).toBe( + join('/home/person', '.local', 'share', 'opencode', 'auth.json') + ) + }) + + it('prefers the settings override over every other tier', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + + await expect( + resolveOpenCodeGoApiKey({ settingsOverride: ` ${SETTINGS_KEY} ` }) + ).resolves.toEqual({ status: 'found', key: SETTINGS_KEY, tier: 'settings' }) + }) + + it('prefers the key OpenCode saved on /connect over OPENCODE_API_KEY, as OpenCode does', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } }) + + await expect(resolveOpenCodeGoApiKey({ settingsOverride: ' ' })).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('falls back to OPENCODE_API_KEY when OpenCode stored no key', async () => { + process.env.OPENCODE_API_KEY = ENVIRONMENT_KEY + writeAuthFile({ anthropic: { type: 'api', key: 'not-the-go-key' } }) + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: ENVIRONMENT_KEY, + tier: 'environment' + }) + }) + + it('falls back to the key OpenCode 1.x saved on /connect', async () => { + writeAuthFile({ + anthropic: { type: 'oauth', refresh: 'r', access: 'a', expires: 1 }, + 'opencode-go': { type: 'api', key: AUTH_FILE_KEY } + }) + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: AUTH_FILE_KEY, + tier: 'opencode-auth-file' + }) + }) + + it('falls back to the OpenCode 2 credential table when auth.json has no entry', async () => { + writeAuthFile({ anthropic: { type: 'api', key: 'not-the-go-key' } }) + const { path } = writeCredentialDatabase([ + { + value: JSON.stringify({ type: 'key', key: 'stale-placeholder-key' }), + active: 0, + created: 2 + }, + { value: JSON.stringify({ type: 'key', key: DATABASE_KEY }), active: 1, created: 1 } + ]) + process.env.OPENCODE_DB = path + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ + status: 'found', + key: DATABASE_KEY, + tier: 'opencode-credential-database' + }) + }) + + it('reports missing when no tier holds a key', async () => { + writeAuthFile({ 'opencode-go': { type: 'oauth', refresh: 'r', access: 'a', expires: 1 } }) + + await expect(resolveOpenCodeGoApiKey({})).resolves.toEqual({ status: 'missing' }) + }) + + it('treats a malformed auth file as "no key" rather than a failure', () => { + mkdirSync(join(dataHome, 'opencode'), { recursive: true }) + writeFileSync(join(dataHome, 'opencode', 'auth.json'), '{not json') + + expect(readOpenCodeAuthFileGoKey(process.env)).toBeNull() + }) +}) diff --git a/src/main/rate-limits/opencode-go-api-key-source.ts b/src/main/rate-limits/opencode-go-api-key-source.ts new file mode 100644 index 00000000000..545bd058d8e --- /dev/null +++ b/src/main/rate-limits/opencode-go-api-key-source.ts @@ -0,0 +1,195 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + compareOpenCodeClaimPriority, + listOpenCodeDatabases +} from '../opencode-usage/opencode-database-discovery' +import { tableExists } from '../opencode-usage/schema-helpers' +import { isWslUncPath } from '../../shared/wsl-paths' +import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' +import Database from '../sqlite/sync-database' + +/** OpenCode's provider/integration id for the Go subscription. */ +const OPENCODE_GO_INTEGRATION_ID = 'opencode-go' +/** models.dev declares this env var for both `opencode` and `opencode-go`. */ +const OPENCODE_API_KEY_ENV = 'OPENCODE_API_KEY' +const AUTH_FILE_NAME = 'auth.json' +const MAX_AUTH_FILE_BYTES = 1_000_000 + +/** Where the key came from. Safe to log — never carries the key itself. */ +export type OpenCodeGoApiKeyTier = + | 'settings' + | 'environment' + | 'opencode-auth-file' + | 'opencode-credential-database' + +export type OpenCodeGoApiKeyResolution = + | { status: 'found'; key: string; tier: OpenCodeGoApiKeyTier } + | { status: 'missing' } + +export function getOpenCodeAuthFilePath( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory?: string +): string { + return join(resolveOpenCodeDataDirectory(environment, homeDirectory), AUTH_FILE_NAME) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Reads `{ type: , key: "…" }` from an already-narrowed record. */ +function keyFromCredentialRecord(value: unknown, kind: string): string | null { + if (!isRecord(value) || value.type !== kind) { + return null + } + return trimmedKey(value.key) +} + +function trimmedKey(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +/** + * Read the `opencode-go` API key OpenCode 1.x writes on `/connect`. + * + * Shape (opencode `packages/opencode/src/auth/index.ts`, `Api` schema): + * `{ "opencode-go": { "type": "api", "key": "…" } }`. + * @returns The key, or null when the file, the entry, or the key is absent. + */ +export function readOpenCodeAuthFileGoKey( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory?: string +): string | null { + const path = getOpenCodeAuthFilePath(environment, homeDirectory) + if (!existsSync(path)) { + return null + } + try { + const raw = readFileSync(path, 'utf-8') + if (raw.length > MAX_AUTH_FILE_BYTES) { + return null + } + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) { + return null + } + return keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api') + } catch { + // Why: a malformed or unreadable auth file is "no key here", not a fetch + // failure — later tiers and the cookie path still deserve their turn. + return null + } +} + +function selectCredentialKey(database: Database.Database): string | null { + if (!tableExists(database, 'credential')) { + return null + } + // OpenCode marks the chosen credential per integration with `active = 1`; + // newest wins among the rest (packages/core/src/credential.ts). + const rows: unknown[] = database + .prepare( + 'SELECT value FROM credential WHERE integration_id = ? ' + + 'ORDER BY active DESC, time_created DESC LIMIT 8' + ) + .all(OPENCODE_GO_INTEGRATION_ID) + for (const row of rows) { + if (!isRecord(row) || typeof row.value !== 'string') { + continue + } + try { + const key = keyFromCredentialRecord(JSON.parse(row.value), 'key') + if (key) { + return key + } + } catch { + continue + } + } + return null +} + +/** + * Read the `opencode-go` key from OpenCode's `credential` table. + * + * OpenCode 2 imports `auth.json` into SQLite once (migration + * `20260805200742_import_legacy_credentials`) and every later `/connect` writes + * only there, so a fresh OpenCode 2 install has no `auth.json` entry at all. + * The table itself is not a version marker — 1.18.x creates it too (verified + * empty on a real 1.18.16 install), so probe it regardless of version. + * @returns The key, or null when no database, table, or row carries one. + */ +export async function readOpenCodeCredentialDatabaseGoKey(): Promise { + let paths: string[] + try { + paths = [...(await listOpenCodeDatabases())].sort(compareOpenCodeClaimPriority) + } catch { + return null + } + for (const path of paths) { + // A synchronous open against a 9p/UNC share can hang the main process, and + // the status bar is never worth that; the other tiers still apply. + if (isWslUncPath(path)) { + continue + } + let database: Database.Database | null = null + try { + database = new Database(path, { readonly: true, fileMustExist: true }) + database.pragma('query_only = ON') + const key = selectCredentialKey(database) + if (key) { + return key + } + } catch { + // A locked, WAL-index-less, or foreign-schema database is not an error + // here; it just holds no key we can read. + continue + } finally { + database?.close() + } + } + return null +} + +/** + * Resolve the OpenCode Go API key in the documented precedence order. + * + * Settings override, then whatever OpenCode itself stored on `/connect` — + * `auth.json`, then the `credential` table — then `OPENCODE_API_KEY`. Both + * stores are probed on every version: 1.18.x creates the `credential` table too, + * so its presence is not a 2.x marker, and a 2.x install that never ran the + * legacy import has no `auth.json` at all. + * The stored key outranks the env var because OpenCode applies it after env, + * and the env var is shared with the Zen provider. + * @param input.settingsOverride - The key a user pasted into Orca's settings. + * @param input.environment - Process environment to read; injectable for tests. + * @returns The first key found and the tier it came from, or `missing`. + */ +export async function resolveOpenCodeGoApiKey(input: { + settingsOverride?: string + environment?: NodeJS.ProcessEnv +}): Promise { + const environment = input.environment ?? process.env + const override = trimmedKey(input.settingsOverride) + if (override) { + return { status: 'found', key: override, tier: 'settings' } + } + const fromAuthFile = readOpenCodeAuthFileGoKey(environment) + if (fromAuthFile) { + return { status: 'found', key: fromAuthFile, tier: 'opencode-auth-file' } + } + const fromDatabase = await readOpenCodeCredentialDatabaseGoKey() + if (fromDatabase) { + return { status: 'found', key: fromDatabase, tier: 'opencode-credential-database' } + } + const fromEnvironment = trimmedKey(environment[OPENCODE_API_KEY_ENV]) + if (fromEnvironment) { + return { status: 'found', key: fromEnvironment, tier: 'environment' } + } + return { status: 'missing' } +} diff --git a/src/main/rate-limits/opencode-go-status-parsing.test.ts b/src/main/rate-limits/opencode-go-status-parsing.test.ts index f8e0eeb29e7..1116dcad71b 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.test.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' +import { + parseOpenCodeGoStatusPayload, + parseOpenCodeGoUsageApiPayload +} from './opencode-go-status-parsing' const ISSUE_PAYLOAD = { access: { @@ -116,3 +119,35 @@ describe('parseOpenCodeGoStatusPayload', () => { ).toBeNull() }) }) + +describe('parseOpenCodeGoUsageApiPayload', () => { + it('clamps an out-of-range percent and tolerates a missing resetsAt', () => { + const parsed = parseOpenCodeGoUsageApiPayload( + JSON.stringify({ + usage: { + rolling: { status: 'rate-limited', percent: 140 }, + weekly: { status: 'ok', percent: -5, resetsAt: 'not a date' } + } + }) + ) + + expect(parsed?.session).toEqual({ + usedPercent: 100, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }) + expect(parsed?.weekly.usedPercent).toBe(0) + expect(parsed?.monthly).toBeNull() + }) + + it('returns null for the console error bodies and other non-usage payloads', () => { + expect( + parseOpenCodeGoUsageApiPayload( + JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Unauthorized' } }) + ) + ).toBeNull() + expect(parseOpenCodeGoUsageApiPayload('{not json')).toBeNull() + expect(parseOpenCodeGoUsageApiPayload('')).toBeNull() + }) +}) diff --git a/src/main/rate-limits/opencode-go-status-parsing.ts b/src/main/rate-limits/opencode-go-status-parsing.ts index 9137dd911a0..cc98fd477ed 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.ts @@ -55,6 +55,51 @@ function meterToWindow(meter: unknown, windowMinutes: number): RateLimitWindow | } } +// `GET /zen/go/v1/usage` reports each window as +// `{ status: "ok" | "rate-limited", percent: 0-100, resetsAt: }` +// (console `routes/zen/go/v1/usage.ts` + `Subscription.analyze*Usage`). +function percentMeterToWindow(meter: unknown, windowMinutes: number): RateLimitWindow | null { + if (!isRecord(meter) || typeof meter.percent !== 'number' || !Number.isFinite(meter.percent)) { + return null + } + return { + usedPercent: Math.min(100, Math.max(0, meter.percent)), + windowMinutes, + resetsAt: parseResetsAt(meter.resetsAt), + resetDescription: null + } +} + +/** + * Parse the OpenCode Go usage API body into Orca's usage windows. + * @param text - Raw response body from `GET /zen/go/v1/usage`. + * @returns The mapped windows, or null when the body is not a usage payload. + */ +export function parseOpenCodeGoUsageApiPayload(text: string): OpenCodeGoUsageWindows | null { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return null + } + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return null + } + if (!isRecord(payload) || !isRecord(payload.usage)) { + return null + } + const session = percentMeterToWindow(payload.usage.rolling, SESSION_WINDOW_MINUTES) + const weekly = percentMeterToWindow(payload.usage.weekly, WEEKLY_WINDOW_MINUTES) + if (!session || !weekly) { + return null + } + return { + session, + weekly, + monthly: percentMeterToWindow(payload.usage.monthly, MONTHLY_WINDOW_MINUTES) + } +} + export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null { if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { return null diff --git a/src/main/rate-limits/opencode-go-usage-api.test.ts b/src/main/rate-limits/opencode-go-usage-api.test.ts new file mode 100644 index 00000000000..aba878d59a6 --- /dev/null +++ b/src/main/rate-limits/opencode-go-usage-api.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock }, + session: { defaultSession: {} } +})) +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue(undefined) +})) + +import { fetchOpenCodeGoUsageWithApiKey, OPENCODE_GO_USAGE_API_URL } from './opencode-go-usage-api' + +// Placeholder only — a real key must never reach a fixture. +const API_KEY = 'placeholder-go-key' + +function makeResponse(body: string, status = 200): Response { + return new Response(body, { status }) +} + +const USAGE_BODY = { + usage: { + rolling: { status: 'ok', percent: 30, resetsAt: '2026-09-23T17:00:00.000Z' }, + weekly: { status: 'ok', percent: 51, resetsAt: '2026-09-28T00:00:00.000Z' }, + monthly: { status: 'rate-limited', percent: 100, resetsAt: '2026-10-12T00:00:00.000Z' } + } +} + +describe('fetchOpenCodeGoUsageWithApiKey', () => { + beforeEach(() => { + netFetchMock.mockReset() + }) + + it('sends the key as a bearer token and maps every window', async () => { + netFetchMock.mockResolvedValue(makeResponse(JSON.stringify(USAGE_BODY))) + + const outcome = await fetchOpenCodeGoUsageWithApiKey(API_KEY) + + expect(netFetchMock).toHaveBeenCalledWith( + OPENCODE_GO_USAGE_API_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Authorization: `Bearer ${API_KEY}` }) + }) + ) + expect(outcome).toEqual({ + kind: 'ok', + windows: { + session: { + usedPercent: 30, + windowMinutes: 300, + resetsAt: Date.parse('2026-09-23T17:00:00.000Z'), + resetDescription: null + }, + weekly: { + usedPercent: 51, + windowMinutes: 10080, + resetsAt: Date.parse('2026-09-28T00:00:00.000Z'), + resetDescription: null + }, + monthly: { + usedPercent: 100, + windowMinutes: 43200, + resetsAt: Date.parse('2026-10-12T00:00:00.000Z'), + resetDescription: null + } + } + }) + }) + + it('keeps usage when the monthly window is absent', async () => { + netFetchMock.mockResolvedValue( + makeResponse( + JSON.stringify({ + usage: { rolling: USAGE_BODY.usage.rolling, weekly: USAGE_BODY.usage.weekly } + }) + ) + ) + + const outcome = await fetchOpenCodeGoUsageWithApiKey(API_KEY) + + expect(outcome.kind).toBe('ok') + expect(outcome.kind === 'ok' && outcome.windows.monthly).toBeNull() + }) + + it('reports a rejected key as unauthorized', async () => { + netFetchMock.mockResolvedValue( + makeResponse( + JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Unauthorized' } }), + 401 + ) + ) + + await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({ kind: 'unauthorized' }) + }) + + it('reports an entitlement refusal as a missing subscription, not a parse failure', async () => { + netFetchMock.mockResolvedValue( + makeResponse( + JSON.stringify({ + type: 'error', + error: { type: 'EntitlementError', message: 'OpenCode Go subscription required.' } + }), + 403 + ) + ) + + await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({ + kind: 'no-subscription' + }) + }) + + it('trusts the error name over the status when a proxied console answers differently', async () => { + netFetchMock.mockResolvedValue( + makeResponse(JSON.stringify({ error: { type: 'EntitlementError' } }), 402) + ) + + await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({ + kind: 'no-subscription' + }) + }) + + it('reports a sign-in page reached through a redirect as unauthorized', async () => { + netFetchMock.mockResolvedValue( + new Response('Log in', { + status: 200, + headers: { 'content-type': 'text/html; charset=utf-8' } + }) + ) + + await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({ kind: 'unauthorized' }) + }) + + it('reports a malformed success body as a parse failure', async () => { + netFetchMock.mockResolvedValue(makeResponse('{"usage":{"rolling":{}}}')) + + await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({ + kind: 'failed', + message: 'Could not parse OpenCode Go usage response' + }) + }) + + it('never leaks the key through a network failure message', async () => { + netFetchMock.mockRejectedValue(new Error(`request to ${OPENCODE_GO_USAGE_API_URL} failed`)) + + const outcome = await fetchOpenCodeGoUsageWithApiKey(API_KEY) + + expect(outcome.kind).toBe('failed') + expect(outcome.kind === 'failed' && outcome.message).not.toContain(API_KEY) + }) +}) diff --git a/src/main/rate-limits/opencode-go-usage-api.ts b/src/main/rate-limits/opencode-go-usage-api.ts new file mode 100644 index 00000000000..77a711d30ef --- /dev/null +++ b/src/main/rate-limits/opencode-go-usage-api.ts @@ -0,0 +1,113 @@ +import { net, session } from 'electron' +import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' +import { + parseOpenCodeGoUsageApiPayload, + type OpenCodeGoUsageWindows +} from './opencode-go-status-parsing' + +export const OPENCODE_GO_USAGE_API_URL = 'https://opencode.ai/zen/go/v1/usage' +const API_TIMEOUT_MS = 15_000 +const MAX_ERROR_BODY_CHARS = 4_000 + +/** + * Outcome of one Bearer-authenticated usage request. + * + * `unauthorized` and `no-subscription` are the server's two definitive verdicts + * about the key; `failed` covers everything Orca could not get an answer from. + */ +export type OpenCodeGoUsageApiOutcome = + | { kind: 'ok'; windows: OpenCodeGoUsageWindows } + | { kind: 'unauthorized' } + | { kind: 'no-subscription' } + | { kind: 'failed'; message: string } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Error type names the console returns; see `routes/zen/go/v1/usage.ts`. */ +function readErrorType(body: string): string | null { + if (!body || body.length > MAX_ERROR_BODY_CHARS) { + return null + } + try { + const parsed: unknown = JSON.parse(body) + if (!isRecord(parsed) || !isRecord(parsed.error)) { + return null + } + const type = parsed.error.type + return typeof type === 'string' ? type : null + } catch { + return null + } +} + +function classifyFailure(status: number, body: string): OpenCodeGoUsageApiOutcome { + const errorType = readErrorType(body) + // Why type-first: a migrated account's request is proxied to the new console, + // which owns its own status codes; the error name is the stable signal. + if (errorType === 'EntitlementError' || status === 403) { + return { kind: 'no-subscription' } + } + if (errorType === 'AuthError' || status === 401) { + return { kind: 'unauthorized' } + } + return { kind: 'failed', message: `OpenCode Go usage request failed (${status})` } +} + +/** + * Fetch OpenCode Go usage with the account's API key. + * + * Runs on the default Electron session so the app-wide proxy guard covers it. + * The key is sent only as an `Authorization` header and never reaches a log, + * a returned message, or telemetry. + * @param apiKey - The resolved OpenCode Go API key. + * @param signal - Optional cancellation signal for the enclosing fetch cycle. + * @returns The parsed usage windows or a classified failure. + */ +export async function fetchOpenCodeGoUsageWithApiKey( + apiKey: string, + signal?: AbortSignal +): Promise { + await ensureElectronProxyFromEnvironment({ + proxySession: session.defaultSession, + probeUrl: OPENCODE_GO_USAGE_API_URL + }).catch(() => {}) + + const requestSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + + let response: Response + try { + response = await net.fetch(OPENCODE_GO_USAGE_API_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json' + }, + signal: requestSignal + }) + } catch (error) { + // Why fixed prefix: the thrown message can embed the request, and the + // request carries the key. + return { + kind: 'failed', + message: `OpenCode Go usage request failed (${error instanceof Error ? error.name : 'network error'})` + } + } + + const body = await response.text().catch(() => '') + if (!response.ok) { + return classifyFailure(response.status, body) + } + const windows = parseOpenCodeGoUsageApiPayload(body) + if (!windows) { + // Why: Electron follows redirects, so a key bounced to console sign-in arrives as a 200 page. + if (response.headers.get('content-type')?.includes('text/html')) { + return { kind: 'unauthorized' } + } + return { kind: 'failed', message: 'Could not parse OpenCode Go usage response' } + } + return { kind: 'ok', windows } +} diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 704a15b9b73..9e07f83f472 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -484,7 +484,7 @@ describe('fetchOpenCodeGoRateLimits', () => { const result = await fetchOpenCodeGoRateLimits('auth=mytoken') expect(result.status).toBe('error') - expect(result.error).toMatch(/No workspace ID found/) + expect(result.error).toMatch(/No workspace found.*OpenCode Go API key/) }) it('returns error on non-ok usage response', async () => { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 1d9699bec88..451d2f316bc 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -212,7 +212,8 @@ async function fetchOpenCodeGoRateLimitsWithSession( weekly: null, monthly: null, updatedAt: Date.now(), - error: 'No workspace ID found — set a Workspace ID override in settings', + error: + 'No workspace found for this cookie — add an OpenCode Go API key (or run /connect in OpenCode), or set a Workspace ID override', status: 'error' } } diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts new file mode 100644 index 00000000000..c907705b45e --- /dev/null +++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import type * as usageFetcher from './opencode-go-usage-fetcher' + +const resolveApiKeyMock = vi.hoisted(() => vi.fn()) +const fetchWithApiKeyMock = vi.hoisted(() => vi.fn()) +const fetchWithCookieMock = vi.hoisted(() => vi.fn()) + +vi.mock('./opencode-go-api-key-source', () => ({ resolveOpenCodeGoApiKey: resolveApiKeyMock })) +vi.mock('./opencode-go-usage-api', () => ({ + fetchOpenCodeGoUsageWithApiKey: fetchWithApiKeyMock +})) +vi.mock('./opencode-go-usage-fetcher', async (importOriginal) => { + const actual: typeof usageFetcher = await importOriginal() + return { + normalizeCookieInput: actual.normalizeCookieInput, + fetchOpenCodeGoRateLimits: fetchWithCookieMock + } +}) + +import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' + +// Placeholder only — a real key must never reach a fixture. +const API_KEY = 'placeholder-go-key' +const COOKIE = 'auth=placeholder; __Host-console_session=placeholder' + +const WINDOWS: OpenCodeGoUsageWindows = { + session: { usedPercent: 12, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 34, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + monthly: null +} + +function cookieResult(status: ProviderRateLimits['status']): ProviderRateLimits { + return { + provider: 'opencode-go', + session: status === 'ok' ? WINDOWS.session : null, + weekly: status === 'ok' ? WINDOWS.weekly : null, + monthly: null, + updatedAt: Date.now(), + error: status === 'ok' ? null : 'Usage fetch failed (401)', + status + } +} + +describe('fetchOpenCodeGoUsage', () => { + beforeEach(() => { + resolveApiKeyMock.mockReset() + fetchWithApiKeyMock.mockReset() + fetchWithCookieMock.mockReset() + }) + + it('uses the API key ahead of a configured cookie and records its tier', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'environment' }) + fetchWithApiKeyMock.mockResolvedValue({ kind: 'ok', windows: WINDOWS }) + const onApiKeyResolved = vi.fn() + + const result = await fetchOpenCodeGoUsage({ cookie: COOKIE, onApiKeyResolved }) + + expect(fetchWithCookieMock).not.toHaveBeenCalled() + expect(onApiKeyResolved).toHaveBeenCalledWith({ + status: 'found', + key: API_KEY, + tier: 'environment' + }) + expect(result.status).toBe('ok') + expect(result.session).toEqual(WINDOWS.session) + expect(result.usageMetadata?.credentialSource).toBe('environment') + }) + + it('passes the settings override down as the highest-precedence tier', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'missing' }) + + await fetchOpenCodeGoUsage({ cookie: '', settingsApiKey: API_KEY }) + + expect(resolveApiKeyMock).toHaveBeenCalledWith({ settingsOverride: API_KEY }) + }) + + it('names the missing subscription instead of a generic refresh failure', async () => { + resolveApiKeyMock.mockResolvedValue({ + status: 'found', + key: API_KEY, + tier: 'opencode-auth-file' + }) + fetchWithApiKeyMock.mockResolvedValue({ kind: 'no-subscription' }) + + const result = await fetchOpenCodeGoUsage({ cookie: '' }) + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('no-subscription') + expect(result.error).toContain('no OpenCode Go subscription') + expect(result.error).not.toContain(API_KEY) + }) + + it('keeps a working cookie account alive when the key has no Go entitlement', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' }) + fetchWithApiKeyMock.mockResolvedValue({ kind: 'no-subscription' }) + fetchWithCookieMock.mockResolvedValue(cookieResult('ok')) + + const result = await fetchOpenCodeGoUsage({ cookie: COOKIE }) + + expect(fetchWithCookieMock).toHaveBeenCalledWith(COOKIE, undefined, undefined) + expect(result.status).toBe('ok') + }) + + it('reports the key verdict when the cookie fallback also fails', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' }) + fetchWithApiKeyMock.mockResolvedValue({ kind: 'unauthorized' }) + fetchWithCookieMock.mockResolvedValue(cookieResult('error')) + + const result = await fetchOpenCodeGoUsage({ cookie: COOKIE }) + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('stale-token') + expect(result.error).toContain('/connect') + }) + + it('surfaces a transport failure without exposing the key', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'found', key: API_KEY, tier: 'settings' }) + fetchWithApiKeyMock.mockResolvedValue({ + kind: 'failed', + message: 'OpenCode Go usage request failed (503)' + }) + + const result = await fetchOpenCodeGoUsage({ cookie: '' }) + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('server') + expect(result.error).toBe('OpenCode Go usage request failed (503)') + }) + + it('falls back to the cookie path when no key exists anywhere', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'missing' }) + fetchWithCookieMock.mockResolvedValue(cookieResult('ok')) + + const proxy = { httpProxyUrl: 'http://proxy.example:8080', httpProxyBypassRules: '' } + const result = await fetchOpenCodeGoUsage({ + cookie: COOKIE, + workspaceIdOverride: 'wrk_abc', + networkProxySettings: proxy + }) + + expect(fetchWithApiKeyMock).not.toHaveBeenCalled() + expect(fetchWithCookieMock).toHaveBeenCalledWith(COOKIE, 'wrk_abc', proxy) + expect(result.status).toBe('ok') + }) + + it('stays unavailable when neither a key nor a cookie is configured', async () => { + resolveApiKeyMock.mockResolvedValue({ status: 'missing' }) + + const result = await fetchOpenCodeGoUsage({ cookie: ' ' }) + + expect(fetchWithCookieMock).not.toHaveBeenCalled() + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + }) +}) diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts new file mode 100644 index 00000000000..9d5399a585e --- /dev/null +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -0,0 +1,132 @@ +import type { NetworkProxySettings } from '../../shared/network-proxy' +import type { ProviderRateLimits, UsageRateLimitMetadata } from '../../shared/rate-limit-types' +import { + resolveOpenCodeGoApiKey, + type OpenCodeGoApiKeyResolution +} from './opencode-go-api-key-source' +import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import { + fetchOpenCodeGoUsageWithApiKey, + type OpenCodeGoUsageApiOutcome +} from './opencode-go-usage-api' +import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher' + +export type OpenCodeGoUsageSourceInput = { + /** Explicit Orca override; the highest-precedence key tier. */ + settingsApiKey?: string + cookie: string + workspaceIdOverride?: string + networkProxySettings?: NetworkProxySettings + signal?: AbortSignal + /** Lets the caller record key presence without handling the key itself. */ + onApiKeyResolved?: (resolution: OpenCodeGoApiKeyResolution) => void +} + +function emptyResult( + error: string, + status: 'error' | 'unavailable', + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { + return { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status, + ...(usageMetadata ? { usageMetadata } : {}) + } +} + +function usageResult( + windows: OpenCodeGoUsageWindows, + credentialSource: string +): ProviderRateLimits { + return { + provider: 'opencode-go', + session: windows.session, + weekly: windows.weekly, + monthly: windows.monthly, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { source: 'web', credentialSource } + } +} + +// Why explicit copy per verdict: #22257's reporter was misled by a generic +// "could not parse" for what was really an entitlement answer, so each verdict +// says what the account's state is and what to do about it. +function apiFailureResult( + outcome: Exclude, + credentialSource: string +): ProviderRateLimits { + if (outcome.kind === 'no-subscription') { + return emptyResult( + 'This OpenCode account has no OpenCode Go subscription. Subscribe at opencode.ai to see Go usage.', + 'error', + { source: 'web', credentialSource, failureKind: 'no-subscription' } + ) + } + if (outcome.kind === 'unauthorized') { + return emptyResult( + 'The OpenCode Go API key was rejected. Run /connect in OpenCode, or replace the key in Settings.', + 'error', + { source: 'web', credentialSource, failureKind: 'stale-token' } + ) + } + return emptyResult(outcome.message, 'error', { + source: 'web', + credentialSource, + failureKind: 'server' + }) +} + +/** + * Fetch OpenCode Go usage from the best source the account offers. + * + * The keyed `GET /zen/go/v1/usage` endpoint is tried first because it needs no + * console session; the legacy cookie path stays as the fallback for Black and + * other legacy-console accounts, which upstream `fe51b0b19a` still serves. + * @param input.settingsApiKey - Explicit Orca override, if the user set one. + * @param input.cookie - The opencode.ai cookie header from Orca settings. + * @returns A provider snapshot for the status bar. + */ +export async function fetchOpenCodeGoUsage( + input: OpenCodeGoUsageSourceInput +): Promise { + const apiKeyResolution = await resolveOpenCodeGoApiKey({ + settingsOverride: input.settingsApiKey + }) + input.onApiKeyResolved?.(apiKeyResolution) + const hasCookie = Boolean(normalizeCookieInput(input.cookie)) + if (apiKeyResolution.status === 'missing') { + return hasCookie + ? fetchOpenCodeGoRateLimits( + input.cookie, + input.workspaceIdOverride, + input.networkProxySettings + ) + : emptyResult('No OpenCode Go API key or session cookie configured', 'unavailable', { + failureKind: 'missing-credentials' + }) + } + + const { key, tier } = apiKeyResolution + const outcome = await fetchOpenCodeGoUsageWithApiKey(key, input.signal) + if (outcome.kind === 'ok') { + return usageResult(outcome.windows, tier) + } + if (!hasCookie) { + return apiFailureResult(outcome, tier) + } + // A Black-only account holds a key with no Go entitlement, so its usage still + // only exists behind the console session; keep that path working. + const cookieResult = await fetchOpenCodeGoRateLimits( + input.cookie, + input.workspaceIdOverride, + input.networkProxySettings + ) + return cookieResult.status === 'ok' ? cookieResult : apiFailureResult(outcome, tier) +} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 041ede99b62..25f7baeb78d 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -8,7 +8,7 @@ import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' -import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' export type Deferred = { @@ -85,7 +85,7 @@ export function unavailableProvider( export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24)) vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0)) - vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0)) + vi.mocked(fetchOpenCodeGoUsage).mockImplementation(async () => okProvider('opencode-go', 0)) vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) @@ -95,7 +95,7 @@ export function mockFreshBackgroundProviderFetches(): void { export function resetRateLimitProviderMocks(): void { vi.clearAllMocks() vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 0, Date.now())) vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now())) vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now())) vi.mocked(fetchGrokRateLimits).mockResolvedValue({ diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 7f9174f5d1f..16373024614 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -28,8 +28,8 @@ vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db0..9f5afe9423d 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -27,8 +27,8 @@ vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 3c629351382..1dee94e8c99 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -35,8 +35,8 @@ vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index cf9e306a110..9b9137568fb 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -32,8 +32,8 @@ vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 819b4c93f5d..89b311827b6 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -29,8 +29,8 @@ vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 44f4b05b3ba..9b70fa0ea51 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -8,7 +8,7 @@ import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' -import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { deferred, errorProvider, @@ -36,8 +36,8 @@ vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ @@ -112,7 +112,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).not.toHaveBeenCalled() expect(fetchCodexRateLimits).not.toHaveBeenCalled() expect(fetchGeminiRateLimits).not.toHaveBeenCalled() - expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled() + expect(fetchOpenCodeGoUsage).not.toHaveBeenCalled() expect(fetchKimiRateLimits).not.toHaveBeenCalled() expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled() expect(service.getState().grokAuthConfigured).toBe(true) @@ -272,9 +272,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( - okProvider('opencode-go', 40, Date.now()) - ) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40, Date.now())) vi.mocked(fetchKimiRateLimits).mockResolvedValueOnce(okProvider('kimi', 50, Date.now())) vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 60, Date.now())) vi.mocked(fetchGrokRateLimits).mockReturnValueOnce(grok.promise) @@ -365,7 +363,8 @@ describe('RateLimitService', () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=abc123', - workspaceIdOverride: '' + workspaceIdOverride: '', + apiKey: '' })) const networkProxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -377,9 +376,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( - okProvider('opencode-go', 40, Date.now()) - ) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40, Date.now())) await service.refresh() @@ -395,11 +392,14 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true) - expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) - expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith( - 'session=abc123', - undefined, - networkProxySettings + expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith( + expect.objectContaining({ + cookie: 'session=abc123', + workspaceIdOverride: undefined, + settingsApiKey: '', + networkProxySettings + }) ) expect(fetchGrokRateLimits).toHaveBeenCalledWith({ signal: expect.any(AbortSignal), @@ -478,9 +478,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(geminiWithBuckets) - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( - okProvider('opencode-go', 0, Date.now()) - ) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 0, Date.now())) await service.refresh() @@ -496,15 +494,14 @@ describe('RateLimitService', () => { const service = new RateLimitService() service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: '', - workspaceIdOverride: '' + workspaceIdOverride: '', + apiKey: '' })) vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down')) vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) vi.mocked(fetchGeminiRateLimits).mockRejectedValueOnce(new Error('gemini down')) - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( - okProvider('opencode-go', 40, Date.now()) - ) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40, Date.now())) await service.refresh() @@ -522,23 +519,22 @@ describe('RateLimitService', () => { let cookie = 'session=valid' service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: cookie, - workspaceIdOverride: '' + workspaceIdOverride: '', + apiKey: '' })) // 1. Success fetch vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now())) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20, Date.now())) vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 30, Date.now())) - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( - okProvider('opencode-go', 40, Date.now()) - ) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 40, Date.now())) await service.refresh() expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40) // 2. Clear cookie -> should become unavailable and LOSE the 40% data cookie = '' - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({ + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue({ provider: 'opencode-go', session: null, weekly: null, @@ -560,27 +556,24 @@ describe('RateLimitService', () => { let workspaceId = 'wrk_A' service.setOpenCodeGoConfigResolver(() => ({ sessionCookie: 'session=valid', - workspaceIdOverride: workspaceId + workspaceIdOverride: workspaceId, + apiKey: '' })) // 1. Success fetch for Workspace A - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( - okProvider('opencode-go', 40, Date.now()) - ) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 40, Date.now())) await service.refresh() expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40) // 2. Change Workspace ID to B -> old data from A should be discarded workspaceId = 'wrk_B' - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( - okProvider('opencode-go', 10, Date.now()) - ) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 10, Date.now())) await service.refresh() expect(service.getState().opencodeGo?.session?.usedPercent).toBe(10) // 3. Clear Workspace ID (automatic) but it fails -> should show error, NOT stale data from B workspaceId = '' - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({ + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue({ provider: 'opencode-go', session: null, weekly: null, diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index ec813a53d94..2b53c0919a1 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -7,7 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' -import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { asRateLimitWindow, deferred, @@ -37,8 +37,8 @@ vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() +vi.mock('./opencode-go-usage-source-selection', () => ({ + fetchOpenCodeGoUsage: vi.fn() })) vi.mock('./minimax/minimax-fetcher', () => ({ @@ -188,7 +188,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) - expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) @@ -546,7 +546,7 @@ describe('RateLimitService', () => { vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude')) vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex')) vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini')) - vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go')) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(unavailableProvider('opencode-go')) vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi')) const service = new RateLimitService() @@ -563,7 +563,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) - expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(1) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(5 * 60 * 1000) @@ -573,7 +573,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2) - expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2) + expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(2) expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) service.stop() diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 655ba9b93d8..f2629aa4fea 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -125,6 +125,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. minimaxCookieConfigured: hasMiniMaxSessionCookie(), minimaxApiKeyConfigured: hasMiniMaxApiKey(), + opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured, grokAuthConfigured: this.grokAuthConfigured, claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 7551652bca1..3e4f77b906d 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -4,7 +4,8 @@ import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' -import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher' +import { createHash } from 'node:crypto' +import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' import { RateLimitServiceFetchPolicy } from './service-fetch-policy' import type { ClaudeRuntimeAuthPreparation, @@ -76,6 +77,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const openCodeGoConfig = this.openCodeGoConfigResolver?.() const cookie = openCodeGoConfig?.sessionCookie ?? '' const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' + const openCodeGoApiKey = openCodeGoConfig?.apiKey ?? '' const miniMaxConfigResult = this.resolveMiniMaxConfig() const miniMaxCookie = miniMaxConfigResult.config.sessionCookie const miniMaxGroupId = miniMaxConfigResult.config.groupId @@ -88,7 +90,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ this.grokAuthConfigured = grokAuthReadResult.status === 'ok' // Discard stale data on config change — it belongs to a different session/workspace. - const currentConfigHash = `${cookie}|${workspaceIdOverride}` + // Digest, not the key: this string only has to change when the account does. + const apiKeyFingerprint = openCodeGoApiKey + ? createHash('sha256').update(openCodeGoApiKey).digest('hex') + : '' + const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}` const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash if (opencodeConfigChanged) { this.lastOpencodeConfigHash = currentConfigHash @@ -158,11 +164,18 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ signal })), fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( + fetchOpenCodeGoUsage({ + settingsApiKey: openCodeGoApiKey, + // Why here: the key can also come from the environment or OpenCode's + // own store, so presence is only known once the fetch resolves it. + onApiKeyResolved: (resolution) => { + this.openCodeGoApiKeyConfigured = resolution.status === 'found' + }, cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), + workspaceIdOverride: workspaceIdOverride || undefined, + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), this.fetchKimiWithResolvedHome(), miniMaxConfigResult.error ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index c7e9aa4751a..5cea2a26338 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -34,6 +34,7 @@ export abstract class RateLimitServiceState { grok: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + protected openCodeGoApiKeyConfigured = false protected pollInterval: number = DEFAULT_POLL_MS protected timer: ReturnType | null = null protected deferredStartupRefreshTimer: ReturnType | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 414fa9b8dfd..92204150d9a 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -44,6 +44,8 @@ export type ClaudeAuthPreparationResolver = ( export type OpenCodeGoRateLimitConfig = { sessionCookie: string workspaceIdOverride: string + /** Explicit Orca override; empty means fall back to env and OpenCode's own store. */ + apiKey: string } export type MiniMaxRateLimitConfig = { diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 4bd8b9388cb..7a677ed760e 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -119,7 +119,8 @@ export function initializeMainProcessAccountServices(): void { const settings = store.getSettings() return { sessionCookie: settings.opencodeSessionCookie, - workspaceIdOverride: settings.opencodeWorkspaceId + workspaceIdOverride: settings.opencodeWorkspaceId, + apiKey: settings.opencodeGoApiKey } }) state.rateLimits.setMiniMaxConfigResolver(() => { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index eb19fc54306..3af24a986fd 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -78,7 +78,9 @@ export function AccountsPane({ const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const recordedOpenCodeSettingEditsRef = useRef>(new Set()) + const recordedOpenCodeSettingEditsRef = useRef>( + new Set() + ) const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') const [miniMaxApiKeyDraft, setMiniMaxApiKeyDraft] = useState('') const [miniMaxApiKeyConfigured, setMiniMaxApiKeyConfigured] = useState(false) @@ -214,7 +216,7 @@ export function AccountsPane({ const accountRuntimeUnavailable = accountRuntime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading - const recordOpenCodeSettingEdit = (field: 'cookie' | 'workspaceId'): void => { + const recordOpenCodeSettingEdit = (field: 'cookie' | 'workspaceId' | 'apiKey'): void => { if (recordedOpenCodeSettingEditsRef.current.has(field)) { return } diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index a04690c52c0..61428c5cd3b 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -95,6 +95,59 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):

+ + +
+ recordOpenCodeSettingEdit('apiKey')} + commit={(opencodeGoApiKey) => updateSettings({ opencodeGoApiKey })} + placeholder={translate( + 'auto.components.settings.AccountsPane.opencodeGo.apiKey.placeholder', + 'Leave blank to use the key saved by /connect or OPENCODE_API_KEY' + )} + spellCheck={false} + className="flex-1 text-xs" + /> + {settings.opencodeGoApiKey && ( + + )} +
+

+ {translate( + 'auto.components.settings.AccountsPane.opencodeGo.apiKey.help', + 'Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts.' + )} +

+
+ > runCodexAccountAction: CodexAccountActionRunner - recordOpenCodeSettingEdit: (field: 'cookie' | 'workspaceId') => void + recordOpenCodeSettingEdit: (field: 'cookie' | 'workspaceId' | 'apiKey') => void miniMaxRateLimits: ProviderRateLimits | null miniMaxApiKeyDraft: string setMiniMaxApiKeyDraft: Dispatch> diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index ab2a0eca23f..239c3314eb3 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -75,6 +75,7 @@ function usageSettings(overrides: Partial = {}): UsagePro antigravityUsageConfigured: false, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, + opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, ...overrides } @@ -128,6 +129,16 @@ describe('hasUsageProviderSettings', () => { expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( false ) + // Why: an OPENCODE_API_KEY or a key OpenCode saved on /connect is invisible + // to the renderer, so main's presence flag is the only durable signal. + expect(hasUsageProviderSettings(usageSettings({ opencodeGoApiKeyConfigured: true }))).toBe(true) + expect( + hasUsageProviderSettingsForProvider( + 'opencode-go', + usageSettings({ opencodeGoApiKeyConfigured: true }) + ) + ).toBe(true) + expect(hasUsageProviderSettingsForProvider('opencode-go', usageSettings())).toBe(false) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxApiKeyConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 19258592f1f..c14f0a23a39 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -17,6 +17,9 @@ export type UsageProviderSettings = Pick< // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean minimaxApiKeyConfigured: boolean + // Why: the OpenCode Go key can live in OPENCODE_API_KEY or in OpenCode's own + // store, neither of which the renderer can see; main reports presence. + opencodeGoApiKeyConfigured: boolean grokAuthConfigured: boolean } @@ -75,6 +78,7 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || + settings?.opencodeGoApiKeyConfigured === true || // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is // already covered by the gemini term above. settings?.minimaxCookieConfigured === true || @@ -100,7 +104,10 @@ export function hasUsageProviderSettingsForProvider( return settings.geminiCliOAuthEnabled === true } if (providerId === 'opencode-go') { - return Boolean(settings.opencodeSessionCookie?.trim()) + return ( + Boolean(settings.opencodeSessionCookie?.trim()) || + settings.opencodeGoApiKeyConfigured === true + ) } if (providerId === 'antigravity') { // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts index c91d0704ace..b68a2fb05f6 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.test.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -4,7 +4,11 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -import { getProviderDisplayName } from './usage-error-copy' +import { + getProviderDisplayName, + getProviderUsageErrorMessage, + getProviderUsageStatusLabel +} from './usage-error-copy' describe('getProviderDisplayName', () => { it('returns the Antigravity brand name', () => { @@ -30,3 +34,25 @@ describe('getProviderDisplayName', () => { expect(getProviderDisplayName('unknown-provider' as never)).toBe('unknown-provider') }) }) + +describe('unsubscribed OpenCode Go accounts', () => { + const noSubscription = { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: 0, + error: + 'This OpenCode account has no OpenCode Go subscription. Subscribe at opencode.ai to see Go usage.', + status: 'error', + usageMetadata: { failureKind: 'no-subscription' } + } as const + + it('labels the entitlement verdict instead of a refresh failure', () => { + expect(getProviderUsageStatusLabel(noSubscription)).toBe('No subscription') + }) + + it('keeps the specific message rather than the generic auth copy', () => { + expect(getProviderUsageErrorMessage(noSubscription)).toBe(noSubscription.error) + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index dff1d178034..8dd8ec7f2aa 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -103,6 +103,7 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { return translate('auto.components.status.bar.tooltip.f8b8dbed85', 'Usage unavailable') case 'missing-credentials': case 'missing-scope': + case 'no-subscription': case 'parse': case 'rate-limited': case 'server': @@ -116,6 +117,14 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { if (p.provider === 'minimax' && p.usageMetadata?.failureKind === 'stale-token') { return translate('auto.components.status.bar.tooltip.minimax.expired.label', 'Sign-in expired') } + // Why: an unsubscribed account is a settled answer about the account, not a + // failed refresh; "Refresh failed" sends the user hunting a bug that is not there. + if (p.usageMetadata?.failureKind === 'no-subscription') { + return translate( + 'auto.components.status.bar.tooltip.usage.noSubscription.label', + 'No subscription' + ) + } if (isUsageRateLimitError(p.error)) { return translate('auto.components.status.bar.tooltip.7ad719c4bf', 'Limited') } @@ -178,6 +187,7 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { 'Claude usage is unavailable right now.' ) case 'missing-credentials': + case 'no-subscription': case 'rate-limited': case 'unknown': case undefined: @@ -198,6 +208,10 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { 'MiniMax session cookie expired. Replace it in Settings.' ) } + // The entitlement verdict names the account state; generic auth copy would bury it. + if (p.usageMetadata?.failureKind === 'no-subscription') { + return p.error + } if (isUsageAuthError(p.error)) { const name = getProviderDisplayName(p.provider) return translate( diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 33db1976b0d..d2f9a21ea18 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -113,6 +113,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { antigravityUsageConfigured, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured, + opencodeGoApiKeyConfigured: rateLimits.opencodeGoApiKeyConfigured, grokAuthConfigured: rateLimits.grokAuthConfigured } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f04fae0b9da..7622ed662be 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3980,6 +3980,11 @@ "apiKey": "MiniMax API key expired. Replace it in Settings.", "cookie": "MiniMax session cookie expired. Replace it in Settings." } + }, + "usage": { + "noSubscription": { + "label": "No subscription" + } } }, "SshTargetStatusRow": { @@ -6637,7 +6642,16 @@ "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.", "5e08b0fe57": "Stored locally and sent only to platform.minimax.io for usage refreshes.", "79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).", - "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in." + "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", + "opencodeGo": { + "apiKey": { + "title": "OpenCode Go API Key", + "description": "Optional override. Orca otherwise uses the key OpenCode saved when you ran /connect, then OPENCODE_API_KEY.", + "label": "OpenCode Go API key", + "placeholder": "Leave blank to use the key saved by /connect or OPENCODE_API_KEY", + "help": "Used for OpenCode Go usage in the status bar. The session cookie below is only needed for legacy console (OpenCode Black) accounts." + } + } }, "AdvancedPane": { "40b29e0bf3": "Restart", diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 7c6750ddb40..2fd16df1c4f 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -210,6 +210,7 @@ export function buildDefaultSettings(args: { defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + opencodeGoApiKey: '', minimaxGroupId: '', minimaxUsageModels: 'general', minimaxEndpoint: 'overseas', diff --git a/src/shared/ghostty-import-preview.ts b/src/shared/ghostty-import-preview.ts new file mode 100644 index 00000000000..ee24e56b393 --- /dev/null +++ b/src/shared/ghostty-import-preview.ts @@ -0,0 +1,11 @@ +import type { GlobalSettings } from './global-settings-types' + +/** What a Ghostty config import would change, shown before the user accepts it. */ +export type GhosttyImportPreview = { + found: boolean + configPath?: string + configPaths?: string[] + diff: Partial + unsupportedKeys: string[] + error?: string +} diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 9651e58ef9d..1e0410f4c22 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -389,6 +389,8 @@ export type GlobalSettings = { opencodeSessionCookie: string /** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */ opencodeWorkspaceId: string + /** Optional OpenCode Go API key override. Takes precedence over OpenCode's own stored key and OPENCODE_API_KEY. Stored encrypted. */ + opencodeGoApiKey: string /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ minimaxGroupId: string /** Comma-separated MiniMax model names to show in the status bar usage window. */ @@ -525,11 +527,6 @@ export type OrcaWorkspaceLayout = { nestWorkspaces: boolean } -export type GhosttyImportPreview = { - found: boolean - configPath?: string - configPaths?: string[] - diff: Partial - unsupportedKeys: string[] - error?: string -} +// Re-exported so existing importers keep one entry point; the shape lives in its +// own file because this one is at the max-lines ceiling. +export type { GhosttyImportPreview } from './ghostty-import-preview' diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index bf8d97e541e..96ae50f9b28 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -13,6 +13,7 @@ export function createEmptyRateLimitState(overrides: Partial = { grok: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, + opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index f11dc638d41..d3276b1afe4 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -18,6 +18,7 @@ describe('RateLimitState', () => { grok: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, + opencodeGoApiKeyConfigured: false, grokAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5744e3e6749..dd89c9e8ec6 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -25,6 +25,8 @@ export type UsageRateLimitFailureKind = | 'deferred-by-live-session' | 'keychain-unavailable' | 'missing-scope' + /** The account is authenticated but not entitled to the product being polled. */ + | 'no-subscription' | 'network' | 'server' | 'parse' @@ -138,6 +140,13 @@ export type RateLimitState = { * visible across reloads. */ minimaxApiKeyConfigured: boolean + /** + * True when main resolved an OpenCode Go API key (Orca settings, + * OPENCODE_API_KEY, or what OpenCode stored on /connect). The key itself + * never leaves main; the status bar ORs this with the session cookie to + * decide whether the OpenCode Go bar stays visible. + */ + opencodeGoApiKeyConfigured: boolean /** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */ grokAuthConfigured: boolean claudeTarget: RateLimitRuntimeTarget