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