Skip to content

feat(quota): stamp when each shared window was last observed - #471

Merged
MagicalTux merged 2 commits into
KarpelesLab:masterfrom
EvanAgee:fm/teamclaude-quota-seenat
Sep 29, 2026
Merged

MagicalTux merged 2 commits into
KarpelesLab:masterfrom
EvanAgee:fm/teamclaude-quota-seenat

Conversation

@EvanAgee

Copy link
Copy Markdown
Contributor

/teamclaude/status now says when upstream last stated each account's shared 5-hour and weekly readings. Every account's quota carries unified5hSeenAt and unified7dSeenAt (epoch ms) beside unified5h and unified7d, for Claude and Codex accounts alike.

Why: a status reader can't tell a reading taken a minute ago from one restored off disk after the account sat idle for a week. Both show up as the same number. The Fable and Sonnet buckets already carry a SeenAt for the staleness check, and Codex model buckets carry seenAt. The two shared windows, which every account has, carried no age at all. I read status from a dashboard that refuses to forecast from a reading whose age it can't prove, so right now every shared reading there is marked unverified.

What moves a stamp

A stamp moves only in the same branch that writes its window's value:

  • Anthropic response headers (anthropic-ratelimit-unified-5h/7d-utilization) and the OAuth usage probe.
  • Codex x-codex-* headers and the /wham/usage read.

So these leave both stamps alone: an empty payload, a failed probe (usage.error), a bucket with a reset time and no utilization, an unparseable value, a zeroed Codex window, and a model-scoped weekly bucket (7d_oi, sevenDayFable, a named Codex family's weekly window). A partial update stamps only the window it stated.

One case reads like a model bucket but isn't: a Codex subscription states its only 5-hour window inside a model-named family, and parseCodexQuota already takes that as the account's unified5h. The value changes, so the 5-hour stamp moves with it. There's a test for it.

When a window resets in _clearExpiredQuotas, its stamp is cleared along with the value, so a stamp never outlives the number it vouches for.

Restarts

Both stamps are in PERSISTED_QUOTA_FIELDS, so a real time survives a restart unchanged. restoreQuotaState only copies non-null fields onto emptyQuota(), so a row from a state file written before this change comes back with its value and a null stamp. It stays null until upstream states that window again. Nothing starts a clock on restore. That's deliberate: the family buckets start a local grace clock when their stamp is missing, but that clock drives revalidation and isn't evidence of an upstream reading, so the shared stamps don't copy it.

Nothing gates on the new fields. Rotation, thresholds and the TUI behave exactly as before.

Notes for review

  • The two new emptyQuota() entries carry a /** @type {number|null} */ cast. Without it, strict mode infers the type null and the ratchet grows by six diagnostics on src/account-manager.js. With it, the count stays at the base (1703 both sides).
  • applyUsageData already read the clock once, as const now above the family buckets. That line moves to the top of the method, so the shared stamps and the family stamps use the same reading.
  • Out of scope, and left as they were: codexModelBuckets isn't persisted, so model buckets and their seenAt start empty after a restart (nothing is invented, they just aren't kept). The Anthropic header path still accepts whatever parseFloat returns, so a negative or Infinity utilization is stored, and now stamped with it. Value and stamp still agree. The Anthropic per-model scopedWeekly readings carry no time either; this change doesn't add one.

Tests

test/quota-seen-at.test.js, seven tests:

  • Anthropic headers, the Anthropic probe, Codex headers and the Codex usage read each stamp only the windows they stated, with the empty, failed, reset-only, unparseable, partial and model-only cases asserted to leave the other stamp alone.
  • A 5-hour and then a weekly reset each clear their own stamp.
  • A real stamp survives export and restore. A pre-stamp state row restores with null stamps, and still null after a second restart.
  • A real proxy against a fake upstream serves one Claude and one Codex request, then GET /teamclaude/status shows each stamp beside its value inside the request's time window, with null stamps on an idle account.

All seven fail on 6c3bdc0 and pass on this branch. I also broke each piece on a throwaway copy (dropped persistence, kept a stamp through a reset, stamped on reset-only, left the Codex header writes unstamped, left the Codex usage-read writes unstamped, stamped on any Codex payload, stamped on any Anthropic response). Each mutation failed a named test.

npm test                               # tests 2424, pass 2424, fail 0
npm run lint                           # clean
npm run typecheck                      # clean
npm run typecheck:strict -- --base origin/master
                                       # 1703 strict-mode diagnostics (base 6c3bdc01: 1703)

docs/usage.md documents the two fields next to the existing status --json notes.

What I walked

A side server on 127.0.0.1:3458, run from this branch with a scratch TEAMCLAUDE_CONFIG holding one Claude account, one idle Claude account and one Codex account. It had access tokens only, no refresh tokens, plus upstreamProxy: false, autoUpdate: false, quotaProbeSeconds: 0, TEAMCLAUDE_DISABLE_AUTOUPDATE=1 and the proxy env unset. Nothing live was touched.

Before any request, every stamp read null. Then I sent one claude-haiku-4-5-20251001 request pinned to claude-served (sent 14:55:01.48Z, HTTP 200, answered ok) and one real Codex CLI request (codex-cli 0.156.0), gpt-5.6-luna, pinned to codex-served (14:55:02.16Z to 14:55:03.97Z, answered ok). Status right after:

captured 2026-09-25T14:55:04Z server.version 1.1.21
claude-served  anthropic reqs=1 unified5h=0 unified5hSeenAt=1790348102100 (2026-09-25T14:55:02.100Z) unified7d=0.97 unified7dSeenAt=1790348102100 (2026-09-25T14:55:02.100Z)
claude-idle    anthropic reqs=0 unified5h=None unified5hSeenAt=None (None) unified7d=None unified7dSeenAt=None (None)
codex-served   codex     reqs=1 unified5h=None unified5hSeenAt=None (None) unified7d=0.85 unified7dSeenAt=1790348103153 (2026-09-25T14:55:03.153Z)

The Codex response stated a weekly window and no 5-hour window, so only the weekly stamp moved. After a SIGTERM and a restart on the same state file, the times came back unchanged (1790348102100, 1790348103153) and the idle account still read null.


🤖 Generated with Claude Code

Status readers could not tell a 5-hour or weekly reading taken a minute
ago from one restored off disk after a week idle. Each account's quota
now carries unified5hSeenAt and unified7dSeenAt beside the values in
/teamclaude/status, for Claude and Codex accounts.

A stamp moves only with its own window's value: a response header or
usage probe that states that window's utilization sets it. An empty
payload, a failed probe, a reset time alone, or a model-scoped weekly
bucket leaves it alone. A window that resets clears its stamp. The
stamps persist across a restart; a value restored from an older state
file keeps a null stamp until upstream states the window again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 25, 2026 15:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Shared quota values and timestamps can still be overwritten on reset-only or missing-utilization usage payloads.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds persisted timestamps for the last observed shared Claude and Codex quota windows, exposed through status output.

Changes:

  • Tracks and clears unified5hSeenAt and unified7dSeenAt.
  • Persists timestamps across restarts.
  • Adds tests and usage documentation.
File Summary
src/​account-manager.js Implements timestamp tracking and persistence; requires guarding value/stamp writes when utilization is absent or unparseable.
test/​quota-seen-at.test.js Covers provider updates, resets, persistence, and status output.
docs/​usage.md Documents the new status fields.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/account-manager.js
Comment on lines 3920 to 3924
if (usage.fiveHour) {
q.unified5h = usage.fiveHour.utilization;
q.unified5hReset = usage.fiveHour.resetAt ?? null;
q.unified5hSeenAt = Date.now();
}
@MagicalTux
MagicalTux merged commit 0dabc76 into KarpelesLab:master Sep 29, 2026
5 checks passed
@MagicalTux MagicalTux mentioned this pull request Sep 29, 2026
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
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.

3 participants