Repository navigation
feat(routing): soonest-reset credential selector (MASTRA-765) - #3
Conversation
…RA-768) Hermetic selector tests written before the selector. S1, S2, S3 and S5 fail on stock v8.0.13; S7 and S8 are regression tests and pass on stock. - The mock takes POST /_mock/script to set per-credential quota headers or a 429 usage_limit_reached answer. - The hermetic config sets routing.strategy soonest-reset (stock falls back to round-robin), session-affinity and save-cooldown-status. - Each scenario owns its model and credentials through per-key models; `<model>-<name>` reaches one credential to seed its observation. - Each assertion sends several new sessions that must all land on one credential, so round-robin cannot pass by ID order. Auth IDs hash the mock's random port, so ID order differs between runs. - With session affinity on, the all-cooled 429 body has no provider field, and /v1/models lists the scenario models; two existing assertions follow that. S10 (restart with persisted observations) is a Go unit test in a later commit: a hermetic restart would kill requests of tests running in parallel workers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a "soonest-reset" routing strategy that prefers the credential whose weekly (7-day) quota window resets soonest, so quota is consumed before it expires at the reset. Exhausted credentials fall into the normal cooldown and selection moves to the next-soonest reset. Reset times come from the passive quota snapshot captured from Claude (Anthropic-Ratelimit-Unified-7d-Reset) and Codex (secondary window, or a weekly primary window) response headers. Credentials without a known future reset are picked first so one request can learn their window; ties resolve by ID, so providers without quota signals behave like fill-first. The strategy is a built-in selector and runs on the scheduler fast path for both single-provider and mixed-provider picks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit faa4ccff3b7920732e22752cac0eb62f817aafdf)
Quota observations (rate-limit headers from Claude/Codex responses) lived only in memory, so after a restart soonest-reset had no reset times. It then probed every credential with an unknown window, moving conversations between accounts (breaking Claude threads and prompt cache), and the management quota view showed nothing until traffic resumed. When save-cooldown-status is enabled, the latest observation is saved in the same per-auth/model record as cooldown state (stores key records by auth and model, so one record carries both; records with no cooldown get status "observed"). Saves are throttled to once a minute because every response refreshes the observation. On restore the newest observation wins, and observations older than eight days are neither saved nor restored. Cooldown change detection is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 01ad00219f945d663ef8b3e26cf25890574e5928)
Claude Code continues a conversation with
thread: {"type":"continue","previous_message_id":...}, and Anthropic keeps
that thread state on the account that created it. When a request lands on
a different account, the upstream returns 404 with
error.details.error_code "thread_not_found".
That 404 went through the generic not-found path: the proxy retried every
other credential (none can have the thread) and put the model into a 12h
cooldown on each, so the client's own recovery request with
thread: {"type":"create"} then failed with auth_unavailable.
Recognize thread_not_found (read from error.details.error_code) as a
request fault, like OpenAI's previous_response_not_found: it returns to
the client without rotating or cooling down credentials.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 67f5749d91980c93cf43144ca4fa6418ef460766)
…ovider (MASTRA-765) Rework the soonest-reset selector per MASTRA-765 scope 2.2-2.6. - Windows by length: a Codex window is weekly when window-minutes >= 10080, whichever position carries it; Claude reads 5h and 7d. - Exhaustion gate: skip a credential while an observed window is exhausted (Codex used-percent >= 100, or the most used window when limit-reached is set; Claude utilization >= 1 or status rejected) and its reset is ahead. When every candidate is gated, the ordering alone decides and upstream 429 handling takes over. - Unknown resets: Claude unknown sorts first and a passed Claude reset rolls forward by whole weeks; Codex unknown or passed sorts last. - Delete the scheduler fast-path additions and the isBuiltInSelector entry. Session affinity always takes the legacy pick path, so they were unreachable in the supported config; without affinity the legacy path now calls the selector too. - Quota signals: v8.0.13 hands the selector deep clones taken under the manager lock (availableAuthsForSelector, cloneAuthSlice), so reads are already race-clean; a race test follows in the next commit. Drop the zsprackett tests for the deleted fast path and the replaced unknown-first rule; the persistence test now checks restored signals directly instead of the dropped model-scoped reset helper (4d7b2e84 was not taken). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…RA-765) - S1: Codex order by weekly reset, weekly window chosen by length. - S2: a small remainder resetting soon goes before a fresh late window. - S3: Claude 5h utilization and rejected status gate until the reset; Codex used-percent and limit-reached gate; all gated keeps the order. - S5: Claude unknown first, Codex unknown or passed last, passed Claude reset rolled forward by whole weeks. - S10: a manager saves observations through MarkResult, a second manager on the same store restores them, and a new session goes to the soonest reset with no new headers. - Routing: the config's soonest-reset + session-affinity selector, and a manager with that selector, reach the selector through affinity on the legacy pick path; a bound session stays put. - Race: concurrent MarkResult observations and picks under -race. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TRA-765) MarkResult saved a passive quota observation only when a minute had passed since the last save. A second credential observed inside that minute was lost on restart, so the soonest-reset selector ranked it as unknown. Save whenever MarkResult records an observation, through the existing cooldown-state save path. Upstream already rewrites the auth file on every result, so a save per observation is in keeping. Remove quotaObservationPersistInterval, Manager.observationPersistedAt and the throttle test. The S10 restart test now records two credentials, restarts and picks the soonest reset with no new headers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing (MASTRA-765) The soonest-reset selector dropped a Codex window when Window-Minutes did not parse, which also dropped its exhaustion signal: a credential with a used-up primary window could still be picked. Keep every window with a known reset and use the parsed length only to mark the window weekly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saves skipped, and restore rejected, observations older than eight days. A Claude credential idle for nine days lost its known weekly schedule across a restart and the soonest-reset selector sorted it first as unknown. The selector already handles a passed reset: Claude rolls forward by whole weeks and a passed Codex reset sorts last. Remove quotaObservationRetention, both age checks, the now parameters that existed only for them, and the retention test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(MASTRA-765) d595299 removed the one-minute save throttle; the save-cooldown-status comment still described it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MASTRA-765 Finish the soonest-weekly-reset credential selector on CLIProxyAPI v8.0.13 (from zsprackett's fork)
ContextEric chose option 1 on MASTRA-763 (2026-10-03): drain subscription windows inside CLIProxyAPI with a soonest-weekly-reset selector, starting from zsprackett/CLIProxyAPI. Upstream will not take quota-aware routing (router-for-me/CLIProxyAPI#4715). We carry the patch in a private repo, Policy: send each new session to the account whose weekly window resets soonest, among accounts that still have quota. For one shared weekly limit, that order never wastes more quota than any other. With per-model weekly limits it is a heuristic, not an optimum. The plan was reviewed by Astra (gpt-6-astra, xhigh) on 2026-10-03, and I verified every finding I accepted against the source. This body reflects that review. Starting point: zsprackett commits from 2026-10-02, MIT:
Upstream Scope
Not in scope
Known limitation. A Codex window started outside the proxy is invisible until the proxy sends that account a request. Outside starts come from Eric's interactive CLIs and the factory's native routes, which use the same accounts. MASTRA-766 measures how often selection sees an unknown reset before anything is added. Acceptance criteria
Source refs
|
…ASTRA-765) A token refresh rewrites the auth file, the watcher reloads it and Manager.Update replaced the auth with one that carries no quota observation. soonest-reset then ranked the credential as unknown until its next response. Update now keeps the existing Signals and ObservedAt when the incoming auth has no observation; an incoming observation still wins and cooldown fields keep their upstream reset behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TRA-768) S3 seeded a 5-hour reset four seconds ahead and slept five, so a CI pause before the first assertions could flip the result. The exhausted window now resets an hour out and only the skip is asserted. The Go case "claude 5h reset passed" covers the transition after the reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est (MASTRA-768) The test sent none of the four signals DetectClaudeCodeRequest needs (X-App: cli, a claude-cli User-Agent, the claude-code beta and a valid metadata.user_id), so it exercised the cloaking path. It now sends them, claude-main cloaks every unconfirmed client, and the test asserts the upstream request has no injected system prompt. Without the signals the assertion fails on the billing-header system block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…affinity (MASTRA-765) MOBILYZE.md listed 4d7b2e84 as taken; it was left out. config.example.yaml now says soonest-reset is meant to run with session affinity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review gate: pstack:interrogate, run because Unfret is not installed here. Reviewers: Opus at max and Sol at max. Grok was a dropout. The verdict is recorded on MASTRA-765. Fix-round commits:
Correction to commit d595299's message: upstream does not rewrite the auth file on every result. |
Replaces #2, which GitHub closed when its base branch (claude/MASTRA-768-e2e-hermetic) was deleted at the merge of #1. The CI run on the tests-only commit is recorded on #2: S1, S2, S3 and S5 fail on stock as intended (run 37140121133).
Linear: MASTRA-765 (tests from MASTRA-768, U4)
Adds
routing.strategy: soonest-reset. Each new session goes to the credential whose weekly quota window resets soonest, among credentials that still have quota. Existing sessions follow upstream session affinity: they move only on cooldown or binding expiry. For one shared weekly limit, this order wastes the least quota. With per-model weekly limits it is a heuristic, and those limits stay out of scope here.The hermetic e2e suite it extends merged in #1.
Commits
06eff342tests only: hermetic selector scenarios S1, S2, S3 and S5, which must fail on stock, plus S7 and S8 as regression tests. The mock gainsPOST /_mock/script, which sets one credential's quota headers or 429s. Each scenario owns its own credentials. The hermetic config turns onsoonest-reset, session affinity andsave-cooldown-status.143cc73c,6c0fccaa,7f35e809: zsprackett/CLIProxyAPIfaa4ccff(soonest-reset strategy),01ad0021(persist observed quota with the cooldown state) and67f5749d(Claudethread_not_foundas a request fault). They are cherry-picked with-x, and Zac Sprackett stays the author.conductor_cooldown.gomerged cleanly: upstream's v8.0.10..v8.0.13 change to it only adds unauthorized-credential handling.4d7b2e84(model-scoped weekly) was not taken, because it is per-model scope only.ee450e85: our selector rework.isBuiltInSelectorentry are deleted. With session affinity on, picks always take the legacy path.fce6a86b: Go unit tests for S1, S2, S3, S5 and S10 (a real restart through the file store); a pick through session affinity; and a race test.d595299a: save every observation, not once a minute.1d90a949: keep a Codex window with noWindow-Minutes.1e43486d: no age cutoff on saved observations.a714c907: thesave-cooldown-statuscomment inconfig.example.yamlno longer mentions the removed throttle.Size against base 1aa79f5: non-test Go +254/−7 (selector 152, persistence 84); Go tests +620; e2e +215/−12.
Acceptance criteria from MASTRA-765
go test -race."go test -race -count=2 ./sdk/cliproxy/auth/passes on 1e43486 (a714c90 changes only a YAML comment).sdk/cliproxyfail intermittently under-race -count=6on stock v8.0.13 as well as on this branch:TestHandleAuthUpdates_SameRevisionWaitDoesNotWaitForOtherAuthInBatchandTestEndToEndAuthFile{Replacement,Patch}_RestoresModelsInV1ModelsWithoutRestart.go test ./...passes, and CI runs it.ASSERTION_FAILED. For example, S1 expectede2e-s1-band gote2e-s1-a. S7, S8 and the 9 protocol tests pass.Review
thread_not_foundas a request fault. Upstream already classifies OpenAI's equivalent,previous_response_not_found, the same way. Without it, a 404 cools down a healthy account and pulls it out of the drain order.picked == nilguard is safe, because both candidate paths return an error instead of an empty list.Known limitation
A Codex window started outside this proxy stays invisible until the proxy sends that account a request. MASTRA-766 measures how often that happens before anyone adds usage polling.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.