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
@@ -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",