mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user