Files
orca/src/main/rate-limits/opencode-go-usage-source-selection.test.ts
T
5802b54579 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>
2026-09-23 20:09:02 -07:00

158 lines
6.0 KiB
TypeScript

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')
})
})