Skip to content

feat(server): opt-in stripOverageHeaders drops per-org billing headers - #478

Merged
MagicalTux merged 4 commits into
KarpelesLab:masterfrom
devdotbo:upstream/strip-overage-headers
Sep 29, 2026
Merged

MagicalTux merged 4 commits into
KarpelesLab:masterfrom
devdotbo:upstream/strip-overage-headers

Conversation

@devdotbo

@devdotbo devdotbo commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #476. Builds on #477, whose commit comes first here until it lands: with the flag on, a refused handshake left with only overage headers hits the empty-head case it fixes.

The gap

Responses carry the billing state of the organization that served them (anthropic-ratelimit-unified-overage-* and anthropic-ratelimit-unified-upgrade-paths). On Claude Code 2.1.283, the disabled-reason header updates the client's cached billing reason. In a pool spanning several organizations, a cached org_level_disabled from another org keeps the Fable 5.1 consent dialog up with plan quota left. Reading the client code shows that the dialog's re-enable action calls billing endpoints for the client's own organization; that action was not exercised. Observed behavior, client-code findings and proposed two-org reproduction steps are in #476.

What changed

  • stripOverageHeaders, default false, in src/config.js, config.example.json and docs/configuration.md.
  • src/server.js: isOverageHeader(name) (the overage- prefix plus upgrade-paths, case-insensitive) and shouldStripOverageHeaders(config) (=== true). The filter runs on the client-bound copy only, in forwardRequest (after updateQuota, which still sees the originals), relayStream, relayRaw (/v1/oauth/token), relayUpgrade (101 and refused handshake) and relayHttpForward (absolute-form relay). Blind CONNECT tunnels stay opaque. Plan-quota headers pass through.
  • The flag is sampled when a request is dispatched, so its retries (including the stream failover of feat(routing): fail over once when a 200 stream reports its own failure #470) and holds keep one value. relayUpgrade reads it from its options object, forwardRequest from the request context, the other three from an optional trailing argument that defaults to false.
  • src/mitm.js passes it to relayUpgrade. src/index.js copies it on reload (=== true), so POST /teamclaude/reload applies an edit and a removed key restores false.

With the key unset or false, responses are unchanged apart from the CRLF fix.

Tests

test/overage-headers.test.js, 20 tests. isOverageHeader matches the overage family and upgrade-paths and no plan-quota header. With the flag on, the headers are gone on /v1/messages (with updateQuota booking from the originals), relayStream, relayRaw, relayUpgrade directly, the main listener's upgrade path (101 and 403), the MITM upgrade path through a terminated CONNECT tunnel (101) and the absolute-form relay, plus exact bytes for a refused handshake whose headers all drop. Passthrough is checked across those relay paths using unset or false configurations. A stream failover test changes the flag while the first attempt is in flight: the hop keeps the value the request was dispatched with, and the next request sees the new one. The absolute-form and MITM tests use allowLoopbackForward(proxy), as test/http-forward-proxy.test.js does.

test/reload-strip-overage-headers.test.js, 1 test: the real server as a subprocess, as in test/reload-event-logging-blocklist.test.js. It edits the key through false, true, false, true and removed, reloads after each edit and checks the next /v1/messages response.

The CI checks pass locally: npm test (2542 passing on Node 20.19.6, 22.21.1 and 24.12.0), npm run lint, npm run typecheck, and npm run typecheck:strict -- --base master (1703 strict diagnostics, same as master).

Not covered: the MITM HTTP/2 path and a refused handshake on the MITM listener; relayStream with an explicit false and the absolute-form relay unset; a request in flight across a POST /teamclaude/reload (the failover test changes the shared config object directly); byte equivalence with the flag off outside the two exact-byte handshake cases (the rest compare header values).

Left alone

  • forwardRequest still writes the full upstream header set, stripped names included, to the per-request log when request logging is on.
  • The comment above notifyRunningServer in src/index.js still lists only eventLogging and blockedModels among the settings reload picks up.
  • teamclaude status does not show the flag.
  • It filters headers only: no org's billing settings change, no spend is capped, no model entitlement is granted, and client state cached before the flag was turned on is not cleared. On 2.1.283 the cached reason went to null after ordinary traffic, because the client caches a missing header as null; other client versions may differ.

Default

false. When the serving organization's billing state matches the client's organization, stripping hides legitimate billing information. Mixed-org operators opt in with "stripOverageHeaders": true and a reload. If you would rather strip by default, I can switch it.

devdotbo and others added 4 commits September 27, 2026 16:11
When upstream refuses a WebSocket handshake with a plain response,
relayUpgrade writes the refusal head to the client by hand. It joined
the surviving header lines into one string and wrapped it in fixed
CRLFs, so an empty header set produced a malformed head. On a refusal
whose only headers are Connection and Content-Length (both filtered)
the client got

  HTTP/1.1 403 Forbidden\r\n\r\nConnection: close\r\n\r\n

an empty head followed by "Connection: close" as body bytes.

The refusal writer now builds the head from a line array (status line,
header lines, Connection: close) joined with CRLF and ended by a single
empty line. The 101 writer is changed to the same serialization for
consistency. Its empty case is latent: Node only emits 'upgrade' when
the response carries Upgrade and Connection, so that writer always has
header lines today, and only a future transformation that removed every
header would expose it.

Tests assert the exact bytes for a refusal whose headers all drop and
for a 101, reading until the proxy closes the client socket.
Anthropic responses carry the serving organization's billing state in
anthropic-ratelimit-unified-overage-* and
anthropic-ratelimit-unified-upgrade-paths: whether extra usage is
enabled, why it is not, whether it is in use, and what the org could
upgrade to. In a pool whose accounts belong to several organizations,
each response carries the headers of whichever org served it, and
Claude Code caches them as its own org's state. One response from an
org with extra usage disabled can make the client report "no usage
credits", block a model the pool still has plan quota for, or show a
consent dialog offering to enable paid overage on the wrong org.

`stripOverageHeaders: true` removes that family from every upstream
response whose HTTP headers TeamClaude parses: forwardRequest, the
client-credential relay (relayStream), the token relay (relayRaw), the
WebSocket handshake (relayUpgrade, both the 101 and a refused
handshake, on the main listener and the MITM listener) and the
absolute-form relay (relayHttpForward). Blind CONNECT tunnels and bytes
after a completed upgrade are not parsed and stay untouched. The
plan-quota headers (5h, 7d and 7d_oi status, utilization and reset, the
overall status, representative-claim, fallback) always pass through.
updateQuota receives the unfiltered headers and does not persist the
overage family. The flag is sampled from the live config when each
request is dispatched, so POST /teamclaude/reload applies an edit to
subsequent requests.

Default false: with the key unset nothing changes for existing
deployments. config.example.json and docs/configuration.md list it.

Tests cover each path with the flag on (stripped, plan headers kept,
quota accounting on the originals) and with it unset or false
(forwarded unchanged), the MITM upgrade listener through a terminated
CONNECT tunnel, and a reload subprocess test that flips the key on disk
false, true, false and removes it, asserting the next /v1/messages
response each time.
…ltering

The MITM CONNECT/TLS setup in overage-headers.test.js now rejects on a
premature close, an error or a 10 s timeout and destroys both sockets, and
teardown destroys every accepted socket, since closeAllConnections() skips
sockets handed to an 'upgrade' or 'connect' listener.

The readiness and reload fetches in reload-strip-overage-headers.test.js get
AbortSignal.timeout, as the /v1/messages fetch already had, so a child that
accepts but never answers cannot hold the test past its deadline.

A new regression test flips stripOverageHeaders while the first attempt is
in flight and triggers the in-stream failover hop: the hop keeps the value
the request was dispatched with, and the next request sees the new one.

The relayRaw comment no longer says "no header rewriting": it names the
dropped connection-specific and framing headers and the optional billing
header filter.
@MagicalTux
MagicalTux merged commit b5be7fa 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
lifrary added a commit to lifrary/teamclaude that referenced this pull request Sep 29, 2026
Takes KarpelesLab/teamclaude up to ba01b4f while keeping the fork's routing
safeguards (403 cooldown without parking, send-failure fail-over, outbound
content-length, the single predispatch wait budget, cappedMessage, the
overload slot release, the dead-refresh-token guard and refresh retry, the
session home wait from d7810f1, and unranked-priority semantics).

Adopted from upstream, among others: per-conversation session pins (KarpelesLab#438),
401 fail-over without parking (KarpelesLab#439, KarpelesLab#473), real quota-reset retry-after and
candidate counts (KarpelesLab#429, KarpelesLab#408), a headerless 429 retry (KarpelesLab#431), fail-over on a
failing 200 stream (KarpelesLab#470), per-account egress proxies (KarpelesLab#441), extra-usage
fallback (KarpelesLab#427), maxSpend (KarpelesLab#466), stripOverageHeaders (KarpelesLab#478), keep-alive that
outlives the client pool (KarpelesLab#411), a dead terminal not killing the proxy
(KarpelesLab#410), console resolution per call (KarpelesLab#432), config reload and sync fixes
(KarpelesLab#465, KarpelesLab#415), and the dashboard, TUI and Codex work since 1.1.20.

Integration fixes the merge needed beyond conflict hunks:
- upstream request-path code that referenced upstream-only locals (sx,
  route, ctx.tried) rewritten for the fork's forwardRequest
- resolveSwitchThreshold was declared twice after a clean auto-merge
- the fork's warm-up probe now forwards a routed account through its own
  proxy instead of sending its credential direct (new regression test)
- a routing failure during a token refresh arms the routing hold instead of
  parking the account; a pinned request may use an account on routing hold
- the headerless-429 branch no longer writes after headers were sent
- canonical-state allowlists widened for upstream's new quota fields; saves
  use exportState()
- the fork's home wait keys on the conversation pin like selection does

Tests adapted where the fork deliberately differs (warm-up probe on by
default, account-anchored TUI cursor, coordinator-built Prober and Warmer,
unranked priority, per-conversation pin keys, fail-over-only 401, the wait
budget instead of inline waits), each with an in-file note. Full suite
2919/2919, lint, typecheck and the strict ratchet (1411 vs 1465) pass.

Co-Authored-By: Claude Opus 5.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.

Overage headers from another org's account reach the client, so a mixed-org pool triggers Claude Code's extra-usage dialog with quota left

2 participants