fix(rate-limits): read OpenCode Go usage with the Go API key (#22551)

* 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 <key>` 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* chore(i18n): add the OpenCode Go API key strings to the English catalog

Co-Authored-By: Claude <noreply@anthropic.com>

* 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 <jinwoo0825@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-09-23 20:09:02 -07:00
committed by GitHub
co-authored by Jinwoo-H Claude
parent 57bf732a42
commit 5802b54579
43 changed files with 1246 additions and 80 deletions
@@ -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)
}
@@ -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)
}
@@ -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,
@@ -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 ?? ''
+1
View File
@@ -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
@@ -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<Record<(typeof ENVIRONMENT_KEYS)[number], string>>
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()
})
})
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Reads `{ type: <kind>, 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<string | null> {
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<OpenCodeGoApiKeyResolution> {
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' }
}
@@ -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()
})
})
@@ -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: <ISO> }`
// (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
@@ -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('<!doctype html><title>Log in</title>', {
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)
})
})
@@ -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<string, unknown> {
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<OpenCodeGoUsageApiOutcome> {
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 }
}
@@ -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 () => {
@@ -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'
}
}
@@ -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')
})
})
@@ -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<OpenCodeGoUsageApiOutcome, { kind: 'ok' }>,
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<ProviderRateLimits> {
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)
}
@@ -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<T> = {
@@ -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({
@@ -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', () => ({
@@ -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', () => ({
@@ -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', () => ({
@@ -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', () => ({
@@ -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', () => ({
@@ -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,
@@ -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()
@@ -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,
@@ -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))
@@ -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<typeof setInterval> | null = null
protected deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | null = null
@@ -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 = {
@@ -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(() => {
@@ -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<Set<'cookie' | 'workspaceId'>>(new Set())
const recordedOpenCodeSettingEditsRef = useRef<Set<'cookie' | 'workspaceId' | 'apiKey'>>(
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
}
@@ -95,6 +95,59 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
</p>
</div>
<SearchableSetting
title={translate(
'auto.components.settings.AccountsPane.opencodeGo.apiKey.title',
'OpenCode Go API Key'
)}
description={translate(
'auto.components.settings.AccountsPane.opencodeGo.apiKey.description',
'Optional override. Orca otherwise uses the key OpenCode saved when you ran /connect, then OPENCODE_API_KEY.'
)}
keywords={['opencode', 'go', 'api', 'key', 'connect', 'rate limit', 'status bar']}
className="space-y-2"
>
<Label>
{translate(
'auto.components.settings.AccountsPane.opencodeGo.apiKey.label',
'OpenCode Go API key'
)}
</Label>
<div className="flex gap-2">
<DebouncedSettingsTextInput
type="password"
value={settings.opencodeGoApiKey}
onEdit={() => 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 && (
<Button
variant="ghost"
size="xs"
onClick={() => {
recordFeatureInteraction('usage-tracking')
updateSettings({ opencodeGoApiKey: '' })
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AccountsPane.b398b834c9', 'Clear')}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AccountsPane.36223200ac',
@@ -103,7 +103,7 @@ export type AccountsPaneSectionModel = {
codexRateLimitTarget: RateLimitRuntimeTarget
setRemoveCodexTarget: Dispatch<SetStateAction<RemoveAccountTarget | null>>
runCodexAccountAction: CodexAccountActionRunner
recordOpenCodeSettingEdit: (field: 'cookie' | 'workspaceId') => void
recordOpenCodeSettingEdit: (field: 'cookie' | 'workspaceId' | 'apiKey') => void
miniMaxRateLimits: ProviderRateLimits | null
miniMaxApiKeyDraft: string
setMiniMaxApiKeyDraft: Dispatch<SetStateAction<string>>
@@ -75,6 +75,7 @@ function usageSettings(overrides: Partial<UsageProviderSettings> = {}): 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)
@@ -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
@@ -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)
})
})
@@ -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(
@@ -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)
+15 -1
View File
@@ -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",
+1
View File
@@ -210,6 +210,7 @@ export function buildDefaultSettings(args: {
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
opencodeGoApiKey: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
minimaxEndpoint: 'overseas',
+11
View File
@@ -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<GlobalSettings>
unsupportedKeys: string[]
error?: string
}
+5 -8
View File
@@ -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<GlobalSettings>
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'
+1
View File
@@ -13,6 +13,7 @@ export function createEmptyRateLimitState(overrides: Partial<RateLimitState> = {
grok: null,
minimaxCookieConfigured: false,
minimaxApiKeyConfigured: false,
opencodeGoApiKeyConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
+1
View File
@@ -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 },
+9
View File
@@ -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