@timo972/cc-router 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,112 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - Automatic upstream failover and retry on both providers. A 429 or 5xx
14
+ received before any response byte is relayed no longer passes straight
15
+ through to the client: the router applies the existing cooldown/affinity
16
+ bookkeeping and retries the request itself, up to 3 upstream attempts
17
+ per request. A 429 (or an overload the provider cools down: Anthropic
18
+ 529; Codex 503/529) always fails over to a *different* account; any
19
+ other 5xx keeps a session-bound request on its own account, retrying
20
+ after a short pause, while a session-less request re-routes the way a
21
+ fresh request would.
22
+ Covers Claude `/v1/messages`, Codex `/v1/responses`, and cross-routed
23
+ `/v1/messages`. When nothing is eligible or the budget is exhausted, the
24
+ last failed upstream response is relayed unchanged, exactly as before;
25
+ 401s still pass through with a background token refresh, and the router
26
+ still never retries after response bytes have started. Failed attempts
27
+ show up in the activity log with a `:will-retry` suffix. On by
28
+ default — set `"autoFailover": false` in `~/.cc-router/config.json`
29
+ (restart required) to opt out and restore pure pass-through behavior.
30
+ Note that current Claude Code builds no longer retry 429s themselves,
31
+ so with failover off a rate limit surfaces directly in the session.
32
+
33
+ ### Changed
34
+
35
+ - Claude-bound POST `/v1/messages` moved from the generic proxy middleware
36
+ to a dedicated transport (same byte-transparent relay contract: verbatim
37
+ status/headers, raw body bytes, no synthesized events) so the router can
38
+ decide to retry at upstream response headers. Every other `/v1` endpoint
39
+ stays on the generic proxy. Claude activity rows now record the requested
40
+ model and the full `/v1/messages` path.
41
+
42
+ ### Fixed
43
+
44
+ - An account whose quota refills early — upgrading a Claude plan being the
45
+ common case — is returned to rotation as soon as the usage endpoint says
46
+ so, instead of staying benched for the rest of the pre-upgrade window. A
47
+ 429 records a cooldown whose expiry comes from the reset timestamps on
48
+ that response, and both that cooldown and the header-derived
49
+ `rate_limited` flag were released only by the wall clock. Nothing
50
+ reconnected them to the usage refresher, so a plan upgrade produced an
51
+ account reporting `0%` on every window, `usage fresh`, and `busy` with a
52
+ multi-hour cooldown — and because it was benched, no new response could
53
+ ever arrive to correct it. In the reported case one stale cooldown on the
54
+ only account with capacity left the whole pool answering
55
+ `429 no-eligible`.
56
+
57
+ A usage snapshot now supersedes both blockers, under two conditions that
58
+ keep it from unbenching an account that is still limited.
59
+
60
+ The refresh must have been *initiated* after the block was recorded.
61
+ `fetchedAt` cannot answer that — it is stamped after the response body is
62
+ parsed, so a refresh already on the wire when a 429 lands completes
63
+ afterwards while describing the account as it was before. Neither can
64
+ wall-clock milliseconds: the 429, the headers taken from it, and the
65
+ refresh the router starts in response all happen in one event-loop turn
66
+ and read the same millisecond (measured at 199 ties in 200 runs), which
67
+ would have made that immediate refresh useless. Ordering now runs on a
68
+ process-wide monotonic sequence, with tokens on the usage snapshot, the
69
+ header snapshot, and each cooldown entry.
70
+
71
+ And the snapshot must report on the scope that caused the block: only the
72
+ claimed window releases a global cooldown, and only the matching family
73
+ releases a model cooldown. Blocks for limits no snapshot describes — an
74
+ upstream 529 overload, the `seven_day_oauth_apps` quota, an unattributed
75
+ claim — stay purely time-based. Cooldowns are grouped by scope — global by
76
+ limiting window, model by family — and within each scope every expiry keeps
77
+ the sequence of the event that produced it, so overlapping blocks neither
78
+ merge nor cancel each other: releasing a quota cooldown leaves a concurrent
79
+ overload running, a brief overload does not make a multi-hour cooldown
80
+ permanent, and a later shorter 429 cannot revive an expiry a refresh had
81
+ already retired.
82
+
83
+ Relatedly, a usage window with no usable figure — `five_hour: {}`, a
84
+ non-numeric utilization — no longer parses as `0`. It now arrives with the
85
+ figure absent, so missing data can never read as proof of capacity and
86
+ retire a live cooldown. Rolling a spent window over past its reset likewise
87
+ clears the reading instead of writing a `0` nobody reported. Blocking
88
+ decisions still treat both as `0`, and the dashboard still displays `0`,
89
+ exactly as before.
90
+
91
+ Which of the response headers and the usage snapshot describes an account's
92
+ current capacity is now decided on the same event order, rather than on
93
+ `fetchedAt` against `lastUpdated`. A refresh that starts before a response
94
+ and finishes after it holds the older picture despite the later clock
95
+ reading, and preferring it hid a fresher exhaustion signal behind a snapshot
96
+ that never saw it — while cooldown release, already running on the event
97
+ order, disagreed about which source was current. Snapshots predating the
98
+ ordering tokens still fall back to the timestamp comparison.
99
+
100
+ Within scope, releasing opens no hole: the same snapshot feeds the
101
+ exhausted-window check, so an account with no real capacity stays blocked
102
+ on its own merits.
103
+
104
+ - The activity log's "cooldown expired — rate limit cleared" entry now marks
105
+ the moment an account is actually routable again. It hung off the
106
+ header `rate_limited` flag alone, which both over- and under-reports as soon
107
+ as anything else can block the account: a 429 overlapping a 529 announced
108
+ recovery while the overload cooldown still kept the account out of rotation,
109
+ and because that flag only flips once, the moment it genuinely came back
110
+ passed unannounced. The entry is now emitted when the last account-wide
111
+ blocker clears — header status, global cooldown, or a spent account-wide
112
+ window — whichever that turns out to be, including a cooldown that lapses
113
+ during an idle stretch with nothing routing or polling in the meantime. A
114
+ model-scoped limit never emits one, since the account kept serving every
115
+ other family and so never left the rotation to rejoin.
116
+
11
117
  ---
12
118
 
13
119
  ## [0.10.1] — 2026-08-20
package/README.md CHANGED
@@ -23,6 +23,7 @@ Distribute Claude Code requests across Claude subscriptions, and expose an OpenA
23
23
  - **Codex CLI support** — configure Codex to use CC-Router as a Responses-compatible provider
24
24
  - **Automatic token refresh** — OAuth tokens are refreshed before they expire, saved atomically to disk
25
25
  - **Model-aware rate limits** — avoids accounts whose requested-model or global allowance is exhausted, and respects scoped cooldowns
26
+ - **Automatic failover & retry** — a 429 fails over to another account and a 5xx is retried inside the router, before any response byte is relayed, on both the Claude and Codex routes; on by default, opt out with `"autoFailover": false`
26
27
  - **Client mode** — connect another device you own to your private CC-Router (`cc-router client connect <url>`)
27
28
  - **Claude Desktop support** — route Cowork / Agent-mode traffic through CC-Router via mitmproxy interception (macOS, Windows, Linux)
28
29
  - **Guided setup wizard** — interactive `cc-router setup` extracts tokens from Keychain or credentials file, configures everything
@@ -75,7 +76,7 @@ CC-Router keeps requests from one Claude Code session on the same eligible Anthr
75
76
 
76
77
  Anthropic cooldowns, effective global or requested-model quota exhaustion, disabled accounts, invalid authentication, and unhealthy accounts are hard exclusions. The configured per-account percentage caps are softer policy controls: when at least one account is otherwise usable but every usable account is over a configured cap, CC-Router may explicitly fall back to the least-loaded capped account. It never uses that fallback to bypass an Anthropic cooldown or exhausted effective quota.
77
78
 
78
- If an upstream account returns 401, 429, or 529, CC-Router passes that response through unchanged and invalidates the session's affinity. The client's next retry can then select another usable account; the router never retries after response bytes have started. If no account is usable before forwarding begins, the router instead returns a local Anthropic-shaped 429 whenever any account is blocked by a rate limit or exhausted quota. That 429 includes `Retry-After` only when a trustworthy unblock time is known. A local 503 is reserved for entirely non-rate-limit unavailability, such as all accounts being disabled or unhealthy. Either local response makes no Anthropic Messages request. Affinity mappings exist only in process memory, expire after one hour of inactivity, and are capped in size. Session IDs are never persisted or logged.
79
+ If an upstream account returns 429 or any 5xx before a single response byte has been relayed, CC-Router applies the failure's cooldown and affinity bookkeeping and then retries the request itself, up to 3 upstream attempts per request. A 429 (or an overload the provider cools down: Anthropic 529; Codex 503/529) always fails over to a *different* account. Any other 5xx keeps a session-bound request on its own account, retrying after a short pause; a session-less request re-routes the way a fresh request would — typically an idle other account, exactly where the client's own retry used to land. The failover is on by default; set `"autoFailover": false` in `~/.cc-router/config.json` (and restart the router) to opt out — every upstream failure then passes through unchanged and clients own all retries, as before. Be aware that current Claude Code builds no longer retry 429s themselves, so with failover off a rate limit surfaces directly in the session as an error. One trade-off worth knowing: once the router commits to a retry it abandons the original failure response, so a network error on the retry attempt surfaces as a local 502 rather than the original 429. When no other account is eligible or the budget is exhausted, the last failed upstream response is passed through unchanged, exactly as before. A 401 is always passed through (with a background token refresh), and the router never retries after response bytes have started — mid-stream failures reach the client untouched. If no account is usable before forwarding begins, the router instead returns a local Anthropic-shaped 429 whenever any account is blocked by a rate limit or exhausted quota. That 429 includes `Retry-After` only when a trustworthy unblock time is known. A local 503 is reserved for entirely non-rate-limit unavailability, such as all accounts being disabled or unhealthy. Either local response makes no Anthropic Messages request. Affinity mappings exist only in process memory, expire after one hour of inactivity, and are capped in size. Session IDs are never persisted or logged.
79
80
 
80
81
  Streaming remains byte-transparent. In particular, CC-Router never appends a synthetic `message_stop` event. `proxyRequestTimeoutMs` protects only the phase before Anthropic response headers arrive; once a response starts, its body continues through the native byte-exact proxy pipe. Automatic `cc-router configure` setup manages Claude Code's event-level and byte-level stream idle watchdogs at 30 minutes. Restart any existing Claude Code process after configuration so it inherits those values.
81
82
 
@@ -92,8 +93,8 @@ Claude Max has rate limits per account. If you hit them regularly mid-session
92
93
  With two accounts you double your effective rate limit. With three, you triple it. The proxy distributes requests automatically; you don't change how you use Claude Code at all.
93
94
 
94
95
  ```text
95
- 1 account → hit limit, wait 60s, continue
96
- 3 accounts → new sessions spread across all three; each session stays cache-local
96
+ 1 account → hit limit, session errors out (current Claude Code no longer retries 429s)
97
+ 3 accounts → sessions spread across all three; a rate-limited request fails over mid-flight
97
98
  ```
98
99
 
99
100
  ---
@@ -232,6 +232,15 @@ export function getProxyRequestTimeoutMs() {
232
232
  ? timeoutMs
233
233
  : DEFAULT_PROXY_REQUEST_TIMEOUT_MS;
234
234
  }
235
+ /**
236
+ * Whether the router may retry upstream 429/5xx failures itself. Enabled
237
+ * unless the config explicitly says `"autoFailover": false` — a missing or
238
+ * malformed value keeps the default on, matching how the other optional
239
+ * proxy settings degrade.
240
+ */
241
+ export function getAutoFailoverEnabled() {
242
+ return readConfig().autoFailover !== false;
243
+ }
235
244
  function normalizeProxyConfig(cfg) {
236
245
  const { proxyRequesTime, ...normalized } = cfg;
237
246
  const timeoutMs = normalized.proxyRequestTimeoutMs ?? proxyRequesTime;
@@ -0,0 +1,44 @@
1
+ import { nextEventSequence } from "../../proxy/event-sequence.js";
2
+ /**
3
+ * Rate-limit extraction from Anthropic's unified response headers. Lives
4
+ * apart from the server so both Anthropic transports (the generic /v1 proxy
5
+ * and the retrying /v1/messages route) capture the same snapshot.
6
+ */
7
+ function inferPlan(requestsLimit) {
8
+ if (requestsLimit <= 0)
9
+ return "";
10
+ if (requestsLimit <= 100)
11
+ return "Pro";
12
+ if (requestsLimit <= 500)
13
+ return "Max 5x";
14
+ return "Max 20x";
15
+ }
16
+ function extractRateLimits(headers) {
17
+ const h = (name) => String(headers[name] ?? "");
18
+ const status = h("anthropic-ratelimit-unified-status");
19
+ if (!status)
20
+ return null; // No unified headers in this response
21
+ const requestsLimit = parseInt(h("anthropic-ratelimit-requests-limit"), 10) || 0;
22
+ return {
23
+ status: status === "rate_limited" ? "rate_limited" : "allowed",
24
+ fiveHourUtil: parseFloat(h("anthropic-ratelimit-unified-5h-utilization")) || 0,
25
+ fiveHourReset: parseInt(h("anthropic-ratelimit-unified-5h-reset"), 10) || 0,
26
+ sevenDayUtil: parseFloat(h("anthropic-ratelimit-unified-7d-utilization")) || 0,
27
+ sevenDayReset: parseInt(h("anthropic-ratelimit-unified-7d-reset"), 10) || 0,
28
+ claim: h("anthropic-ratelimit-unified-representative-claim"),
29
+ plan: inferPlan(requestsLimit),
30
+ requestsLimit,
31
+ lastUpdated: Date.now(),
32
+ // Wall-clock ms ties with the usage refresh the router starts from this
33
+ // same response, so the ordering token is what makes them comparable.
34
+ lastUpdatedSeq: nextEventSequence(),
35
+ };
36
+ }
37
+ /** Apply upstream rate-limit headers without discarding the usage snapshot. */
38
+ export function applyRateLimitHeaders(account, headers) {
39
+ const rateLimits = extractRateLimits(headers);
40
+ if (!rateLimits)
41
+ return false;
42
+ account.rateLimits = { ...account.rateLimits, ...rateLimits };
43
+ return true;
44
+ }
@@ -1,3 +1,4 @@
1
+ import { nextEventSequence } from "../../proxy/event-sequence.js";
1
2
  const ANTHROPIC_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
2
3
  const OAUTH_BETA_HEADER = "oauth-2025-04-20";
3
4
  const DEFAULT_USAGE_TIMEOUT_MS = 5_000;
@@ -18,10 +19,20 @@ function stringValue(value) {
18
19
  function numberValue(value) {
19
20
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
20
21
  }
22
+ /**
23
+ * Normalize a reported percentage into a 0–1 fraction, or undefined when the
24
+ * provider reported nothing usable.
25
+ *
26
+ * The distinction matters downstream: a *reported* 0 is proof of capacity and
27
+ * can retire a cooldown, while a missing or non-numeric figure is only absence
28
+ * of information. Collapsing the two to 0 would let a malformed payload —
29
+ * `five_hour: {}`, `utilization: null` — unbench an account that is still
30
+ * being rate limited.
31
+ */
21
32
  function utilization(value) {
22
33
  const number = numberValue(value);
23
34
  if (number === undefined)
24
- return 0;
35
+ return undefined;
25
36
  return Math.max(0, Math.min(1, number / 100));
26
37
  }
27
38
  function resetAt(value) {
@@ -43,8 +54,9 @@ function getFirst(record, keys) {
43
54
  function parseWindow(value) {
44
55
  if (!isRecord(value))
45
56
  return undefined;
57
+ const reported = utilization(getFirst(value, ["utilization", "percentage", "percent"]));
46
58
  return {
47
- utilization: utilization(getFirst(value, ["utilization", "percentage", "percent"])),
59
+ ...(reported === undefined ? {} : { utilization: reported }),
48
60
  resetAt: resetAt(getFirst(value, ["resets_at", "reset_at", "resetAt"])),
49
61
  };
50
62
  }
@@ -88,11 +100,12 @@ function parseModelLimit(value) {
88
100
  if (!model)
89
101
  return undefined;
90
102
  const active = getFirst(value, ["active", "is_active"]);
103
+ const reported = utilization(getFirst(value, ["utilization", "percentage", "percent"]));
91
104
  return {
92
105
  kind: "weekly_scoped",
93
106
  group: stringValue(value.group) ?? "weekly",
94
107
  ...model,
95
- utilization: utilization(getFirst(value, ["utilization", "percentage", "percent"])),
108
+ ...(reported === undefined ? {} : { utilization: reported }),
96
109
  resetAt: resetAt(getFirst(value, ["resets_at", "reset_at", "resetAt"])),
97
110
  active: typeof active === "boolean" ? active : true,
98
111
  severity: stringValue(value.severity) ?? "",
@@ -138,7 +151,7 @@ function legacyModelLimit(family, value) {
138
151
  };
139
152
  }
140
153
  /** Parse the OAuth usage endpoint without retaining its provider-specific payload. */
141
- export function parseAnthropicUsage(value, fetchedAt) {
154
+ export function parseAnthropicUsage(value, fetchedAt, requestedSeq) {
142
155
  if (!isRecord(value) || !Object.keys(value).some((key) => USAGE_FIELDS.has(key)))
143
156
  return null;
144
157
  const limits = Array.isArray(value.limits) ? value.limits : undefined;
@@ -153,6 +166,8 @@ export function parseAnthropicUsage(value, fetchedAt) {
153
166
  fetchedAt,
154
167
  fetchStatus: "fresh",
155
168
  };
169
+ if (requestedSeq !== undefined)
170
+ snapshot.requestedSeq = requestedSeq;
156
171
  const fiveHour = parseWindow(value.five_hour);
157
172
  const sevenDay = parseWindow(value.seven_day);
158
173
  const extraUsage = parseExtraUsage(value.extra_usage);
@@ -183,6 +198,10 @@ export async function fetchAnthropicUsage(account, options = {}) {
183
198
  const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_USAGE_TIMEOUT_MS);
184
199
  const controller = new AbortController();
185
200
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
201
+ // Claimed before the request goes out: the response describes the account no
202
+ // earlier than this point in the event order, which is what lets a caller
203
+ // order the snapshot against events that happened while it was in flight.
204
+ const requestedSeq = options.nextSequence?.() ?? nextEventSequence();
186
205
  try {
187
206
  const response = await request(ANTHROPIC_USAGE_ENDPOINT, {
188
207
  method: "GET",
@@ -201,7 +220,7 @@ export async function fetchAnthropicUsage(account, options = {}) {
201
220
  catch {
202
221
  return { ok: false, reason: "invalid_json" };
203
222
  }
204
- const snapshot = parseAnthropicUsage(body, now());
223
+ const snapshot = parseAnthropicUsage(body, now(), requestedSeq);
205
224
  return snapshot
206
225
  ? { ok: true, snapshot }
207
226
  : { ok: false, reason: "invalid_schema" };