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
73 changes: 73 additions & 0 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,27 @@ export interface OAuthQuotaSnapshot {
secondary?: AccountQuotaWindow
resetCreditsAvailable?: number
resetCreditsApplicable?: number
spendControl?: OAuthSpendControlReading
credits?: OAuthCredits
}

export interface OAuthSpendControlReading {
limit: number
used: number
remaining: number
usedPercent: number
remainingPercent: number
resetsAt?: string
unit?: string
source?: string
reached: boolean
}

export interface OAuthCredits {
hasCredits: boolean
unlimited: boolean
overageLimitReached: boolean
balance?: number
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -477,6 +498,58 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] {
}
}

const spendControl = value.spendControl
if (isRecord(spendControl)) {
const limit = Number(spendControl.limit)
const used = Number(spendControl.used)
const remaining = Number(spendControl.remaining)
const usedPercent = Number(spendControl.usedPercent)
const remainingPercent = Number(spendControl.remainingPercent)
if (
Number.isFinite(limit) &&
Number.isFinite(used) &&
Number.isFinite(remaining) &&
Number.isFinite(usedPercent) &&
Number.isFinite(remainingPercent) &&
typeof spendControl.reached === 'boolean'
) {
quota.spendControl = {
limit,
used,
remaining,
usedPercent,
remainingPercent,
resetsAt:
typeof spendControl.resetsAt === 'string'
? spendControl.resetsAt
: undefined,
unit:
typeof spendControl.unit === 'string' ? spendControl.unit : undefined,
source:
typeof spendControl.source === 'string'
? spendControl.source
: undefined,
reached: spendControl.reached,
}
}
}

const credits = value.credits
if (
isRecord(credits) &&
typeof credits.hasCredits === 'boolean' &&
typeof credits.unlimited === 'boolean' &&
typeof credits.overageLimitReached === 'boolean'
) {
const balance = Number(credits.balance)
quota.credits = {
hasCredits: credits.hasCredits,
unlimited: credits.unlimited,
overageLimitReached: credits.overageLimitReached,
...(Number.isFinite(balance) ? { balance } : {}),
}
}

return Object.keys(quota).length ? quota : undefined
}

Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isSafeResetAccountKey,
mutateAccounts,
type OAuthAccount,
type OAuthSpendControlReading,
type RoutingMode,
readConfigRosterIds,
} from './accounts'
Expand Down Expand Up @@ -248,6 +249,16 @@ function quotaAge(checkedAt: number | undefined, now: number): string {
return ` (${Math.floor(ageMs / 60_000)}m old)`
}

function formatSpendControlLine(
spendControl: OAuthSpendControlReading,
indent = '',
): string {
const resets = spendControl.resetsAt
? ` · resets ${spendControl.resetsAt}`
: ''
return `${indent}- credits: ${Math.round(spendControl.usedPercent)}% used (${Math.round(spendControl.used)} / ${Math.round(spendControl.limit)}, ${Math.round(spendControl.remaining)} remaining)${resets}`
}

async function executeQuotaCommand(
ctx: CommandContext,
): Promise<OpenDialogPayload> {
Expand All @@ -274,6 +285,9 @@ async function executeQuotaCommand(
if (q.resetCreditsAvailable !== undefined) {
lines.push(`- resets: ${q.resetCreditsAvailable}`)
}
if (q.spendControl) {
lines.push(formatSpendControlLine(q.spendControl))
}
} else {
lines.push('No main quota snapshot available. Send a request first.')
}
Expand All @@ -298,6 +312,9 @@ async function executeQuotaCommand(
if (entry.quota.resetCreditsAvailable !== undefined) {
lines.push(` - resets: ${entry.quota.resetCreditsAvailable}`)
}
if (entry.quota.spendControl) {
lines.push(formatSpendControlLine(entry.quota.spendControl, ' '))
}
}
}

Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/quota-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,31 @@ interface WhamUsageResponse {
available_count?: number
applicable_available_count?: number
} | null
credits?: {
has_credits?: boolean
unlimited?: boolean
overage_limit_reached?: boolean
balance?: unknown
} | null
spend_control?: {
reached?: boolean
individual_limit?: {
source?: string
unit?: string
limit?: unknown
used?: unknown
remaining?: unknown
used_percent?: unknown
remaining_percent?: unknown
reset_at?: string | number
} | null
} | null
}

function nonNegativeNumberish(value: unknown): number | undefined {
if (value == null || value === '') return undefined
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
}

function windowFromWham(
Expand Down Expand Up @@ -256,5 +281,49 @@ export function normalizeWham(json: WhamUsageResponse): OAuthQuotaSnapshot {
if (resetCreditsApplicable !== undefined) {
snapshot.resetCreditsApplicable = resetCreditsApplicable
}
const individualLimit = json.spend_control?.individual_limit
if (individualLimit && typeof json.spend_control?.reached === 'boolean') {
const limit = nonNegativeNumberish(individualLimit.limit)
const used = nonNegativeNumberish(individualLimit.used)
const remaining = nonNegativeNumberish(individualLimit.remaining)
const usedPercent = nonNegativeNumberish(individualLimit.used_percent)
const remainingPercent = nonNegativeNumberish(
individualLimit.remaining_percent,
)
if (
limit !== undefined &&
used !== undefined &&
remaining !== undefined &&
usedPercent !== undefined &&
remainingPercent !== undefined
) {
snapshot.spendControl = {
limit,
used,
remaining,
usedPercent,
remainingPercent,
resetsAt: toResetIso(individualLimit.reset_at),
unit: individualLimit.unit,
source: individualLimit.source,
reached: json.spend_control.reached,
}
}
}
const credits = json.credits
if (
credits &&
typeof credits.has_credits === 'boolean' &&
typeof credits.unlimited === 'boolean' &&
typeof credits.overage_limit_reached === 'boolean'
) {
const balance = nonNegativeNumberish(credits.balance)
snapshot.credits = {
hasCredits: credits.has_credits,
unlimited: credits.unlimited,
overageLimitReached: credits.overage_limit_reached,
...(balance !== undefined ? { balance } : {}),
}
}
return snapshot
}
16 changes: 11 additions & 5 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,11 +763,11 @@ export function findCachekeepFallbackAccount(
)
}

// wham is the only source that reports reset-credit counts; header/WS pushes
// never carry the fields. An incoming push that omits them inherits the last
// known counts for the same account so the sidebar and the reset dialog do
// not lose them on every per-turn header/WS update — but an explicit incoming
// value (including 0) always wins over a stale cached one.
// wham is the only source that reports reset-credit counts and spend-control
// budgets; header/WS pushes never carry those fields. An incoming push that
// omits them inherits the last known reading for the same account so the
// sidebar and command output do not lose it on every per-turn update — but an
// explicit incoming value (including 0) always wins over a stale cached one.
export function mergePushedQuotaMetadata(
incoming: OAuthQuotaSnapshot,
previous: OAuthQuotaSnapshot | undefined,
Expand All @@ -783,6 +783,12 @@ export function mergePushedQuotaMetadata(
merged[key] = carried
}
}
if (
merged.spendControl === undefined &&
previous.spendControl !== undefined
) {
merged.spendControl = previous.spendControl
}
return merged
}

Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,23 @@ export interface QuotaWindow {
windowMinutes?: number
}

export interface SpendControlReading {
limit: number
used: number
remaining: number
usedPercent: number
remainingPercent: number
resetsAt?: string
unit?: string
source?: string
reached: boolean
}

export interface AccountQuota {
checkedAt?: number
primary?: QuotaWindow
secondary?: QuotaWindow
spendControl?: SpendControlReading
resetCreditsAvailable?: number
}

Expand Down Expand Up @@ -1089,6 +1102,9 @@ function mergeQuotaByWindow(
incoming.checkedAt,
existing.checkedAt,
)
const spendControl = existingSnapshotIsFresher
? (existing.spendControl ?? incoming.spendControl)
: (incoming.spendControl ?? existing.spendControl)
let checkedAt: number | undefined
for (const stamp of [
finiteWindowCheckedAt(primary),
Expand All @@ -1102,6 +1118,7 @@ function mergeQuotaByWindow(
...incoming,
primary,
secondary,
...(spendControl !== undefined ? { spendControl } : {}),
checkedAt: checkedAt ?? incoming.checkedAt,
}
}
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/tests/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1853,6 +1853,54 @@ describe('commands', () => {
expect(fb2Section).not.toContain('resets:')
})

test('quota command shows spend control only for accounts that report it', async () => {
const qm = new QuotaManager({
configPath: getAccountStoragePath(),
storage: { version: 1 as const, accounts: [] },
})
qm.setMain('access-main', {
quota: {
...makeQuotaSnapshot(15),
spendControl: {
limit: 2500,
used: 501.7787666320801,
remaining: 1998.2212333679199,
usedPercent: 20.071150665283206,
remainingPercent: 79.9288493347168,
resetsAt: '2026-10-01T00:00:00.000Z',
unit: 'credits',
source: 'individual_limit',
reached: false,
},
},
refreshAfter: Date.now() + 5 * 60 * 1000,
checkedAt: Date.now(),
})
qm.setFallback('fb-1', {
quota: makeQuotaSnapshot(42),
refreshAfter: Date.now() + 5 * 60 * 1000,
checkedAt: Date.now(),
})
const ctx: CommandContext = {
packageVersion: PackageVersion,
accountStoragePath: configPath,
accountStatePath: getAccountStatePath(configPath),
quotaManager: qm,
loadAccounts,
client: makeClient(),
}

const payload = await buildDialogPayload('openai-quota', '', ctx)
const [mainSection, fallbackSection = ''] = payload.text.split(
'### Fallback accounts',
)

expect(mainSection).toContain(
'- credits: 20% used (502 / 2500, 1998 remaining) · resets 2026-10-01T00:00:00.000Z',
)
expect(fallbackSection).not.toContain('credits:')
})

test('refreshAllQuota with one failure → short retry state for failing account', async () => {
const qm = new QuotaManager({
configPath: getAccountStoragePath(),
Expand Down
Loading
Loading