mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
* 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>
158 lines
6.0 KiB
TypeScript
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')
|
|
})
|
|
})
|