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: 2 additions & 2 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion AUDITOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Guidance for future audits of intentional tradeoffs in this repository.
## Sticky-balanced routing

- **Session affinity intentionally outranks moment-to-moment quota ranking.** Once assigned, a session remains on its OAuth account across transient transport/provider/quota-probe failures and relative quota changes so its large prompt cache is not rewritten on another account.
- **A confirmed five-hour exhaustion does not migrate when reset is within 15 minutes.** The route returns `Retry-After` and remains assigned; longer 5h exhaustion, 7d/model-scoped exhaustion, killswitch blocks, removed/disabled accounts, and permanent re-login failures may migrate.
- **OpenCode 1 and Pi hold when all exhausted windows blocking the requested model reset within 15 minutes.** Five-hour, seven-day and the requested model's scoped window are treated alike: every exhausted blocking window must have a known future reset, and the latest reset determines the wait, with deterministic session jitter. Another model's scope does not count. OpenCode 1 returns `Retry-After`; Pi emits an error event. Unknown, invalid or past resets, longer waits, killswitch blocks, removed/disabled accounts, and permanent re-login failures may migrate. OpenCode 2 does not implement the hold yet and can reroute instead.
- **API-key routes are not candidates for quota-balanced first assignment.** Their existing confirmed-main-exhaustion gate remains authoritative.

## CI test-count floor gate
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi.

## Unreleased

- In sticky-balanced mode on OpenCode 1 and Pi, a session now waits on its account when whichever quota window blocks it resets within 15 minutes, including 7-day and model limits, instead of moving and rebuilding its cache. OpenCode 2 does not hold yet.

- OpenCode 2: finishing an OAuth sign-in from `/claude` → Accounts no longer fails with "Action failed"; the Accounts menu explains how to connect OpenCode when no main account exists. Thanks to @colinmollenhour. Addresses #464.

## 2.2.6
Expand Down
2 changes: 2 additions & 0 deletions docs/accounts-and-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ OAuth login must resolve a verified Claude account UUID. Credentials and refresh

Change mode in `/claude` → **Routing**. **Reset routing session** clears the current session's pin; it does not remove accounts. Pins are quota-aware session-affinity hints, not credentials. Changing the selected model can require a fresh assignment. Model-specific exhaustion can move among OAuth accounts, but paid API-key fallback requires confirmed general OAuth exhaustion, not just an exhausted model-specific limit or a display percentage rounded up to 100%.

In OpenCode 1 and Pi, sticky-balanced waits on the assigned account when all exhausted windows blocking the requested model have known future resets and the latest reset is within 15 minutes. This includes five-hour, seven-day and matching model-scoped limits, but not another model's scope. The wait uses the latest reset plus deterministic session jitter; OpenCode 1 returns `Retry-After` and Pi emits an error event. An unknown, invalid or past reset on any exhausted blocking window, or a latest reset beyond 15 minutes, allows migration. OpenCode 2 does not implement this hold yet and can reroute instead.

## Quota and limits

Quota shows five-hour, seven-day and model-scoped windows when available. OpenCode's **Refresh quota** action requests fresh readings; Pi shows the summary without a refresh action. Passive response headers and quota API polling have different coverage: headers can update standard windows without supplying model-scoped limits.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Fallbacks are separate OAuth accounts or Anthropic-compatible API-key routes in

The balanced allocator normalizes headroom by time until reset and accounts for the initial prompt before the next quota refresh. It persists the assignment by hashed session ID across processes and restarts. **Reset routing session** clears only the current session. A user-selected model change discards its old assignment; transparent safety recovery retains the account. Existing hybrid CacheKeep sessions can seed their warm route instead of changing accounts unnecessarily.

Affinity survives network/relay failures, 5xx responses, unconfirmed 429s, quota-probe failures and relative weight changes. Confirmed seven-day or matching model-scoped exhaustion can move the session. Confirmed five-hour exhaustion moves it only when more than 15 minutes remain before reset; within 15 minutes the route stays and returns `Retry-After`. Disabled/removed accounts, permanent re-login failures and killswitch blocks can also cause reselection. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted, preserving Fable-capable quota.
Affinity survives network/relay failures, 5xx responses, unconfirmed 429s, quota-probe failures and relative weight changes. In OpenCode 1 and Pi, sticky-balanced holds the session on its account when every exhausted window blocking the requested model has a known future reset and the latest reset is within 15 minutes. Five-hour, seven-day and the requested model's scoped limits are treated alike; another model's scoped limit does not count. A hold uses the latest reset plus deterministic per-session jitter. OpenCode 1 returns `Retry-After`; Pi reports the hold as an error event. If any exhausted blocking window has an unknown, invalid or past reset, or the latest reset is more than 15 minutes away, the session can move. OpenCode 2 does not implement this hold yet and can reroute instead. Disabled/removed accounts, permanent re-login failures and killswitch blocks can also cause reselection. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted, preserving Fable-capable quota.

### API-key fallback

Expand Down
33 changes: 33 additions & 0 deletions mutations.toml
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,39 @@ broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core
broad_report = "tmp/mutations/core.xml"
broad_id = "{name}"

[[control]]
id = "sticky-quota-seven-day-always-migrate"
guards = "An exhausted seven-day window with a known reset within fifteen minutes must hold instead of migrating immediately."
file = "packages/core/src/sticky-routing.ts"
old = " const resetAt = window.resetsAt ? Date.parse(window.resetsAt) : NaN"
new = """ if (reason === 'seven-day') return { action: 'migrate', reason } // NON-VACUITY BREAK: migrate on seven-day exhaustion without checking its reset.
const resetAt = window.resetsAt ? Date.parse(window.resetsAt) : NaN"""
test_file = "packages/core/src/tests/sticky-quota-failure.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/sticky-quota-failure.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["sticky quota hold > seven-day holds within fifteen minutes including the boundary"]
expect_message = "error: expect(received).toEqual(expected)"
broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"]
broad_report = "tmp/mutations/core.xml"
broad_id = "{name}"

[[control]]
id = "sticky-quota-drop-latest-reset-keep-first"
guards = "The latest reset among exhausted windows blocking the requested model must determine whether to hold and how long to wait."
file = "packages/core/src/sticky-routing.ts"
old = " if (!blocking || resetAt > blocking.resetAt) blocking = { resetAt, reason }"
new = " if (!blocking) blocking = { resetAt, reason } // NON-VACUITY BREAK: keep the first reset instead of the latest."
test_file = "packages/core/src/tests/sticky-quota-failure.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/sticky-quota-failure.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["sticky quota hold > uses the latest blocking reset for both the hold and its duration"]
expect_message = "error: expect(received).toEqual(expected)"
broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"]
broad_report = "tmp/mutations/core.xml"
broad_id = "{name}"

[[control]]
id = "pi-auth-slot-uses-sdk-directory"
guards = "Offline vault setup protects Pi's actual auth file, not an extension-only directory override."
Expand Down
68 changes: 33 additions & 35 deletions packages/core/src/sticky-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ export type StickyQuotaFailureDecision =
| { action: 'retain'; reason: 'not-exhausted' | 'unknown' }
| {
action: 'hold'
reason: 'five-hour-short-reset'
reason:
| 'five-hour-short-reset'
| 'seven-day-short-reset'
| 'model-scoped-short-reset'
retryAfterSeconds: number
}
| {
Expand Down Expand Up @@ -488,43 +491,38 @@ export function decideStickyQuotaFailure(input: {
const now = input.now ?? Date.now()
const quota = knownQuota(input.quota)
if (!quota) return { action: 'retain', reason: 'unknown' }
const scoped = getScopedQuotaWindowForModel(quota, input.modelId)
if (
scoped &&
Number.isFinite(scoped.remainingPercent) &&
scoped.remainingPercent <= 0
) {
return { action: 'migrate', reason: 'model-scoped' }
}
const sevenDay = quota.seven_day
if (
sevenDay &&
Number.isFinite(sevenDay.remainingPercent) &&
sevenDay.remainingPercent <= 0
) {
return { action: 'migrate', reason: 'seven-day' }
}
const fiveHour = quota.five_hour
if (
fiveHour &&
Number.isFinite(fiveHour.remainingPercent) &&
fiveHour.remainingPercent <= 0
) {
const resetAt = fiveHour.resetsAt ? Date.parse(fiveHour.resetsAt) : NaN
let blocking:
| { resetAt: number; reason: 'five-hour' | 'seven-day' | 'model-scoped' }
| undefined
for (const [reason, window] of [
['model-scoped', getScopedQuotaWindowForModel(quota, input.modelId)],
['seven-day', quota.seven_day],
['five-hour', quota.five_hour],
] as const) {
if (
Number.isFinite(resetAt) &&
resetAt > now &&
resetAt - now <= STICKY_ROUTING_SHORT_RESET_GRACE_MS
) {
return {
action: 'hold',
reason: 'five-hour-short-reset',
retryAfterSeconds: Math.max(1, Math.ceil((resetAt - now) / 1000)),
}
!window ||
!Number.isFinite(window.remainingPercent) ||
window.remainingPercent > 0
)
continue
const resetAt = window.resetsAt ? Date.parse(window.resetsAt) : NaN
if (!Number.isFinite(resetAt) || resetAt <= now)
return { action: 'migrate', reason }
// A request can resume only after every exhausted window blocking its model resets.
if (!blocking || resetAt > blocking.resetAt) blocking = { resetAt, reason }
}
if (!blocking) return { action: 'retain', reason: 'not-exhausted' }
if (blocking.resetAt - now <= STICKY_ROUTING_SHORT_RESET_GRACE_MS) {
return {
action: 'hold',
reason: `${blocking.reason}-short-reset`,
retryAfterSeconds: Math.max(
1,
Math.ceil((blocking.resetAt - now) / 1000),
),
}
return { action: 'migrate', reason: 'five-hour' }
}
return { action: 'retain', reason: 'not-exhausted' }
return { action: 'migrate', reason: blocking.reason }
}

export function getStickyRoutingStatePath(accountStoragePath: string) {
Expand Down
142 changes: 142 additions & 0 deletions packages/core/src/tests/sticky-quota-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, test } from 'bun:test'
import type { OAuthQuotaSnapshot } from '../accounts.ts'
import {
decideStickyQuotaFailure,
stickyRetryAfterWithJitter,
} from '../sticky-routing.ts'

const NOW = Date.parse('2026-10-24T08:00:00Z')
const MODEL = 'claude-fable-5'
const kinds = ['five-hour', 'seven-day', 'model-scoped'] as const
type Kind = (typeof kinds)[number]

function exhausted(kind: Kind, reset: string | undefined): OAuthQuotaSnapshot {
const window = {
usedPercent: 100,
remainingPercent: 0,
resetsAt: reset,
checkedAt: NOW,
}
if (kind === 'five-hour') return { five_hour: window }
if (kind === 'seven-day') return { seven_day: window }
return {
scoped: [
{
...window,
id: 'fable-weekly',
title: 'Fable only',
modelName: 'Fable',
},
],
}
}

function decide(quota: OAuthQuotaSnapshot, modelId = MODEL) {
return decideStickyQuotaFailure({ quota, modelId, now: NOW })
}

const resetIn = (ms: number) => new Date(NOW + ms).toISOString()

describe('sticky quota hold', () => {
for (const kind of kinds) {
test(`${kind} holds within fifteen minutes including the boundary`, () => {
for (const ms of [1, 10 * 60_000, 15 * 60_000]) {
expect(decide(exhausted(kind, resetIn(ms)))).toEqual({
action: 'hold',
reason: `${kind}-short-reset`,
retryAfterSeconds: Math.max(1, Math.ceil(ms / 1000)),
})
}
})

test(`${kind} moves beyond fifteen minutes`, () => {
expect(decide(exhausted(kind, resetIn(15 * 60_000 + 1)))).toEqual({
action: 'migrate',
reason: kind,
})
})

test(`${kind} moves for unknown invalid or past resets even with another short reset`, () => {
for (const reset of [undefined, '', 'invalid', resetIn(0), resetIn(-1)]) {
const other = kind === 'five-hour' ? 'seven-day' : 'five-hour'
expect(
decide({
...exhausted(other, resetIn(60_000)),
...exhausted(kind, reset),
}),
).toEqual({ action: 'migrate', reason: kind })
}
})
}

test('uses the latest blocking reset for both the hold and its duration', () => {
for (const first of kinds) {
for (const second of kinds.filter((kind) => kind !== first)) {
const quota = {
...exhausted(first, resetIn(5 * 60_000)),
...exhausted(second, resetIn(12 * 60_000)),
}
expect(decide(quota)).toEqual({
action: 'hold',
reason: `${second}-short-reset`,
retryAfterSeconds: 12 * 60,
})
expect(
decide({ ...quota, ...exhausted(second, resetIn(16 * 60_000)) }),
).toEqual({ action: 'migrate', reason: second })
}
}
expect(
decide({
...exhausted('five-hour', resetIn(14 * 60_000)),
...exhausted('seven-day', resetIn(10 * 60_000)),
...exhausted('model-scoped', resetIn(2 * 60_000)),
}),
).toEqual({
action: 'hold',
reason: 'five-hour-short-reset',
retryAfterSeconds: 14 * 60,
})
})

test('ignores an exhausted scope for a different model including an unknown reset', () => {
for (const reset of [undefined, resetIn(60 * 60_000)]) {
const scoped = exhausted('model-scoped', reset)
expect(decide(scoped, 'claude-opus-5')).toEqual({
action: 'retain',
reason: 'not-exhausted',
})
expect(
decide(
{ ...scoped, ...exhausted('five-hour', resetIn(10 * 60_000)) },
'claude-opus-5',
),
).toEqual({
action: 'hold',
reason: 'five-hour-short-reset',
retryAfterSeconds: 600,
})
}
})

test('retains unknown and non-exhausted quotas unchanged', () => {
for (const quota of [undefined, { kind: 'unknown' as const }]) {
expect(decideStickyQuotaFailure({ quota, now: NOW })).toEqual({
action: 'retain',
reason: 'unknown',
})
}
expect(decide({})).toEqual({ action: 'retain', reason: 'not-exhausted' })
expect(
decide({
five_hour: { usedPercent: 99, remainingPercent: 1, checkedAt: NOW },
}),
).toEqual({ action: 'retain', reason: 'not-exhausted' })
})

test('keeps the existing deterministic SHA256 session jitter', () => {
expect(stickyRetryAfterWithJitter('session-a', 840)).toBe(856)
expect(stickyRetryAfterWithJitter('session-a', 0)).toBe(17)
expect(stickyRetryAfterWithJitter('session-a', 1.1)).toBe(18)
})
})
2 changes: 1 addition & 1 deletion packages/opencode/src/cache-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export async function withStickyRetryAfter(
error: {
type: 'rate_limit_error',
message:
'Sticky OAuth account five-hour quota resets shortly; retaining session affinity.',
'Sticky OAuth account quota resets shortly; retaining session affinity.',
},
}),
{ status: 429, headers },
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9220,7 +9220,7 @@ const anthropicAuthPlugin = async (
error: {
type: 'rate_limit_error',
message:
'Sticky OAuth account five-hour quota resets shortly; retaining session affinity.',
'Sticky OAuth account quota resets shortly; retaining session affinity.',
},
}),
{
Expand Down
Loading
Loading