Skip to content
Open
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
29 changes: 25 additions & 4 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ export type OAuthQuotaSnapshot = Partial<
Record<QuotaWindowName, AccountQuotaWindow>
> & {
scoped?: AccountScopedQuotaWindow[]
// Top-level freshness stamp for the whole snapshot. mergeAccountRuntimeState
// uses this when the snapshot has no per-window checkedAt (e.g. a windowless
// empty-scoped snapshot) — without it, a windowless refresh gets read as
// checkedAt=0 and treated as stale, so an old per-window quota resurrects
// instead of being overwritten.
checkedAt?: number
}

export type RoutingMode = 'main-first' | 'fallback-first'
Expand Down Expand Up @@ -451,6 +457,15 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] {
if (normalized) quota[key] = normalized
}

// Persist a top-level snapshot checkedAt through normalize so the
// mergeAccountRuntimeState freshness comparison stays meaningful when the
// snapshot has no per-window checkedAt (e.g. {scoped:[]}). Pre-feature
// inputs without this key are unaffected — only on-disk snapshots that
// already carry it reach this branch.
if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) {
quota.checkedAt = value.checkedAt
}

if (Array.isArray(value.scoped)) {
const scoped = value.scoped
.map((entry): AccountScopedQuotaWindow | undefined => {
Expand All @@ -477,7 +492,11 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] {
}
})
.filter((entry): entry is AccountScopedQuotaWindow => entry != null)
if (scoped.length) quota.scoped = scoped
// Preserve empty `[]` so a downstream reader can distinguish "scoped
// owned by anthropic-auth, none visible" from "no scoped data on this
// snapshot". Pre-feature inputs without a `scoped` key are not affected
// — only inputs that already carried an array reach this line.
quota.scoped = scoped
}

return Object.keys(quota).length ? quota : undefined
Expand Down Expand Up @@ -697,6 +716,7 @@ function quotaSnapshotCheckedAt(quota: OAuthQuotaSnapshot | undefined) {
quota?.five_hour?.checkedAt ?? 0,
quota?.seven_day?.checkedAt ?? 0,
...(quota?.scoped?.map((window) => window.checkedAt) ?? []),
quota?.checkedAt ?? 0,
)
}

Expand Down Expand Up @@ -1943,8 +1963,8 @@ function slugForQuotaIdentity(value: string): string {
function mapScopedWeeklyLimits(
limits: OAuthUsageLimit[] | undefined,
checkedAt: number,
): AccountScopedQuotaWindow[] | undefined {
if (!Array.isArray(limits)) return undefined
): AccountScopedQuotaWindow[] {
if (!Array.isArray(limits)) return []
const seen = new Set<string>()
const scoped: AccountScopedQuotaWindow[] = []
for (const limit of limits) {
Expand Down Expand Up @@ -1974,7 +1994,7 @@ function mapScopedWeeklyLimits(
checkedAt,
})
}
return scoped.length ? scoped : undefined
return scoped
}

function mapUsageWindow(
Expand Down Expand Up @@ -2027,6 +2047,7 @@ export async function fetchOAuthQuotaSnapshot(input: {
five_hour: mapUsageWindow(usage.five_hour, checkedAt),
seven_day: mapUsageWindow(usage.seven_day, checkedAt),
scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
checkedAt,
} satisfies OAuthQuotaSnapshot
}

Expand Down
27 changes: 18 additions & 9 deletions packages/opencode/src/sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ function normalizeAccountQuota(value: unknown): AccountQuota | null {
}
})
.filter((entry): entry is ScopedQuotaWindow => entry != null)
if (scoped.length) quota.scoped = scoped
// Preserve empty `[]` so a sidebar reader can distinguish "scoped owned
// by anthropic-auth, none visible" from "no quota data at all". The OUTER
// Array.isArray guard means pre-feature inputs without a `scoped` key are
// not affected — only inputs that already carried an array reach this line.
quota.scoped = scoped
}

return Object.keys(quota).length ? quota : null
Expand Down Expand Up @@ -284,18 +288,23 @@ export function getCollapsedQuotaSummary(quota: AccountQuota | null): {
}
}

const scopedSegments = scoped.map(
(window) =>
`${formatScopedQuotaLabel(window.title)}: ${Math.round(window.usedPercent)}%`,
)
const primarySegments =
fiveHourUsedPercent == null && sevenDayUsedPercent == null
? []
: [
`5h: ${fiveHourUsedPercent == null ? '—' : `${Math.round(fiveHourUsedPercent)}%`}`,
`7d: ${sevenDayUsedPercent == null ? '—' : `${Math.round(sevenDayUsedPercent)}%`}`,
]

return {
fiveHourUsedPercent,
sevenDayUsedPercent,
scopedUsedPercents,
text: [
`5h: ${fiveHourUsedPercent == null ? '—' : `${Math.round(fiveHourUsedPercent)}%`}`,
`7d: ${sevenDayUsedPercent == null ? '—' : `${Math.round(sevenDayUsedPercent)}%`}`,
...scoped.map(
(window) =>
`${formatScopedQuotaLabel(window.title)}: ${Math.round(window.usedPercent)}%`,
),
].join(' '),
text: [...primarySegments, ...scopedSegments].join(' '),
}
}

Expand Down
223 changes: 223 additions & 0 deletions packages/opencode/src/tests/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,229 @@ describe('FallbackAccountManager', () => {
).toEqual(['fable-empty', 'fable-ok'])
})

test('preserves an empty scoped quota array through save/load when scoped is the only quota key', async () => {
const storage = baseStorage()
storage.accounts.push({
id: 'empty-scoped-only',
type: 'oauth',
access: 'a',
refresh: 'r',
expires: Date.now() + 60 * 60 * 1000,
quota: { scoped: [] },
})
await saveAccounts(storage)

const loaded = await loadAccounts()
const account = expectOAuthAccount(loaded?.accounts[0])
expect(account.quota).toBeDefined()
expect(account.quota?.scoped).toBeDefined()
expect(account.quota?.scoped).toEqual([])
})

test('preserves an empty scoped quota array alongside five_hour', async () => {
const storage = baseStorage()
storage.accounts.push({
id: 'empty-scoped-with-five-hour',
type: 'oauth',
access: 'a',
refresh: 'r',
expires: Date.now() + 60 * 60 * 1000,
quota: {
five_hour: {
usedPercent: 10,
remainingPercent: 90,
checkedAt: 1_000,
},
scoped: [],
},
})
await saveAccounts(storage)

const loaded = await loadAccounts()
const account = expectOAuthAccount(loaded?.accounts[0])
expect(account.quota?.five_hour?.usedPercent).toBe(10)
expect(account.quota?.scoped).toBeDefined()
expect(account.quota?.scoped).toEqual([])
})

test('routing predicate treats empty scoped array as not exhausted (invariant lock)', () => {
const quota: OAuthQuotaSnapshot = {
five_hour: {
usedPercent: 10,
remainingPercent: 90,
checkedAt: 1_000,
},
seven_day: {
usedPercent: 5,
remainingPercent: 95,
checkedAt: 1_000,
},
scoped: [],
}
expect(quotaSnapshotModelScopeIsExhausted(quota, 'claude-fable-5')).toBe(
false,
)
expect(quotaSnapshotPassesModelScope(quota, 'claude-fable-5')).toBe(true)
expect(quotaSnapshotPassesModelScope(quota, 'claude-opus-4-8')).toBe(true)
})

test('mergeAccountRuntimeState: windowless {scoped:[]} replaces stale exhausted-Fable quota', async () => {
// Persist an exhausted-Fable scoped window at T1, then save a windowless
// empty-scoped snapshot stamped T2 > T1. mergeAccountRuntimeState must NOT
// treat the empty snapshot as stale and resurrect the old Fable window —
// loadAccounts must return scoped: [].
const accountId = 'merge-empty-scoped'
const T1 = 1_000
const T2 = 2_000

const first = baseStorage()
first.accounts.push({
id: accountId,
type: 'oauth',
access: 'a',
refresh: 'r',
expires: Date.now() + 60 * 60 * 1000,
quota: {
five_hour: {
usedPercent: 20,
remainingPercent: 80,
checkedAt: T1,
},
seven_day: {
usedPercent: 15,
remainingPercent: 85,
checkedAt: T1,
},
scoped: [
{
id: 'claude-weekly-scoped-fable',
title: 'Fable only',
modelName: 'Fable',
usedPercent: 100,
remainingPercent: 0,
checkedAt: T1,
},
],
},
})
await saveAccounts(first)

const second = baseStorage()
second.accounts.push({
id: accountId,
type: 'oauth',
access: 'a',
refresh: 'r',
expires: Date.now() + 60 * 60 * 1000,
quota: { scoped: [], checkedAt: T2 },
})
await saveAccounts(second)

const loaded = await loadAccounts()
const account = expectOAuthAccount(
loaded?.accounts.find((entry) => entry.id === accountId),
)
expect(account.quota?.scoped).toEqual([])
})

test('mergeAccountRuntimeState: real promo-end path lands empty scoped', async () => {
// Reachable-path lock: persist an account with five_hour/seven_day + a
// Fable scoped window at T1, then save the post-promo snapshot (5h/7d at
// T2, scoped: [], top-level checkedAt: T2). Loaded quota.scoped must be [].
const accountId = 'merge-promo-end'
const T1 = 1_000
const T2 = 2_000

const first = baseStorage()
first.accounts.push({
id: accountId,
type: 'oauth',
access: 'a',
refresh: 'r',
expires: Date.now() + 60 * 60 * 1000,
quota: {
five_hour: {
usedPercent: 20,
remainingPercent: 80,
checkedAt: T1,
},
seven_day: {
usedPercent: 15,
remainingPercent: 85,
checkedAt: T1,
},
scoped: [
{
id: 'claude-weekly-scoped-fable',
title: 'Fable only',
modelName: 'Fable',
usedPercent: 42,
remainingPercent: 58,
checkedAt: T1,
},
],
},
})
await saveAccounts(first)

const second = baseStorage()
second.accounts.push({
id: accountId,
type: 'oauth',
access: 'a',
refresh: 'r',
expires: Date.now() + 60 * 60 * 1000,
quota: {
five_hour: {
usedPercent: 25,
remainingPercent: 75,
checkedAt: T2,
},
seven_day: {
usedPercent: 20,
remainingPercent: 80,
checkedAt: T2,
},
scoped: [],
checkedAt: T2,
},
})
await saveAccounts(second)

const loaded = await loadAccounts()
const account = expectOAuthAccount(
loaded?.accounts.find((entry) => entry.id === accountId),
)
expect(account.quota?.scoped).toEqual([])
})

test('top-level checkedAt round-trips through save/load', async () => {
const storage = baseStorage()
const stampedAt = 1_234_567
storage.accounts.push({
id: 'with-checked-at',
type: 'oauth',
access: 'a',
refresh: 'r',
expires: Date.now() + 60 * 60 * 1000,
quota: {
five_hour: {
usedPercent: 10,
remainingPercent: 90,
checkedAt: stampedAt,
},
checkedAt: stampedAt,
},
})
await saveAccounts(storage)

const loaded = await loadAccounts()
const account = expectOAuthAccount(
loaded?.accounts.find((entry) => entry.id === 'with-checked-at'),
)
expect(account.quota?.checkedAt).toBe(stampedAt)
})

test('refreshes fallback tokens within the four-hour minimum window', async () => {
const storage = baseStorage()
storage.refresh = {
Expand Down
Loading