Skip to content

feat(codex): spend free resets when the Codex pool runs dry - #436

Merged
MagicalTux merged 3 commits into
KarpelesLab:masterfrom
rikbrown:feat/codex-reset-credits
Sep 25, 2026
Merged

MagicalTux merged 3 commits into
KarpelesLab:masterfrom
rikbrown:feat/codex-reset-credits

Conversation

@rikbrown

@rikbrown rikbrown commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Draft — stacks on #430. This branch is one commit on top of #430, so the shown diff includes it until that PR merges. Review the redemption commit with git diff feat/codex-reset-credit-count..feat/codex-reset-credits. It also overlaps #429 in server.js's refusal branch; whichever lands first requires the other to rebase.

The gap

OpenAI grants ChatGPT/Codex subscriptions occasional free rate-limit reset credits. Redeeming one clears that account's spent rate-limit windows before their reset. The Codex client exposes it as a manual action only.

A pooled Codex account can therefore sit out a weekly window while an expiring credit remains unused. With two spent accounts, every request gets 429 (none available) while the reset sits in the account's billing state.

What changed

New src/codex-reset-credits.js redeems credits. forwardRequest invokes it when selection cannot choose a Codex account.

Trigger. When every Codex account is over threshold, selection returns (none available) before an upstream request. The hook is in forwardRequest's !account branch, after candidate discovery and before hold/retry-after paths. It receives only accounts whose unavailableReason a reset can clear: quota and throttled, not disabled, capped, entitlement, error, or route. It runs only for Codex requests. A decline or throw preserves the refusal byte-for-byte; success reselects without consuming a retry budget because there was no upstream attempt.

Policy. shouldRedeemReset is pure and fully table-tested. A redemption requires a spent weekly window, never only a 5-hour window. It also requires either a dry Codex pool or a credit expiring within three days. is_supported_by_plan: false credits are removed before expiry reasoning.

One credit per dry pool. Three guards enforce it:

  1. Concurrent refusals join one attempt.
  2. A credit spend, or a failure that cannot rule one out, holds the entire fleet rather than only the touched account.
  3. The pool is resolved for each decision, so an account returned to service makes siblings decline because another Codex account can serve.

ctx.resetRedeemTried limits each request to one attempt when upstream reports success but the account remains unselectable.

Off unless armed. autoRedeemResets is a fleet switch, false by default, in createDefaultConfig and config.example.json. It is available from the TUI settings screen and applies on reload. accounts[].autoRedeemReset is veto-only: it can say never, not yes.

Time budget. The attempt waits inline because a dry pool cannot rotate. One 10s deadline covers token refresh, detail fetch, consume, and re-read, leaving the rest of the Codex client's 60s response-head budget. ensureTokenFresh races the deadline because it has no timeout and concurrent refusals join it. A late refresh continues in the background; the attempt declines. Steps with nothing left are skipped and decline with cooldown.

Wire format

The endpoints are not in the public OpenAI API and are documented nowhere. They were recovered from the Codex binary and verified against a live account:

GET /wham/rate-limit-reset-credits detail rows: id, status, expires_at, is_supported_by_plan
POST /wham/rate-limit-reset-credits/consume {redeem_request_id, credit_id?}

Two details are covered by tests:

  • The verdict field is code, not outcome; values are reset, nothing_to_reset, no_credit, and already_redeemed.
  • Failure can return HTTP 200. Classifying by status would treat no credits as a successful redemption.

A transport error, timeout, or unreadable body does not mean upstream declined: it may have acted on the POST. The fleet-wide hold follows, and redeem_request_id is an idempotency key reused by a retry. already_redeemed is treated as success.

Detail-row granted_at / expires_at are ISO-8601 strings; the app-server layer uses epoch seconds for the same fields.

Verification

  • npm test — 1936 tests pass, including 62 new tests in test/codex-reset-credits.test.js; the base PR provides the file's other 6.
  • npm run lint, npm run typecheck — clean.
  • node scripts/typecheck-strict.mjs — the new file has 0 strict diagnostics and the tree is 8 below this PR's base. A required Record<string, any> annotation on hooks in index.js also fixes pre-existing diagnostics.

No test calls the network. Tests inject fetch; two proxy-level tests use a local http server. Tests specifically verify that a failed quota re-read holds all other accounts, a consume without a received verdict stops the walk, and a token refresh exceeding the budget is left running.

The behaviour was observed with two accounts at 100% weekly with a credit: requests were refused before sending, the credit was redeemed on refusal, and the request was served.

Deliberate limitations

  • A live Codex 429 on a chosen account is another spent-weekly trigger, but recognising a spent x-codex-* window as a durable quota rejection is separate work. Once the 429 path identifies it, the second trigger is a few lines using the same ResetCreditRedeemer.
  • The default remains off. No per-account key can arm it.

🤖 Generated with Claude Code

@rikbrown rikbrown changed the title feat(codex): spend a free rate-limit reset when the Codex pool runs dry feat(codex): spend free resets when the Codex pool runs dry Sep 19, 2026
Builds on the reading of `quota.resetCredits` (KarpelesLab#430): this is the one action
that count makes possible, and it is off unless an operator arms it.

A Codex account whose weekly window has run out is often holding the credit
that would clear it. Codex's own client spends one by hand, from its usage
screen, so a pooled account stayed walled for the rest of its week while the
credit sat unspent and expiring.

The trigger is the refusal itself. Once every Codex account reads over
threshold, selection turns the request away BEFORE choosing one: the proxy
answers "(none available)" and no upstream request is made — so there is no 429
to hang this off, and on a spent pool that is what happens to every request.
The hook therefore sits in forwardRequest's `!account` branch, ahead of the
retry-after measurement and the hold/retry paths, and is offered only the
accounts whose `unavailableReason` a reset would actually clear: `quota` and
`throttled`, never `disabled`, `capped`, `entitlement`, `error` or `route`, all
of which survive a cleared window and would take a credit for nothing. A
decline or a throw leaves the refusal byte-for-byte what it was; a success
re-selects, costing no retry from the budget because nothing was ever sent.

The two routes are not part of the public API and are documented nowhere;
these were recovered from the Codex binary and then verified live:

  GET  /wham/rate-limit-reset-credits           detail rows: id, status,
       expires_at, is_supported_by_plan. ISO-8601 STRINGS here, while the Rust
       app-server layer states the same fields as epoch seconds — the two must
       not be parsed the same way
  POST /wham/rate-limit-reset-credits/consume   {redeem_request_id, credit_id?}

Two traps in that last one, both covered by tests: the verdict field is `code`,
not the `outcome` the app-server layer uses, and it answers 200 when it refuses
as readily as when it works. Classify on the body, never the status — reading
"you hold no credits" as a redemption that worked is not recoverable.

Spending a credit is irreversible and they are scarce, so the policy is
deliberately mean. A redemption needs the WEEKLY window spent — never the 5-hour
one, which comes back on its own within hours while the weekly one walls an
account off for days — and then either the whole Codex pool is dry, so the
credit actually unblocks work rather than topping up an account rotation would
have stepped past, or the credit expires within three days and holding it costs
more than spending it. A credit the plan cannot spend is filtered before any
expiry reasoning, so it can never be what makes an expiring-credit decision look
justified.

At most one credit per dry pool, which takes three guards rather than one.
Refusals arriving together join a single attempt. An attempt that spent a
credit — or that failed in a way that cannot rule out having spent one — holds
the WHOLE fleet off, not only the account it touched: an attempt walks the
pool, so a sibling that never touched the endpoint is just as able to spend the
second credit, and the alternative (the redeemed account reading available
again) leans on a quota re-read that is allowed to fail. And the pool is
re-resolved per decision, so once that re-read does land, the account it
returned to service is what makes every sibling answer "another Codex account
can still serve". `ctx.resetRedeemTried` bounds it to one attempt per request,
so a redeem that reports success but leaves the account unselectable — upstream
not yet caught up with its own reset — costs that request one re-selection
rather than a loop.

`redeem_request_id` is an idempotency key. A consume whose verdict never
arrived may still have been acted on, so it replays its key instead of minting
a fresh one, and `already_redeemed` is upstream answering for the POST that did
land — which is why it counts as a success.

Off unless armed: `autoRedeemResets` is a fleet switch, default false, in
`createDefaultConfig` and `config.example.json`, toggleable from the TUI
settings screen and applied live on reload so it can be killed without a
restart. The per-account `accounts[].autoRedeemReset` is a veto only — it can
say never for this account, never yes.

The attempt is awaited inline, before the re-selection, because a dry pool has
nowhere to rotate to: the redemption IS the recovery for that request. It is
bounded by a single 10s deadline across token refresh, detail fetch, consume
and the re-read together, leaving the rest of the client's response-head
budget for the retry it exists to enable. The refresh is raced against that
deadline rather than measured after it returns: `ensureTokenFresh` takes no
timeout of its own and every concurrent refusal joins the same one, so a check
that runs afterwards can only report a promise already broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rikbrown
rikbrown force-pushed the feat/codex-reset-credits branch from cd0c89d to 953b5f9 Compare September 20, 2026 09:33
@rikbrown
rikbrown marked this pull request as ready for review September 20, 2026 09:33
@MagicalTux
MagicalTux merged commit a253acf into KarpelesLab:master Sep 25, 2026
5 checks passed
MagicalTux added a commit that referenced this pull request Sep 29, 2026
Thirty-two commits since 1.1.21. Two change routing on an existing
config without an opt-in (#480, #481); the rest is opt-in, additive, or
display.

Behaviour changes
  #481 an API-key account's 401 is a cooldown, not a permanent `error`:
       1 min, then 5, 15 and 60 for every further rejection with no success
       in between; any 2xx/3xx resets it. The request still fails over and
       the client never sees the 401. OAuth accounts are unchanged
  #480 with session distribution on, requests carrying no session id stay
       within the top priority tier, so a fallback gateway no longer answers
       Claude Code's bootstrap and connector calls
  #470 a 200 whose SSE stream reports a provider failure before any output
       (`server_is_overloaded`, `response.failed`) fails over once, like a
       status-shaped failure would
  #465 a reload removes running accounts whose config entry is gone from
       disk, so `teamclaude remove` from another shell takes effect at once
  #460 `import` refuses an account whose token upstream has definitively
       rejected (401/403), even with `--name`; a 5xx or timeout still imports

Rename
  #483 the project is being renamed to TeamRouter (#72). This release accepts
       the new name everywhere the old one is read and changes nothing an
       install has on disk: `teamrouter` runs the same CLI, every
       `TEAMCLAUDE_*` variable is also read as `TEAMROUTER_*` (which wins when
       both are set), every `/teamclaude/…` control route also answers at
       `/teamrouter/…`, and `~/.config/teamrouter.json` is used when it exists

Features
  #441 per-account egress proxy (`accounts[].routing`: http, socks4/4a,
       socks5/5h) for refresh, probes and requests; `login --routing`,
       `teamclaude routing set/show/clear`, a connection check before it is
       relied on, and a short hold when the proxy is unreachable
  #427 `accounts[].allowExtraUsage: true` lets a paid extra-usage account
       serve once every account is past its threshold, instead of a 429
  #466 `accounts[].maxSpend`, a money cap judged against the month-to-date
       extra-usage spend upstream reports; the TUI shows what an account
       has billed
  #436 `autoRedeemResets` spends a free Codex rate-limit reset credit when
       the Codex pool runs dry (off by default)
  #482 `advisorEligibility: "strict" | "prefer"`; when the advisor model
       narrows selection to a subset of the fleet the log says so, and status
       carries the reading (`advisorNarrowing`)
  #478 `stripOverageHeaders` drops another org's per-organization billing
       headers from responses, for a pool spanning several orgs (#476)
  #471 `quota.unified5hSeenAt` / `unified7dSeenAt` in status: when upstream
       last stated each shared window
  #446 client and dimension usage for the last 5h and 24h in status and the
       dashboard, resumed across restarts
  #458 #459 #461 the dashboard sets the switch threshold, enables/disables and
       reprioritizes an account, and has a light theme remembered per browser
  #464 `l` in the TUI signs an account in `error` in again from the dashboard
  #457 status records which Codex limit meters each model
       (`quota.codexModelLimits`)
  #442 `quotaBarPercent` drops the percentage beside a TUI bar's countdown
  #451 `stripRequestFields` takes `content.<block type>` to drop content
       blocks a strict Anthropic-compatible upstream rejects
  #469 `proxy.mcp` schemas declare their item types, the write audit line
       records what happened, and the write queue has a depth (#447–#450)

Fixes
  #477 a refused WebSocket handshake whose headers all drop is relayed as a
       well-formed head instead of a blank line and body bytes
  #474 two members of one ChatGPT workspace are told apart by user id, so a
       second `login --codex` no longer replaces the first
  #469 a Codex Responses stream with no Content-Type is relayed as a stream
       and booked; thread repair on the global upstream; TUI settings and
       status gaps; a hint when a local login would have served
  #463 a Codex row with no session window draws one wide weekly bar

Tests
  #484 #485 #486 the suite asserts behaviour, not the scheduler: wall-clock
       upper bounds are gone, and subprocess tests spawn the server through
       `test-helpers/spawn-server.js`, which verifies the server it reached by
       `server.pid` (new in status) instead of trusting a port

Tooling
  #452 #453 #454 #455 docker workflow actions bumped
rikbrown added a commit to rikbrown/teamclaude that referenced this pull request Oct 7, 2026
…redits

Upstream KarpelesLab#430 and KarpelesLab#436 now report and redeem free rate-limit reset credits,
so what is left of this change is the fork's side of it.

Persists quota.planType and quota.codexModelBuckets, which were learned and
then dropped on every restart, and clamps a restored bucket table to the 32
newest — the writers cap, but the eviction only runs when a new slug
arrives, so an over-long table would otherwise stand.

docs/openai.md gains the reset-credit section, for a pool reached through
the Codex sidecar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rikbrown added a commit to rikbrown/teamclaude that referenced this pull request Oct 7, 2026
Upstream KarpelesLab#436 redeems a credit on the pool-dry refusal alone, and
docs/accounts.md documents the count, the switch and the policy. The copy
here had drifted from it (it still described a second trigger, on an
upstream 429), so it now says only what the sidecar changes: nothing, since
the credits belong to the pooled accounts and never to the conduit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants