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
@@ -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()
})
})