@timo972/cc-router 0.10.0 → 0.11.0-rc.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 +155 -33
- package/README.md +4 -3
- package/dist/config/manager.js +9 -0
- package/dist/providers/anthropic/rate-limit-headers.js +44 -0
- package/dist/providers/anthropic/usage.js +24 -5
- package/dist/proxy/anthropic-messages-route.js +456 -0
- package/dist/proxy/anthropic-response-capture.js +40 -0
- package/dist/proxy/event-sequence.js +18 -0
- package/dist/proxy/lease-lifecycle.js +20 -13
- package/dist/proxy/messages-cross-route.js +7 -0
- package/dist/proxy/openai-ingress.js +218 -109
- package/dist/proxy/responses-server.js +7 -0
- package/dist/proxy/server.js +100 -109
- package/dist/proxy/stats.js +19 -0
- package/dist/proxy/token-pool.js +302 -37
- package/dist/proxy/upstream-retry.js +87 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,138 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
10
10
|
|
|
11
11
|
### Added
|
|
12
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
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## [0.10.1] — 2026-08-20
|
|
120
|
+
|
|
121
|
+
### Fixed
|
|
122
|
+
|
|
123
|
+
- A passed-through upstream 5xx on the Anthropic path is logged and counted.
|
|
124
|
+
The proxy is byte-transparent and only special-cased 401/429/529, so a
|
|
125
|
+
plain upstream 500 left no trace: an overnight Anthropic 500 stopped an
|
|
126
|
+
unattended Claude session while the daemon log showed nothing and the
|
|
127
|
+
stats reported a clean night. It now produces an `[ERROR]` line, an
|
|
128
|
+
activity entry (`upstream-error`), and error counts — with no cooldown,
|
|
129
|
+
since a plain 5xx says nothing about the account's capacity and can even
|
|
130
|
+
be request-specific.
|
|
131
|
+
- A client-cancelled stream abort is no longer logged as an error on the
|
|
132
|
+
OpenAI path. The Codex CLI aborts streams routinely, and each abort
|
|
133
|
+
rejects the relay's body read — the log printed an `[ERROR] ... relay
|
|
134
|
+
failed` line for every one (eight hours of them in one overnight
|
|
135
|
+
session) while the stats correctly classified them as cancellations. The
|
|
136
|
+
log line now waits for the cancellation check, so it fires only for real
|
|
137
|
+
relay failures.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## [0.10.0] — 2026-08-19
|
|
142
|
+
|
|
143
|
+
### Added
|
|
144
|
+
|
|
13
145
|
- OpenAI/Codex account usage is fetched proactively, so `cc-router status`
|
|
14
146
|
shows the 5h/weekly bars immediately after a restart — matching how
|
|
15
147
|
Anthropic accounts already behaved. Codex usage previously arrived only on
|
|
@@ -52,6 +184,29 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
52
184
|
one row when the selection steps past its bottom or top edge, stays put while
|
|
53
185
|
the selection moves inside it, and re-clamps when new entries push the
|
|
54
186
|
selected row (which is timestamp-anchored) out of the stored window.
|
|
187
|
+
- OpenAI/Codex sticky session routing: sessions pin to one account for prompt-cache
|
|
188
|
+
locality (`session_id` → `x-claude-code-session-id` → `prompt_cache_key`), with
|
|
189
|
+
load- and headroom-aware selection for new sessions.
|
|
190
|
+
- Codex usage tracking from `x-codex-*` response headers: default 5h/weekly windows
|
|
191
|
+
plus dynamically discovered model-scoped metered buckets, credits, and plan.
|
|
192
|
+
- Scoped cooldowns on upstream failures: bucket-scoped via `x-codex-active-limit`,
|
|
193
|
+
account-global otherwise; local 429/503 responses when no account is eligible.
|
|
194
|
+
- Dashboard: OpenAI accounts now show 5h/weekly bars, per-bucket rows, credits,
|
|
195
|
+
plan, request/error/in-flight/session counts, and cooldown state.
|
|
196
|
+
- Unprefixed `gpt-*` models route to OpenAI. The Codex CLI writes the bare slug
|
|
197
|
+
from its own registry — `model = "gpt-5.6-sol"` in `config.toml`, or whatever
|
|
198
|
+
its `/model` picker selects — and an unprefixed name went to the Claude path,
|
|
199
|
+
where `/v1/responses` answers `501 Not Implemented`. No configuration could
|
|
200
|
+
redirect it, because `openAIAliases` is only consulted for names that are
|
|
201
|
+
already prefixed; those aliases now apply to the bare form as well. Every
|
|
202
|
+
other unprefixed model still routes to Claude.
|
|
203
|
+
|
|
204
|
+
### Changed
|
|
205
|
+
|
|
206
|
+
- OpenAI account records persist `scopes`, `sessionLimitPercent`, and
|
|
207
|
+
`weeklyLimitPercent`.
|
|
208
|
+
- The stateless OpenAI round-robin picker was removed in favor of
|
|
209
|
+
`OpenAITokenPool`.
|
|
55
210
|
|
|
56
211
|
### Fixed
|
|
57
212
|
|
|
@@ -143,39 +298,6 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
143
298
|
on `CC_ROUTER_DAEMON`, which the LaunchAgent and systemd unit never set —
|
|
144
299
|
they set `CC_ROUTER_SERVICE` — so every service-managed instance left no PID
|
|
145
300
|
behind and took the weaker port-based stop path.
|
|
146
|
-
|
|
147
|
-
---
|
|
148
|
-
|
|
149
|
-
## [0.10.0] — 2026-08-18
|
|
150
|
-
|
|
151
|
-
### Added
|
|
152
|
-
|
|
153
|
-
- OpenAI/Codex sticky session routing: sessions pin to one account for prompt-cache
|
|
154
|
-
locality (`session_id` → `x-claude-code-session-id` → `prompt_cache_key`), with
|
|
155
|
-
load- and headroom-aware selection for new sessions.
|
|
156
|
-
- Codex usage tracking from `x-codex-*` response headers: default 5h/weekly windows
|
|
157
|
-
plus dynamically discovered model-scoped metered buckets, credits, and plan.
|
|
158
|
-
- Scoped cooldowns on upstream failures: bucket-scoped via `x-codex-active-limit`,
|
|
159
|
-
account-global otherwise; local 429/503 responses when no account is eligible.
|
|
160
|
-
- Dashboard: OpenAI accounts now show 5h/weekly bars, per-bucket rows, credits,
|
|
161
|
-
plan, request/error/in-flight/session counts, and cooldown state.
|
|
162
|
-
- Unprefixed `gpt-*` models route to OpenAI. The Codex CLI writes the bare slug
|
|
163
|
-
from its own registry — `model = "gpt-5.6-sol"` in `config.toml`, or whatever
|
|
164
|
-
its `/model` picker selects — and an unprefixed name went to the Claude path,
|
|
165
|
-
where `/v1/responses` answers `501 Not Implemented`. No configuration could
|
|
166
|
-
redirect it, because `openAIAliases` is only consulted for names that are
|
|
167
|
-
already prefixed; those aliases now apply to the bare form as well. Every
|
|
168
|
-
other unprefixed model still routes to Claude.
|
|
169
|
-
|
|
170
|
-
### Changed
|
|
171
|
-
|
|
172
|
-
- OpenAI account records persist `scopes`, `sessionLimitPercent`, and
|
|
173
|
-
`weeklyLimitPercent`.
|
|
174
|
-
- The stateless OpenAI round-robin picker was removed in favor of
|
|
175
|
-
`OpenAITokenPool`.
|
|
176
|
-
|
|
177
|
-
### Fixed
|
|
178
|
-
|
|
179
301
|
- A refresh token the OAuth server rejects as terminally expired
|
|
180
302
|
(`400 invalid_grant`) is no longer retried forever. Every rejection was
|
|
181
303
|
treated as transient, so the five-minute refresh loop kept re-POSTing a token
|
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
|
|
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,
|
|
96
|
-
3 accounts →
|
|
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
|
---
|
package/dist/config/manager.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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" };
|