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 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:
@@ -60,6 +60,9 @@ export function updateSettings(
|
||||
if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) {
|
||||
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie)
|
||||
}
|
||||
if ('opencodeGoApiKey' in updates && !updates.opencodeGoApiKey) {
|
||||
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeGoApiKey)
|
||||
}
|
||||
if ('httpProxyUrl' in updates && !updates.httpProxyUrl) {
|
||||
operations.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ export function isLegacyOpenCodeSessionCookie(value: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// OpenCode Go keys are opaque bearer tokens; the console issues `sk-`
|
||||
// (legacy) and `oc_sk_` (new console) prefixes.
|
||||
export function isLegacyOpenCodeGoApiKey(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
return /^(?:oc_)?sk[-_][A-Za-z0-9._-]+$/.test(trimmed)
|
||||
}
|
||||
|
||||
export function isLegacySshPtyOwnerLease(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
sshPtyOwnerLeaseSecretSlot
|
||||
} from '../../protected-secret-persistence'
|
||||
import {
|
||||
isLegacyOpenCodeGoApiKey,
|
||||
isLegacyOpenCodeSessionCookie,
|
||||
isLegacySshPtyOwnerLease
|
||||
} from '../leasing-ssh-ptys/secret-validation'
|
||||
@@ -107,6 +108,13 @@ export class LoadedStateParsingOperations {
|
||||
isLegacyOpenCodeSessionCookie
|
||||
)
|
||||
}
|
||||
if (parsed.settings?.opencodeGoApiKey) {
|
||||
parsed.settings.opencodeGoApiKey = this.runtime.protectedSecrets.decrypt(
|
||||
PROTECTED_SECRET_SLOT.opencodeGoApiKey,
|
||||
parsed.settings.opencodeGoApiKey,
|
||||
isLegacyOpenCodeGoApiKey
|
||||
)
|
||||
}
|
||||
if (parsed.settings?.httpProxyUrl) {
|
||||
const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus(
|
||||
PROTECTED_SECRET_SLOT.httpProxyUrl,
|
||||
|
||||
@@ -128,6 +128,10 @@ export class StateSerializationSecretHandlingOperations {
|
||||
PROTECTED_SECRET_SLOT.opencodeSessionCookie,
|
||||
this.runtime.state.settings.opencodeSessionCookie
|
||||
),
|
||||
opencodeGoApiKey: encryptToSentinel(
|
||||
PROTECTED_SECRET_SLOT.opencodeGoApiKey,
|
||||
this.runtime.state.settings.opencodeGoApiKey ?? ''
|
||||
),
|
||||
httpProxyUrl: encryptToSentinel(
|
||||
PROTECTED_SECRET_SLOT.httpProxyUrl,
|
||||
this.runtime.state.settings.httpProxyUrl ?? ''
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSecretStore } from '../shared/secret-store'
|
||||
|
||||
export const PROTECTED_SECRET_SLOT = {
|
||||
opencodeSessionCookie: 'settings.opencodeSessionCookie',
|
||||
opencodeGoApiKey: 'settings.opencodeGoApiKey',
|
||||
httpProxyUrl: 'settings.httpProxyUrl',
|
||||
browserKagiSessionLink: 'ui.browserKagiSessionLink'
|
||||
} as const
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
compareOpenCodeClaimPriority,
|
||||
listOpenCodeDatabases
|
||||
} from '../opencode-usage/opencode-database-discovery'
|
||||
import { tableExists } from '../opencode-usage/schema-helpers'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
|
||||
import Database from '../sqlite/sync-database'
|
||||
|
||||
/** OpenCode's provider/integration id for the Go subscription. */
|
||||
const OPENCODE_GO_INTEGRATION_ID = 'opencode-go'
|
||||
/** models.dev declares this env var for both `opencode` and `opencode-go`. */
|
||||
const OPENCODE_API_KEY_ENV = 'OPENCODE_API_KEY'
|
||||
const AUTH_FILE_NAME = 'auth.json'
|
||||
const MAX_AUTH_FILE_BYTES = 1_000_000
|
||||
|
||||
/** Where the key came from. Safe to log — never carries the key itself. */
|
||||
export type OpenCodeGoApiKeyTier =
|
||||
| 'settings'
|
||||
| 'environment'
|
||||
| 'opencode-auth-file'
|
||||
| 'opencode-credential-database'
|
||||
|
||||
export type OpenCodeGoApiKeyResolution =
|
||||
| { status: 'found'; key: string; tier: OpenCodeGoApiKeyTier }
|
||||
| { status: 'missing' }
|
||||
|
||||
export function getOpenCodeAuthFilePath(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
homeDirectory?: string
|
||||
): string {
|
||||
return join(resolveOpenCodeDataDirectory(environment, homeDirectory), AUTH_FILE_NAME)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Reads `{ type: <kind>, key: "…" }` from an already-narrowed record. */
|
||||
function keyFromCredentialRecord(value: unknown, kind: string): string | null {
|
||||
if (!isRecord(value) || value.type !== kind) {
|
||||
return null
|
||||
}
|
||||
return trimmedKey(value.key)
|
||||
}
|
||||
|
||||
function trimmedKey(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `opencode-go` API key OpenCode 1.x writes on `/connect`.
|
||||
*
|
||||
* Shape (opencode `packages/opencode/src/auth/index.ts`, `Api` schema):
|
||||
* `{ "opencode-go": { "type": "api", "key": "…" } }`.
|
||||
* @returns The key, or null when the file, the entry, or the key is absent.
|
||||
*/
|
||||
export function readOpenCodeAuthFileGoKey(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
homeDirectory?: string
|
||||
): string | null {
|
||||
const path = getOpenCodeAuthFilePath(environment, homeDirectory)
|
||||
if (!existsSync(path)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
if (raw.length > MAX_AUTH_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isRecord(parsed)) {
|
||||
return null
|
||||
}
|
||||
return keyFromCredentialRecord(parsed[OPENCODE_GO_INTEGRATION_ID], 'api')
|
||||
} catch {
|
||||
// Why: a malformed or unreadable auth file is "no key here", not a fetch
|
||||
// failure — later tiers and the cookie path still deserve their turn.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function selectCredentialKey(database: Database.Database): string | null {
|
||||
if (!tableExists(database, 'credential')) {
|
||||
return null
|
||||
}
|
||||
// OpenCode marks the chosen credential per integration with `active = 1`;
|
||||
// newest wins among the rest (packages/core/src/credential.ts).
|
||||
const rows: unknown[] = database
|
||||
.prepare(
|
||||
'SELECT value FROM credential WHERE integration_id = ? ' +
|
||||
'ORDER BY active DESC, time_created DESC LIMIT 8'
|
||||
)
|
||||
.all(OPENCODE_GO_INTEGRATION_ID)
|
||||
for (const row of rows) {
|
||||
if (!isRecord(row) || typeof row.value !== 'string') {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const key = keyFromCredentialRecord(JSON.parse(row.value), 'key')
|
||||
if (key) {
|
||||
return key
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `opencode-go` key from OpenCode's `credential` table.
|
||||
*
|
||||
* OpenCode 2 imports `auth.json` into SQLite once (migration
|
||||
* `20260805200742_import_legacy_credentials`) and every later `/connect` writes
|
||||
* only there, so a fresh OpenCode 2 install has no `auth.json` entry at all.
|
||||
* The table itself is not a version marker — 1.18.x creates it too (verified
|
||||
* empty on a real 1.18.16 install), so probe it regardless of version.
|
||||
* @returns The key, or null when no database, table, or row carries one.
|
||||
*/
|
||||
export async function readOpenCodeCredentialDatabaseGoKey(): Promise<string | null> {
|
||||
let paths: string[]
|
||||
try {
|
||||
paths = [...(await listOpenCodeDatabases())].sort(compareOpenCodeClaimPriority)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
for (const path of paths) {
|
||||
// A synchronous open against a 9p/UNC share can hang the main process, and
|
||||
// the status bar is never worth that; the other tiers still apply.
|
||||
if (isWslUncPath(path)) {
|
||||
continue
|
||||
}
|
||||
let database: Database.Database | null = null
|
||||
try {
|
||||
database = new Database(path, { readonly: true, fileMustExist: true })
|
||||
database.pragma('query_only = ON')
|
||||
const key = selectCredentialKey(database)
|
||||
if (key) {
|
||||
return key
|
||||
}
|
||||
} catch {
|
||||
// A locked, WAL-index-less, or foreign-schema database is not an error
|
||||
// here; it just holds no key we can read.
|
||||
continue
|
||||
} finally {
|
||||
database?.close()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenCode Go API key in the documented precedence order.
|
||||
*
|
||||
* Settings override, then whatever OpenCode itself stored on `/connect` —
|
||||
* `auth.json`, then the `credential` table — then `OPENCODE_API_KEY`. Both
|
||||
* stores are probed on every version: 1.18.x creates the `credential` table too,
|
||||
* so its presence is not a 2.x marker, and a 2.x install that never ran the
|
||||
* legacy import has no `auth.json` at all.
|
||||
* The stored key outranks the env var because OpenCode applies it after env,
|
||||
* and the env var is shared with the Zen provider.
|
||||
* @param input.settingsOverride - The key a user pasted into Orca's settings.
|
||||
* @param input.environment - Process environment to read; injectable for tests.
|
||||
* @returns The first key found and the tier it came from, or `missing`.
|
||||
*/
|
||||
export async function resolveOpenCodeGoApiKey(input: {
|
||||
settingsOverride?: string
|
||||
environment?: NodeJS.ProcessEnv
|
||||
}): Promise<OpenCodeGoApiKeyResolution> {
|
||||
const environment = input.environment ?? process.env
|
||||
const override = trimmedKey(input.settingsOverride)
|
||||
if (override) {
|
||||
return { status: 'found', key: override, tier: 'settings' }
|
||||
}
|
||||
const fromAuthFile = readOpenCodeAuthFileGoKey(environment)
|
||||
if (fromAuthFile) {
|
||||
return { status: 'found', key: fromAuthFile, tier: 'opencode-auth-file' }
|
||||
}
|
||||
const fromDatabase = await readOpenCodeCredentialDatabaseGoKey()
|
||||
if (fromDatabase) {
|
||||
return { status: 'found', key: fromDatabase, tier: 'opencode-credential-database' }
|
||||
}
|
||||
const fromEnvironment = trimmedKey(environment[OPENCODE_API_KEY_ENV])
|
||||
if (fromEnvironment) {
|
||||
return { status: 'found', key: fromEnvironment, tier: 'environment' }
|
||||
}
|
||||
return { status: 'missing' }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing'
|
||||
import {
|
||||
parseOpenCodeGoStatusPayload,
|
||||
parseOpenCodeGoUsageApiPayload
|
||||
} from './opencode-go-status-parsing'
|
||||
|
||||
const ISSUE_PAYLOAD = {
|
||||
access: {
|
||||
@@ -116,3 +119,35 @@ describe('parseOpenCodeGoStatusPayload', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpenCodeGoUsageApiPayload', () => {
|
||||
it('clamps an out-of-range percent and tolerates a missing resetsAt', () => {
|
||||
const parsed = parseOpenCodeGoUsageApiPayload(
|
||||
JSON.stringify({
|
||||
usage: {
|
||||
rolling: { status: 'rate-limited', percent: 140 },
|
||||
weekly: { status: 'ok', percent: -5, resetsAt: 'not a date' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(parsed?.session).toEqual({
|
||||
usedPercent: 100,
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
})
|
||||
expect(parsed?.weekly.usedPercent).toBe(0)
|
||||
expect(parsed?.monthly).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for the console error bodies and other non-usage payloads', () => {
|
||||
expect(
|
||||
parseOpenCodeGoUsageApiPayload(
|
||||
JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Unauthorized' } })
|
||||
)
|
||||
).toBeNull()
|
||||
expect(parseOpenCodeGoUsageApiPayload('{not json')).toBeNull()
|
||||
expect(parseOpenCodeGoUsageApiPayload('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,51 @@ function meterToWindow(meter: unknown, windowMinutes: number): RateLimitWindow |
|
||||
}
|
||||
}
|
||||
|
||||
// `GET /zen/go/v1/usage` reports each window as
|
||||
// `{ status: "ok" | "rate-limited", percent: 0-100, resetsAt: <ISO> }`
|
||||
// (console `routes/zen/go/v1/usage.ts` + `Subscription.analyze*Usage`).
|
||||
function percentMeterToWindow(meter: unknown, windowMinutes: number): RateLimitWindow | null {
|
||||
if (!isRecord(meter) || typeof meter.percent !== 'number' || !Number.isFinite(meter.percent)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
usedPercent: Math.min(100, Math.max(0, meter.percent)),
|
||||
windowMinutes,
|
||||
resetsAt: parseResetsAt(meter.resetsAt),
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the OpenCode Go usage API body into Orca's usage windows.
|
||||
* @param text - Raw response body from `GET /zen/go/v1/usage`.
|
||||
* @returns The mapped windows, or null when the body is not a usage payload.
|
||||
*/
|
||||
export function parseOpenCodeGoUsageApiPayload(text: string): OpenCodeGoUsageWindows | null {
|
||||
if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) {
|
||||
return null
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!isRecord(payload) || !isRecord(payload.usage)) {
|
||||
return null
|
||||
}
|
||||
const session = percentMeterToWindow(payload.usage.rolling, SESSION_WINDOW_MINUTES)
|
||||
const weekly = percentMeterToWindow(payload.usage.weekly, WEEKLY_WINDOW_MINUTES)
|
||||
if (!session || !weekly) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
session,
|
||||
weekly,
|
||||
monthly: percentMeterToWindow(payload.usage.monthly, MONTHLY_WINDOW_MINUTES)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null {
|
||||
if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const netFetchMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { defaultSession: {} }
|
||||
}))
|
||||
vi.mock('../network/proxy-settings', () => ({
|
||||
ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
import { fetchOpenCodeGoUsageWithApiKey, OPENCODE_GO_USAGE_API_URL } from './opencode-go-usage-api'
|
||||
|
||||
// Placeholder only — a real key must never reach a fixture.
|
||||
const API_KEY = 'placeholder-go-key'
|
||||
|
||||
function makeResponse(body: string, status = 200): Response {
|
||||
return new Response(body, { status })
|
||||
}
|
||||
|
||||
const USAGE_BODY = {
|
||||
usage: {
|
||||
rolling: { status: 'ok', percent: 30, resetsAt: '2026-09-23T17:00:00.000Z' },
|
||||
weekly: { status: 'ok', percent: 51, resetsAt: '2026-09-28T00:00:00.000Z' },
|
||||
monthly: { status: 'rate-limited', percent: 100, resetsAt: '2026-10-12T00:00:00.000Z' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('fetchOpenCodeGoUsageWithApiKey', () => {
|
||||
beforeEach(() => {
|
||||
netFetchMock.mockReset()
|
||||
})
|
||||
|
||||
it('sends the key as a bearer token and maps every window', async () => {
|
||||
netFetchMock.mockResolvedValue(makeResponse(JSON.stringify(USAGE_BODY)))
|
||||
|
||||
const outcome = await fetchOpenCodeGoUsageWithApiKey(API_KEY)
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
OPENCODE_GO_USAGE_API_URL,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({ Authorization: `Bearer ${API_KEY}` })
|
||||
})
|
||||
)
|
||||
expect(outcome).toEqual({
|
||||
kind: 'ok',
|
||||
windows: {
|
||||
session: {
|
||||
usedPercent: 30,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date.parse('2026-09-23T17:00:00.000Z'),
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 51,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: Date.parse('2026-09-28T00:00:00.000Z'),
|
||||
resetDescription: null
|
||||
},
|
||||
monthly: {
|
||||
usedPercent: 100,
|
||||
windowMinutes: 43200,
|
||||
resetsAt: Date.parse('2026-10-12T00:00:00.000Z'),
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps usage when the monthly window is absent', async () => {
|
||||
netFetchMock.mockResolvedValue(
|
||||
makeResponse(
|
||||
JSON.stringify({
|
||||
usage: { rolling: USAGE_BODY.usage.rolling, weekly: USAGE_BODY.usage.weekly }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const outcome = await fetchOpenCodeGoUsageWithApiKey(API_KEY)
|
||||
|
||||
expect(outcome.kind).toBe('ok')
|
||||
expect(outcome.kind === 'ok' && outcome.windows.monthly).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a rejected key as unauthorized', async () => {
|
||||
netFetchMock.mockResolvedValue(
|
||||
makeResponse(
|
||||
JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Unauthorized' } }),
|
||||
401
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({ kind: 'unauthorized' })
|
||||
})
|
||||
|
||||
it('reports an entitlement refusal as a missing subscription, not a parse failure', async () => {
|
||||
netFetchMock.mockResolvedValue(
|
||||
makeResponse(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
error: { type: 'EntitlementError', message: 'OpenCode Go subscription required.' }
|
||||
}),
|
||||
403
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({
|
||||
kind: 'no-subscription'
|
||||
})
|
||||
})
|
||||
|
||||
it('trusts the error name over the status when a proxied console answers differently', async () => {
|
||||
netFetchMock.mockResolvedValue(
|
||||
makeResponse(JSON.stringify({ error: { type: 'EntitlementError' } }), 402)
|
||||
)
|
||||
|
||||
await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({
|
||||
kind: 'no-subscription'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a sign-in page reached through a redirect as unauthorized', async () => {
|
||||
netFetchMock.mockResolvedValue(
|
||||
new Response('<!doctype html><title>Log in</title>', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html; charset=utf-8' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({ kind: 'unauthorized' })
|
||||
})
|
||||
|
||||
it('reports a malformed success body as a parse failure', async () => {
|
||||
netFetchMock.mockResolvedValue(makeResponse('{"usage":{"rolling":{}}}'))
|
||||
|
||||
await expect(fetchOpenCodeGoUsageWithApiKey(API_KEY)).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
message: 'Could not parse OpenCode Go usage response'
|
||||
})
|
||||
})
|
||||
|
||||
it('never leaks the key through a network failure message', async () => {
|
||||
netFetchMock.mockRejectedValue(new Error(`request to ${OPENCODE_GO_USAGE_API_URL} failed`))
|
||||
|
||||
const outcome = await fetchOpenCodeGoUsageWithApiKey(API_KEY)
|
||||
|
||||
expect(outcome.kind).toBe('failed')
|
||||
expect(outcome.kind === 'failed' && outcome.message).not.toContain(API_KEY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { net, session } from 'electron'
|
||||
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
|
||||
import {
|
||||
parseOpenCodeGoUsageApiPayload,
|
||||
type OpenCodeGoUsageWindows
|
||||
} from './opencode-go-status-parsing'
|
||||
|
||||
export const OPENCODE_GO_USAGE_API_URL = 'https://opencode.ai/zen/go/v1/usage'
|
||||
const API_TIMEOUT_MS = 15_000
|
||||
const MAX_ERROR_BODY_CHARS = 4_000
|
||||
|
||||
/**
|
||||
* Outcome of one Bearer-authenticated usage request.
|
||||
*
|
||||
* `unauthorized` and `no-subscription` are the server's two definitive verdicts
|
||||
* about the key; `failed` covers everything Orca could not get an answer from.
|
||||
*/
|
||||
export type OpenCodeGoUsageApiOutcome =
|
||||
| { kind: 'ok'; windows: OpenCodeGoUsageWindows }
|
||||
| { kind: 'unauthorized' }
|
||||
| { kind: 'no-subscription' }
|
||||
| { kind: 'failed'; message: string }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Error type names the console returns; see `routes/zen/go/v1/usage.ts`. */
|
||||
function readErrorType(body: string): string | null {
|
||||
if (!body || body.length > MAX_ERROR_BODY_CHARS) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body)
|
||||
if (!isRecord(parsed) || !isRecord(parsed.error)) {
|
||||
return null
|
||||
}
|
||||
const type = parsed.error.type
|
||||
return typeof type === 'string' ? type : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function classifyFailure(status: number, body: string): OpenCodeGoUsageApiOutcome {
|
||||
const errorType = readErrorType(body)
|
||||
// Why type-first: a migrated account's request is proxied to the new console,
|
||||
// which owns its own status codes; the error name is the stable signal.
|
||||
if (errorType === 'EntitlementError' || status === 403) {
|
||||
return { kind: 'no-subscription' }
|
||||
}
|
||||
if (errorType === 'AuthError' || status === 401) {
|
||||
return { kind: 'unauthorized' }
|
||||
}
|
||||
return { kind: 'failed', message: `OpenCode Go usage request failed (${status})` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch OpenCode Go usage with the account's API key.
|
||||
*
|
||||
* Runs on the default Electron session so the app-wide proxy guard covers it.
|
||||
* The key is sent only as an `Authorization` header and never reaches a log,
|
||||
* a returned message, or telemetry.
|
||||
* @param apiKey - The resolved OpenCode Go API key.
|
||||
* @param signal - Optional cancellation signal for the enclosing fetch cycle.
|
||||
* @returns The parsed usage windows or a classified failure.
|
||||
*/
|
||||
export async function fetchOpenCodeGoUsageWithApiKey(
|
||||
apiKey: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<OpenCodeGoUsageApiOutcome> {
|
||||
await ensureElectronProxyFromEnvironment({
|
||||
proxySession: session.defaultSession,
|
||||
probeUrl: OPENCODE_GO_USAGE_API_URL
|
||||
}).catch(() => {})
|
||||
|
||||
const requestSignal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(API_TIMEOUT_MS)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await net.fetch(OPENCODE_GO_USAGE_API_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: requestSignal
|
||||
})
|
||||
} catch (error) {
|
||||
// Why fixed prefix: the thrown message can embed the request, and the
|
||||
// request carries the key.
|
||||
return {
|
||||
kind: 'failed',
|
||||
message: `OpenCode Go usage request failed (${error instanceof Error ? error.name : 'network error'})`
|
||||
}
|
||||
}
|
||||
|
||||
const body = await response.text().catch(() => '')
|
||||
if (!response.ok) {
|
||||
return classifyFailure(response.status, body)
|
||||
}
|
||||
const windows = parseOpenCodeGoUsageApiPayload(body)
|
||||
if (!windows) {
|
||||
// Why: Electron follows redirects, so a key bounced to console sign-in arrives as a 200 page.
|
||||
if (response.headers.get('content-type')?.includes('text/html')) {
|
||||
return { kind: 'unauthorized' }
|
||||
}
|
||||
return { kind: 'failed', message: 'Could not parse OpenCode Go usage response' }
|
||||
}
|
||||
return { kind: 'ok', windows }
|
||||
}
|
||||
@@ -484,7 +484,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/No workspace ID found/)
|
||||
expect(result.error).toMatch(/No workspace found.*OpenCode Go API key/)
|
||||
})
|
||||
|
||||
it('returns error on non-ok usage response', async () => {
|
||||
|
||||
@@ -212,7 +212,8 @@ async function fetchOpenCodeGoRateLimitsWithSession(
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'No workspace ID found — set a Workspace ID override in settings',
|
||||
error:
|
||||
'No workspace found for this cookie — add an OpenCode Go API key (or run /connect in OpenCode), or set a Workspace ID override',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { NetworkProxySettings } from '../../shared/network-proxy'
|
||||
import type { ProviderRateLimits, UsageRateLimitMetadata } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
resolveOpenCodeGoApiKey,
|
||||
type OpenCodeGoApiKeyResolution
|
||||
} from './opencode-go-api-key-source'
|
||||
import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing'
|
||||
import {
|
||||
fetchOpenCodeGoUsageWithApiKey,
|
||||
type OpenCodeGoUsageApiOutcome
|
||||
} from './opencode-go-usage-api'
|
||||
import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher'
|
||||
|
||||
export type OpenCodeGoUsageSourceInput = {
|
||||
/** Explicit Orca override; the highest-precedence key tier. */
|
||||
settingsApiKey?: string
|
||||
cookie: string
|
||||
workspaceIdOverride?: string
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
/** Lets the caller record key presence without handling the key itself. */
|
||||
onApiKeyResolved?: (resolution: OpenCodeGoApiKeyResolution) => void
|
||||
}
|
||||
|
||||
function emptyResult(
|
||||
error: string,
|
||||
status: 'error' | 'unavailable',
|
||||
usageMetadata?: UsageRateLimitMetadata
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'opencode-go',
|
||||
session: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status,
|
||||
...(usageMetadata ? { usageMetadata } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function usageResult(
|
||||
windows: OpenCodeGoUsageWindows,
|
||||
credentialSource: string
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'opencode-go',
|
||||
session: windows.session,
|
||||
weekly: windows.weekly,
|
||||
monthly: windows.monthly,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok',
|
||||
usageMetadata: { source: 'web', credentialSource }
|
||||
}
|
||||
}
|
||||
|
||||
// Why explicit copy per verdict: #22257's reporter was misled by a generic
|
||||
// "could not parse" for what was really an entitlement answer, so each verdict
|
||||
// says what the account's state is and what to do about it.
|
||||
function apiFailureResult(
|
||||
outcome: Exclude<OpenCodeGoUsageApiOutcome, { kind: 'ok' }>,
|
||||
credentialSource: string
|
||||
): ProviderRateLimits {
|
||||
if (outcome.kind === 'no-subscription') {
|
||||
return emptyResult(
|
||||
'This OpenCode account has no OpenCode Go subscription. Subscribe at opencode.ai to see Go usage.',
|
||||
'error',
|
||||
{ source: 'web', credentialSource, failureKind: 'no-subscription' }
|
||||
)
|
||||
}
|
||||
if (outcome.kind === 'unauthorized') {
|
||||
return emptyResult(
|
||||
'The OpenCode Go API key was rejected. Run /connect in OpenCode, or replace the key in Settings.',
|
||||
'error',
|
||||
{ source: 'web', credentialSource, failureKind: 'stale-token' }
|
||||
)
|
||||
}
|
||||
return emptyResult(outcome.message, 'error', {
|
||||
source: 'web',
|
||||
credentialSource,
|
||||
failureKind: 'server'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch OpenCode Go usage from the best source the account offers.
|
||||
*
|
||||
* The keyed `GET /zen/go/v1/usage` endpoint is tried first because it needs no
|
||||
* console session; the legacy cookie path stays as the fallback for Black and
|
||||
* other legacy-console accounts, which upstream `fe51b0b19a` still serves.
|
||||
* @param input.settingsApiKey - Explicit Orca override, if the user set one.
|
||||
* @param input.cookie - The opencode.ai cookie header from Orca settings.
|
||||
* @returns A provider snapshot for the status bar.
|
||||
*/
|
||||
export async function fetchOpenCodeGoUsage(
|
||||
input: OpenCodeGoUsageSourceInput
|
||||
): Promise<ProviderRateLimits> {
|
||||
const apiKeyResolution = await resolveOpenCodeGoApiKey({
|
||||
settingsOverride: input.settingsApiKey
|
||||
})
|
||||
input.onApiKeyResolved?.(apiKeyResolution)
|
||||
const hasCookie = Boolean(normalizeCookieInput(input.cookie))
|
||||
if (apiKeyResolution.status === 'missing') {
|
||||
return hasCookie
|
||||
? fetchOpenCodeGoRateLimits(
|
||||
input.cookie,
|
||||
input.workspaceIdOverride,
|
||||
input.networkProxySettings
|
||||
)
|
||||
: emptyResult('No OpenCode Go API key or session cookie configured', 'unavailable', {
|
||||
failureKind: 'missing-credentials'
|
||||
})
|
||||
}
|
||||
|
||||
const { key, tier } = apiKeyResolution
|
||||
const outcome = await fetchOpenCodeGoUsageWithApiKey(key, input.signal)
|
||||
if (outcome.kind === 'ok') {
|
||||
return usageResult(outcome.windows, tier)
|
||||
}
|
||||
if (!hasCookie) {
|
||||
return apiFailureResult(outcome, tier)
|
||||
}
|
||||
// A Black-only account holds a key with no Go entitlement, so its usage still
|
||||
// only exists behind the console session; keep that path working.
|
||||
const cookieResult = await fetchOpenCodeGoRateLimits(
|
||||
input.cookie,
|
||||
input.workspaceIdOverride,
|
||||
input.networkProxySettings
|
||||
)
|
||||
return cookieResult.status === 'ok' ? cookieResult : apiFailureResult(outcome, tier)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
import { readGrokAuthSession } from './grok-auth'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
|
||||
export type Deferred<T> = {
|
||||
@@ -85,7 +85,7 @@ export function unavailableProvider(
|
||||
export function mockFreshBackgroundProviderFetches(): void {
|
||||
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24))
|
||||
vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0))
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockImplementation(async () => okProvider('opencode-go', 0))
|
||||
vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0))
|
||||
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
|
||||
@@ -95,7 +95,7 @@ export function mockFreshBackgroundProviderFetches(): void {
|
||||
export function resetRateLimitProviderMocks(): void {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
|
||||
vi.mocked(fetchKimiRateLimits).mockResolvedValue(okProvider('kimi', 0, Date.now()))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
|
||||
vi.mocked(fetchGrokRateLimits).mockResolvedValue({
|
||||
|
||||
@@ -28,8 +28,8 @@ vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
|
||||
@@ -27,8 +27,8 @@ vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
|
||||
@@ -35,8 +35,8 @@ vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
|
||||
@@ -32,8 +32,8 @@ vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
|
||||
@@ -29,8 +29,8 @@ vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
import { readGrokAuthSession } from './grok-auth'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
|
||||
import {
|
||||
deferred,
|
||||
errorProvider,
|
||||
@@ -36,8 +36,8 @@ vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
@@ -112,7 +112,7 @@ describe('RateLimitService', () => {
|
||||
expect(fetchClaudeRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchCodexRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchGeminiRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchOpenCodeGoRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchOpenCodeGoUsage).not.toHaveBeenCalled()
|
||||
expect(fetchKimiRateLimits).not.toHaveBeenCalled()
|
||||
expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled()
|
||||
expect(service.getState().grokAuthConfigured).toBe(true)
|
||||
@@ -272,9 +272,7 @@ describe('RateLimitService', () => {
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
||||
okProvider('opencode-go', 40, Date.now())
|
||||
)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40, Date.now()))
|
||||
vi.mocked(fetchKimiRateLimits).mockResolvedValueOnce(okProvider('kimi', 50, Date.now()))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 60, Date.now()))
|
||||
vi.mocked(fetchGrokRateLimits).mockReturnValueOnce(grok.promise)
|
||||
@@ -365,7 +363,8 @@ describe('RateLimitService', () => {
|
||||
const service = new RateLimitService()
|
||||
service.setOpenCodeGoConfigResolver(() => ({
|
||||
sessionCookie: 'session=abc123',
|
||||
workspaceIdOverride: ''
|
||||
workspaceIdOverride: '',
|
||||
apiKey: ''
|
||||
}))
|
||||
const networkProxySettings = {
|
||||
httpProxyUrl: 'http://proxy.example:8080',
|
||||
@@ -377,9 +376,7 @@ describe('RateLimitService', () => {
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
||||
okProvider('opencode-go', 40, Date.now())
|
||||
)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
@@ -395,11 +392,14 @@ describe('RateLimitService', () => {
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith(
|
||||
'session=abc123',
|
||||
undefined,
|
||||
networkProxySettings
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cookie: 'session=abc123',
|
||||
workspaceIdOverride: undefined,
|
||||
settingsApiKey: '',
|
||||
networkProxySettings
|
||||
})
|
||||
)
|
||||
expect(fetchGrokRateLimits).toHaveBeenCalledWith({
|
||||
signal: expect.any(AbortSignal),
|
||||
@@ -478,9 +478,7 @@ describe('RateLimitService', () => {
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(geminiWithBuckets)
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
||||
okProvider('opencode-go', 0, Date.now())
|
||||
)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 0, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
@@ -496,15 +494,14 @@ describe('RateLimitService', () => {
|
||||
const service = new RateLimitService()
|
||||
service.setOpenCodeGoConfigResolver(() => ({
|
||||
sessionCookie: '',
|
||||
workspaceIdOverride: ''
|
||||
workspaceIdOverride: '',
|
||||
apiKey: ''
|
||||
}))
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down'))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
vi.mocked(fetchGeminiRateLimits).mockRejectedValueOnce(new Error('gemini down'))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
||||
okProvider('opencode-go', 40, Date.now())
|
||||
)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
@@ -522,23 +519,22 @@ describe('RateLimitService', () => {
|
||||
let cookie = 'session=valid'
|
||||
service.setOpenCodeGoConfigResolver(() => ({
|
||||
sessionCookie: cookie,
|
||||
workspaceIdOverride: ''
|
||||
workspaceIdOverride: '',
|
||||
apiKey: ''
|
||||
}))
|
||||
|
||||
// 1. Success fetch
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20, Date.now()))
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 30, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(
|
||||
okProvider('opencode-go', 40, Date.now())
|
||||
)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 40, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40)
|
||||
|
||||
// 2. Clear cookie -> should become unavailable and LOSE the 40% data
|
||||
cookie = ''
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue({
|
||||
provider: 'opencode-go',
|
||||
session: null,
|
||||
weekly: null,
|
||||
@@ -560,27 +556,24 @@ describe('RateLimitService', () => {
|
||||
let workspaceId = 'wrk_A'
|
||||
service.setOpenCodeGoConfigResolver(() => ({
|
||||
sessionCookie: 'session=valid',
|
||||
workspaceIdOverride: workspaceId
|
||||
workspaceIdOverride: workspaceId,
|
||||
apiKey: ''
|
||||
}))
|
||||
|
||||
// 1. Success fetch for Workspace A
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(
|
||||
okProvider('opencode-go', 40, Date.now())
|
||||
)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 40, Date.now()))
|
||||
await service.refresh()
|
||||
expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40)
|
||||
|
||||
// 2. Change Workspace ID to B -> old data from A should be discarded
|
||||
workspaceId = 'wrk_B'
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(
|
||||
okProvider('opencode-go', 10, Date.now())
|
||||
)
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(okProvider('opencode-go', 10, Date.now()))
|
||||
await service.refresh()
|
||||
expect(service.getState().opencodeGo?.session?.usedPercent).toBe(10)
|
||||
|
||||
// 3. Clear Workspace ID (automatic) but it fails -> should show error, NOT stale data from B
|
||||
workspaceId = ''
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue({
|
||||
provider: 'opencode-go',
|
||||
session: null,
|
||||
weekly: null,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
|
||||
import { fetchGrokRateLimits } from './grok-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
|
||||
import {
|
||||
asRateLimitWindow,
|
||||
deferred,
|
||||
@@ -37,8 +37,8 @@ vi.mock('./kimi-fetcher', () => ({
|
||||
fetchKimiRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
vi.mock('./opencode-go-usage-source-selection', () => ({
|
||||
fetchOpenCodeGoUsage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax/minimax-fetcher', () => ({
|
||||
@@ -188,7 +188,7 @@ describe('RateLimitService', () => {
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(1)
|
||||
expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1)
|
||||
@@ -546,7 +546,7 @@ describe('RateLimitService', () => {
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude'))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex'))
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini'))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go'))
|
||||
vi.mocked(fetchOpenCodeGoUsage).mockResolvedValue(unavailableProvider('opencode-go'))
|
||||
vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi'))
|
||||
|
||||
const service = new RateLimitService()
|
||||
@@ -563,7 +563,7 @@ describe('RateLimitService', () => {
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(1)
|
||||
expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
|
||||
@@ -573,7 +573,7 @@ describe('RateLimitService', () => {
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(fetchOpenCodeGoUsage).toHaveBeenCalledTimes(2)
|
||||
expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2)
|
||||
|
||||
service.stop()
|
||||
|
||||
@@ -125,6 +125,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
|
||||
// Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads.
|
||||
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
|
||||
minimaxApiKeyConfigured: hasMiniMaxApiKey(),
|
||||
opencodeGoApiKeyConfigured: this.openCodeGoApiKeyConfigured,
|
||||
grokAuthConfigured: this.grokAuthConfigured,
|
||||
claudeTarget: this.claudeFetchTarget,
|
||||
codexTarget: this.codexFetchTarget,
|
||||
|
||||
@@ -4,7 +4,8 @@ import { fetchGeminiRateLimits } from '../gemini-usage-fetcher'
|
||||
import { fetchGrokRateLimits } from '../grok-fetcher'
|
||||
import { readGrokAuthSession } from '../grok-auth'
|
||||
import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection'
|
||||
import { RateLimitServiceFetchPolicy } from './service-fetch-policy'
|
||||
import type {
|
||||
ClaudeRuntimeAuthPreparation,
|
||||
@@ -76,6 +77,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
|
||||
const cookie = openCodeGoConfig?.sessionCookie ?? ''
|
||||
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
|
||||
const openCodeGoApiKey = openCodeGoConfig?.apiKey ?? ''
|
||||
const miniMaxConfigResult = this.resolveMiniMaxConfig()
|
||||
const miniMaxCookie = miniMaxConfigResult.config.sessionCookie
|
||||
const miniMaxGroupId = miniMaxConfigResult.config.groupId
|
||||
@@ -88,7 +90,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
this.grokAuthConfigured = grokAuthReadResult.status === 'ok'
|
||||
|
||||
// Discard stale data on config change — it belongs to a different session/workspace.
|
||||
const currentConfigHash = `${cookie}|${workspaceIdOverride}`
|
||||
// Digest, not the key: this string only has to change when the account does.
|
||||
const apiKeyFingerprint = openCodeGoApiKey
|
||||
? createHash('sha256').update(openCodeGoApiKey).digest('hex')
|
||||
: ''
|
||||
const currentConfigHash = `${cookie}|${workspaceIdOverride}|${apiKeyFingerprint}`
|
||||
const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash
|
||||
if (opencodeConfigChanged) {
|
||||
this.lastOpencodeConfigHash = currentConfigHash
|
||||
@@ -158,11 +164,18 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
|
||||
signal
|
||||
})),
|
||||
fetchGeminiRateLimits(geminiCliOAuthEnabled),
|
||||
fetchOpenCodeGoRateLimits(
|
||||
fetchOpenCodeGoUsage({
|
||||
settingsApiKey: openCodeGoApiKey,
|
||||
// Why here: the key can also come from the environment or OpenCode's
|
||||
// own store, so presence is only known once the fetch resolves it.
|
||||
onApiKeyResolved: (resolution) => {
|
||||
this.openCodeGoApiKeyConfigured = resolution.status === 'found'
|
||||
},
|
||||
cookie,
|
||||
workspaceIdOverride || undefined,
|
||||
this.networkProxySettingsResolver?.()
|
||||
),
|
||||
workspaceIdOverride: workspaceIdOverride || undefined,
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
}),
|
||||
this.fetchKimiWithResolvedHome(),
|
||||
miniMaxConfigResult.error
|
||||
? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
|
||||
|
||||
@@ -34,6 +34,7 @@ export abstract class RateLimitServiceState {
|
||||
grok: null
|
||||
}
|
||||
protected grokAuthConfigured = readGrokAuthSession().status === 'ok'
|
||||
protected openCodeGoApiKeyConfigured = false
|
||||
protected pollInterval: number = DEFAULT_POLL_MS
|
||||
protected timer: ReturnType<typeof setInterval> | null = null
|
||||
protected deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -44,6 +44,8 @@ export type ClaudeAuthPreparationResolver = (
|
||||
export type OpenCodeGoRateLimitConfig = {
|
||||
sessionCookie: string
|
||||
workspaceIdOverride: string
|
||||
/** Explicit Orca override; empty means fall back to env and OpenCode's own store. */
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export type MiniMaxRateLimitConfig = {
|
||||
|
||||
@@ -119,7 +119,8 @@ export function initializeMainProcessAccountServices(): void {
|
||||
const settings = store.getSettings()
|
||||
return {
|
||||
sessionCookie: settings.opencodeSessionCookie,
|
||||
workspaceIdOverride: settings.opencodeWorkspaceId
|
||||
workspaceIdOverride: settings.opencodeWorkspaceId,
|
||||
apiKey: settings.opencodeGoApiKey
|
||||
}
|
||||
})
|
||||
state.rateLimits.setMiniMaxConfigResolver(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -210,6 +210,7 @@ export function buildDefaultSettings(args: {
|
||||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
opencodeGoApiKey: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
minimaxEndpoint: 'overseas',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { GlobalSettings } from './global-settings-types'
|
||||
|
||||
/** What a Ghostty config import would change, shown before the user accepts it. */
|
||||
export type GhosttyImportPreview = {
|
||||
found: boolean
|
||||
configPath?: string
|
||||
configPaths?: string[]
|
||||
diff: Partial<GlobalSettings>
|
||||
unsupportedKeys: string[]
|
||||
error?: string
|
||||
}
|
||||
@@ -389,6 +389,8 @@ export type GlobalSettings = {
|
||||
opencodeSessionCookie: string
|
||||
/** Optional OpenCode Go workspace ID override; when set, skips the workspaces lookup and fetches usage directly. */
|
||||
opencodeWorkspaceId: string
|
||||
/** Optional OpenCode Go API key override. Takes precedence over OpenCode's own stored key and OPENCODE_API_KEY. Stored encrypted. */
|
||||
opencodeGoApiKey: string
|
||||
/** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */
|
||||
minimaxGroupId: string
|
||||
/** Comma-separated MiniMax model names to show in the status bar usage window. */
|
||||
@@ -525,11 +527,6 @@ export type OrcaWorkspaceLayout = {
|
||||
nestWorkspaces: boolean
|
||||
}
|
||||
|
||||
export type GhosttyImportPreview = {
|
||||
found: boolean
|
||||
configPath?: string
|
||||
configPaths?: string[]
|
||||
diff: Partial<GlobalSettings>
|
||||
unsupportedKeys: string[]
|
||||
error?: string
|
||||
}
|
||||
// Re-exported so existing importers keep one entry point; the shape lives in its
|
||||
// own file because this one is at the max-lines ceiling.
|
||||
export type { GhosttyImportPreview } from './ghostty-import-preview'
|
||||
|
||||
@@ -13,6 +13,7 @@ export function createEmptyRateLimitState(overrides: Partial<RateLimitState> = {
|
||||
grok: null,
|
||||
minimaxCookieConfigured: false,
|
||||
minimaxApiKeyConfigured: false,
|
||||
opencodeGoApiKeyConfigured: false,
|
||||
grokAuthConfigured: false,
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('RateLimitState', () => {
|
||||
grok: null,
|
||||
minimaxCookieConfigured: false,
|
||||
minimaxApiKeyConfigured: false,
|
||||
opencodeGoApiKeyConfigured: false,
|
||||
grokAuthConfigured: false,
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
|
||||
@@ -25,6 +25,8 @@ export type UsageRateLimitFailureKind =
|
||||
| 'deferred-by-live-session'
|
||||
| 'keychain-unavailable'
|
||||
| 'missing-scope'
|
||||
/** The account is authenticated but not entitled to the product being polled. */
|
||||
| 'no-subscription'
|
||||
| 'network'
|
||||
| 'server'
|
||||
| 'parse'
|
||||
@@ -138,6 +140,13 @@ export type RateLimitState = {
|
||||
* visible across reloads.
|
||||
*/
|
||||
minimaxApiKeyConfigured: boolean
|
||||
/**
|
||||
* True when main resolved an OpenCode Go API key (Orca settings,
|
||||
* OPENCODE_API_KEY, or what OpenCode stored on /connect). The key itself
|
||||
* never leaves main; the status bar ORs this with the session cookie to
|
||||
* decide whether the OpenCode Go bar stays visible.
|
||||
*/
|
||||
opencodeGoApiKeyConfigured: boolean
|
||||
/** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */
|
||||
grokAuthConfigured: boolean
|
||||
claudeTarget: RateLimitRuntimeTarget
|
||||
|
||||
Reference in New Issue
Block a user