Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/opencode/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,7 @@ async function executeQuotaCommand(
if (failures.length > 0) {
lines.push('')
for (const f of failures) {
lines.push(
`⚠ ${f.account}: could not fetch (${f.error ?? 'unknown error'})`,
)
lines.push(`- ${f.account}: fetch failed — Refresh to retry`)
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/core/backoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function isTransientRefreshError(error: unknown) {
}
if (!(error instanceof Error)) return false
return (
error.name === 'AbortError' ||
error.name === 'TimeoutError' ||
error.message.includes('fetch failed') ||
('code' in error &&
(error.code === 'ECONNRESET' ||
Expand All @@ -37,6 +39,8 @@ export function isTransientQuotaError(error: unknown) {
if (!(error instanceof Error)) return false
const code = (error as Error & { code?: unknown }).code
return (
error.name === 'AbortError' ||
error.name === 'TimeoutError' ||
message.includes('fetch failed') ||
code === 'ECONNRESET' ||
code === 'ECONNREFUSED' ||
Expand Down
66 changes: 50 additions & 16 deletions packages/opencode/src/core/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
* directly.
*/

import { createLogger } from '../logger.ts'
import { errorMessage } from '../util/error.ts'
import type { OAuthQuotaSnapshot } from './accounts.ts'
import { parseRetryAfter } from './backoff.ts'

const log = createLogger('quota')
type QuotaLogger = Pick<typeof log, 'debug' | 'warn'>

// ---------------------------------------------------------------------------
// Window names are widen-able strings (not an enum) so multi-family
// rate-limit windows (additional_rate_limits / metered_limit_name) can be
Expand Down Expand Up @@ -92,6 +97,7 @@ export async function codexRefreshFn(input: {
}> {
const response = await input.fetchImpl(`${CODEX_ISSUER}/oauth/token`, {
method: 'POST',
signal: AbortSignal.timeout(15_000),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
Expand Down Expand Up @@ -140,23 +146,51 @@ export async function whamUsageFn(input: {
fetchImpl: typeof fetch
now: () => number
accountId?: string
accountKey?: string
logger?: QuotaLogger
}): Promise<OAuthQuotaSnapshot> {
const res = await input.fetchImpl(WHAM_USAGE_URL, {
headers: {
authorization: `Bearer ${input.accessToken}`,
'chatgpt-account-id': input.accountId ?? '',
'oai-client-platform': 'web',
'oai-client-version': '0',
'x-openai-target-path': '/backend-api/wham/usage',
},
})
if (!res.ok) {
const retryAfter = parseRetryAfter(res.headers.get('retry-after'))
throw Object.assign(new Error(`wham usage check failed: ${res.status}`), {
const logger = input.logger ?? log
const accountId = input.accountKey ?? 'unknown'
const startedAt = Date.now()
logger.debug('wham usage fetch started', { pid: process.pid, accountId })
try {
const res = await input.fetchImpl(WHAM_USAGE_URL, {
signal: AbortSignal.timeout(15_000),
headers: {
authorization: `Bearer ${input.accessToken}`,
'chatgpt-account-id': input.accountId ?? '',
'oai-client-platform': 'web',
'oai-client-version': '0',
'x-openai-target-path': '/backend-api/wham/usage',
},
})
if (!res.ok) {
const retryAfter = parseRetryAfter(res.headers.get('retry-after'))
throw Object.assign(new Error(`wham usage check failed: ${res.status}`), {
status: res.status,
retryAfter,
}) as ProviderHttpError
}
const { normalizeWham } = await import('../quota-normalize.ts')
const snapshot = normalizeWham(await res.json())
logger.debug('wham usage fetch succeeded', {
pid: process.pid,
accountId,
status: res.status,
retryAfter,
}) as ProviderHttpError
elapsedMs: Date.now() - startedAt,
})
return snapshot
} catch (error) {
logger.warn('wham usage fetch failed', {
pid: process.pid,
accountId,
status:
typeof (error as { status?: unknown } | null)?.status === 'number'
? (error as { status: number }).status
: 'error',
elapsedMs: Date.now() - startedAt,
error: errorMessage(error),
})
throw error
}
const { normalizeWham } = await import('../quota-normalize.ts')
return normalizeWham(await res.json())
}
46 changes: 35 additions & 11 deletions packages/opencode/src/core/refresh-all-quota.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createLogger } from '../logger'
import { errorMessage } from '../util/error'
import type {
FallbackAccountManager,
isOAuthAccount,
Expand All @@ -7,6 +9,9 @@ import type {
import type { whamUsageFn } from './provider'
import type { QuotaManager } from './quota-manager'

const log = createLogger('quota')
type QuotaLogger = Pick<typeof log, 'debug' | 'warn'>

export interface RefreshAllQuotaDeps {
getAuth: () => Promise<{
type: string
Expand Down Expand Up @@ -50,6 +55,7 @@ export interface RefreshAllQuotaDeps {
isOAuthAccountFn: typeof isOAuthAccount
whamFn?: typeof whamUsageFn
respectBackoff?: boolean
logger?: QuotaLogger
}

export interface RefreshAllQuotaResult {
Expand All @@ -65,6 +71,18 @@ export async function refreshAllQuota(
if (!whamFn) throw new Error('whamFn is required for refreshAllQuota')

const results: RefreshAllQuotaResult[] = []
const logger = deps.logger ?? log
const recordOutcome = (result: RefreshAllQuotaResult) => {
results.push(result)
const payload = {
pid: process.pid,
accountId: result.account,
status: result.ok ? 'ok' : 'error',
...(result.error ? { error: result.error } : {}),
}
if (result.ok) logger.debug('quota refresh succeeded', payload)
else logger.warn('quota refresh failed', payload)
}

// --- MAIN ---
try {
Expand All @@ -90,13 +108,14 @@ export async function refreshAllQuota(

if (auth.access) {
if (deps.respectBackoff && deps.quotaManager.isBackedOff()) {
results.push({ account: 'main', ok: true })
recordOutcome({ account: 'main', ok: true })
} else {
const snap = await whamFn({
accessToken: auth.access,
fetchImpl: deps.fetchImpl,
now: deps.now,
accountId: deps.storageMainAccountId,
accountKey: 'main',
})
deps.quotaManager.setMain(
auth.access,
Expand All @@ -108,23 +127,27 @@ export async function refreshAllQuota(
undefined,
true,
)
results.push({ account: 'main', ok: true })
recordOutcome({ account: 'main', ok: true })
}
} else {
results.push({ account: 'main', ok: false, error: 'no access token' })
recordOutcome({
account: 'main',
ok: false,
error: 'no access token',
})
}
} else {
results.push({
recordOutcome({
account: 'main',
ok: false,
error: 'auth type is not oauth',
})
}
} catch (e) {
results.push({
recordOutcome({
account: 'main',
ok: false,
error: (e as Error)?.message ?? String(e),
error: errorMessage(e),
})
}

Expand All @@ -142,7 +165,7 @@ export async function refreshAllQuota(
(acct as OAuthAccount).access,
)
) {
results.push({ account: acct.id, ok: true })
recordOutcome({ account: acct.id, ok: true })
continue
}

Expand All @@ -154,7 +177,7 @@ export async function refreshAllQuota(
}

if (!refreshed.access) {
results.push({
recordOutcome({
account: acct.id,
ok: false,
error: 'no access token',
Expand All @@ -167,6 +190,7 @@ export async function refreshAllQuota(
fetchImpl: deps.fetchImpl,
now: deps.now,
accountId: refreshed.accountId,
accountKey: acct.id,
})
deps.quotaManager.setFallback(
acct.id,
Expand All @@ -178,12 +202,12 @@ export async function refreshAllQuota(
refreshed.access,
true,
)
results.push({ account: acct.id, ok: true })
recordOutcome({ account: acct.id, ok: true })
} catch (e) {
results.push({
recordOutcome({
account: acct.id,
ok: false,
error: (e as Error)?.message ?? String(e),
error: errorMessage(e),
})
}
}
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
setSidebarMachineState,
upsertSidebarActiveRouting,
} from './sidebar-state'
import { errorMessage } from './util/error'
import { isRecord } from './util/record'
import { stableStringify } from './util/stable-json'
import { uuidV7 } from './util/uuid-v7'
Expand Down Expand Up @@ -1971,7 +1972,12 @@ export async function CodexAuthPlugin(
isOAuthAccountFn: isOAuthAccount,
whamFn: whamUsageFn,
respectBackoff: true,
}).catch(() => {})
}).catch((error) =>
logQ.warn('boot quota seed failed', {
pid: process.pid,
error: errorMessage(error),
}),
)
}

// -------------------------------------------------------------------
Expand Down
8 changes: 3 additions & 5 deletions packages/opencode/src/tests/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ describe('commands', () => {
expect(fb2Section).not.toContain('resets:')
})

test('refreshAllQuota with one failure → ⚠ line for failing account', async () => {
test('refreshAllQuota with one failure → short retry state for failing account', async () => {
const qm = new QuotaManager({
storage: { version: 1 as const, accounts: [] },
})
Expand Down Expand Up @@ -884,10 +884,8 @@ describe('commands', () => {
expect(payload.text).toContain('10% used')
expect(payload.text).toContain('50% used')

// Failure line
expect(payload.text).toContain(
'⚠ fb-2: could not fetch (wham usage check failed: 401)',
)
expect(payload.text).toContain('- fb-2: fetch failed — Refresh to retry')
expect(payload.text).not.toContain('wham usage check failed: 401')
})

test('refreshAllQuota undefined → falls back to cached display', async () => {
Expand Down
Loading