@timo972/cc-router 0.9.0 → 0.10.0-rc.1
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 +125 -0
- package/README.md +13 -2
- package/dist/cli/cmd-accounts.js +64 -6
- package/dist/config/manager.js +22 -5
- package/dist/protocol/model-ref.js +21 -2
- package/dist/protocol/openai-response-to-anthropic.js +16 -1
- package/dist/protocol/openai-responses-collect.js +190 -11
- package/dist/protocol/openai-stream-to-anthropic.js +20 -2
- package/dist/protocol/sse.js +10 -1
- package/dist/providers/openai/account-state.js +377 -0
- package/dist/providers/openai/codex-transport.js +8 -0
- package/dist/providers/openai/failure-routing.js +141 -0
- package/dist/providers/openai/token-pool.js +401 -0
- package/dist/providers/openai/token-refresher.js +144 -11
- package/dist/providers/openai/usage.js +160 -0
- package/dist/proxy/account-add.js +10 -7
- package/dist/proxy/account-deletion.js +5 -1
- package/dist/proxy/account-patch.js +64 -0
- package/dist/proxy/account-pool.js +19 -0
- package/dist/proxy/anthropic-routing.js +2 -2
- package/dist/proxy/lease-lifecycle.js +6 -4
- package/dist/proxy/messages-cross-route.js +243 -62
- package/dist/proxy/openai-ingress.js +379 -0
- package/dist/proxy/openai-routing.js +61 -0
- package/dist/proxy/provider-routing.js +8 -4
- package/dist/proxy/responses-server.js +109 -48
- package/dist/proxy/server.js +241 -65
- package/dist/proxy/stats.js +30 -1
- package/dist/proxy/token-pool.js +3 -19
- package/dist/proxy/token-refresher.js +36 -0
- package/dist/ui/Dashboard.js +93 -17
- package/package.json +1 -1
- package/dist/providers/openai/account-pool.js +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,131 @@ Nothing yet.
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## [0.10.0] — 2026-08-18
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- OpenAI/Codex sticky session routing: sessions pin to one account for prompt-cache
|
|
20
|
+
locality (`session_id` → `x-claude-code-session-id` → `prompt_cache_key`), with
|
|
21
|
+
load- and headroom-aware selection for new sessions.
|
|
22
|
+
- Codex usage tracking from `x-codex-*` response headers: default 5h/weekly windows
|
|
23
|
+
plus dynamically discovered model-scoped metered buckets, credits, and plan.
|
|
24
|
+
- Scoped cooldowns on upstream failures: bucket-scoped via `x-codex-active-limit`,
|
|
25
|
+
account-global otherwise; local 429/503 responses when no account is eligible.
|
|
26
|
+
- Dashboard: OpenAI accounts now show 5h/weekly bars, per-bucket rows, credits,
|
|
27
|
+
plan, request/error/in-flight/session counts, and cooldown state.
|
|
28
|
+
- Unprefixed `gpt-*` models route to OpenAI. The Codex CLI writes the bare slug
|
|
29
|
+
from its own registry — `model = "gpt-5.6-sol"` in `config.toml`, or whatever
|
|
30
|
+
its `/model` picker selects — and an unprefixed name went to the Claude path,
|
|
31
|
+
where `/v1/responses` answers `501 Not Implemented`. No configuration could
|
|
32
|
+
redirect it, because `openAIAliases` is only consulted for names that are
|
|
33
|
+
already prefixed; those aliases now apply to the bare form as well. Every
|
|
34
|
+
other unprefixed model still routes to Claude.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- OpenAI account records persist `scopes`, `sessionLimitPercent`, and
|
|
39
|
+
`weeklyLimitPercent`.
|
|
40
|
+
- The stateless OpenAI round-robin picker was removed in favor of
|
|
41
|
+
`OpenAITokenPool`.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- A refresh token the OAuth server rejects as terminally expired
|
|
46
|
+
(`400 invalid_grant`) is no longer retried forever. Every rejection was
|
|
47
|
+
treated as transient, so the five-minute refresh loop kept re-POSTing a token
|
|
48
|
+
that could never succeed — one account issued roughly 2000 futile requests
|
|
49
|
+
over three weeks on the shared Claude Code `client_id`, and nothing marked it
|
|
50
|
+
as needing re-authentication. Such an account is now flagged, dropped from the
|
|
51
|
+
refresh loop, and reported once to the operator; the flag is persisted, so a
|
|
52
|
+
restart neither resumes the futile traffic nor returns the dead account to the
|
|
53
|
+
routing pool, where it would have answered every request with a `401`. Any
|
|
54
|
+
other rejection — a different 400, 401, 429, 5xx, a network error — is still
|
|
55
|
+
retried. Thanks to @ethanhawkes-gif.
|
|
56
|
+
- `accounts list` no longer prints a count that contradicts the rows beneath it.
|
|
57
|
+
The proxy reads `accounts.json` once at startup and holds that snapshot, so
|
|
58
|
+
the two sources diverge the moment anything rewrites the file underneath it;
|
|
59
|
+
the count came from disk while the rows came from the live pool, and a real
|
|
60
|
+
drift surfaced only as "Accounts (4 configured)" above six rows. An account
|
|
61
|
+
could be routing live while its refresh token existed nowhere on disk — one
|
|
62
|
+
restart from needing re-authentication — with nothing to indicate it. The
|
|
63
|
+
count now describes the rows it sits above, and both directions of drift are
|
|
64
|
+
named: accounts missing from disk (a credential-loss risk, with the recovery)
|
|
65
|
+
and accounts on disk the proxy has not loaded (merely stale).
|
|
66
|
+
- `accounts remove` accepts an account that exists only in the running proxy.
|
|
67
|
+
The guard validated the id against disk while the removal it guards prefers
|
|
68
|
+
the live pool — the same pool `list` displays — so an account you could see
|
|
69
|
+
and the code could remove was rejected as "not found". The inventory is now
|
|
70
|
+
the union of both sources.
|
|
71
|
+
- An unexpected failure partway through an OpenAI request — an upstream
|
|
72
|
+
connection error, a rejected token refresh, a mid-stream abort — no longer
|
|
73
|
+
takes down the proxy. Both `/v1/responses` and the `/v1/messages` OpenAI
|
|
74
|
+
branch awaited the upstream call without catching a rejection, so a single
|
|
75
|
+
network blip could kill the daemon and lose every account's routing state.
|
|
76
|
+
- `/v1/messages` no longer reports an upstream OpenAI failure as a success. A
|
|
77
|
+
stream ending in `response.failed`, an `error` event, a JSON error body, or
|
|
78
|
+
no completion event at all — a stream that stopped mid-flight, or an
|
|
79
|
+
event-stream response with no body — was translated into an empty Anthropic
|
|
80
|
+
message with HTTP 200; each now surfaces as an error response, so a rate
|
|
81
|
+
limit reads as a rate limit instead of an empty assistant turn. A non-2xx
|
|
82
|
+
upstream response (401, 429, 5xx) is now relayed with its real status, error
|
|
83
|
+
message, and safe headers — `Retry-After` included, so a client can honor the
|
|
84
|
+
backoff the server asked for — instead of being parsed as an event stream and
|
|
85
|
+
reported as a success or a generic failure; non-2xx Codex responses also keep
|
|
86
|
+
their real content type instead of being rewritten to `text/event-stream`.
|
|
87
|
+
- A terminal event that carries no response object is no longer treated as a
|
|
88
|
+
successful result. `{"type":"response.completed","response":null}` — or any
|
|
89
|
+
other non-object payload — satisfied the completion check, so a
|
|
90
|
+
non-streaming request got HTTP 200 with a `null` body, and `/v1/messages` got
|
|
91
|
+
a fabricated empty assistant turn; a streamed `/v1/messages` turn was closed
|
|
92
|
+
with `message_stop` and `end_turn`, telling the client a truncated answer had
|
|
93
|
+
finished normally. The collected paths now report the `502` that a stream
|
|
94
|
+
ending without a terminal event already did, and the streamed path ends
|
|
95
|
+
without `message_stop`, which is what clients already surface as a
|
|
96
|
+
truncation.
|
|
97
|
+
- A client that disconnects mid-response no longer leaves the upstream Codex
|
|
98
|
+
request running. Nothing propagated the disconnect, so the relay drained the
|
|
99
|
+
whole upstream body into a closed socket and held that connection open for a
|
|
100
|
+
response nobody would receive; the request is now cancelled as soon as the
|
|
101
|
+
client goes away.
|
|
102
|
+
- A single malformed SSE frame no longer truncates a `/v1/messages` stream.
|
|
103
|
+
Parsing a chunk was all-or-nothing, so one bad frame discarded the valid
|
|
104
|
+
events beside it and ended the response as a clean `200` the client could
|
|
105
|
+
not tell apart from a complete answer.
|
|
106
|
+
- OpenAI credentials are written back to the accounts file the proxy was
|
|
107
|
+
started with. Under `--accounts <path>` accounts were read from that file
|
|
108
|
+
but every refresh, add, delete, and update wrote the default
|
|
109
|
+
`accounts.json` — discarding the change and copying OAuth tokens into an
|
|
110
|
+
unrelated file.
|
|
111
|
+
- OpenAI token refresh survives a malformed token response. A payload missing
|
|
112
|
+
`expires_in` produced a `NaN` expiry that read as "never needs refreshing",
|
|
113
|
+
so the account kept presenting a stale token indefinitely — as did a lifetime
|
|
114
|
+
large enough to overflow into an infinite expiry, while a zero or negative
|
|
115
|
+
one reported success on a token that was already due for another refresh.
|
|
116
|
+
Each is now treated as the failed refresh it is; a failure to
|
|
117
|
+
persist rotated credentials no longer fails the request that triggered the
|
|
118
|
+
refresh, and the write is now retried on subsequent requests (and the
|
|
119
|
+
background refresh loop) until it succeeds, so a rotated refresh token
|
|
120
|
+
still reaches disk.
|
|
121
|
+
- `PATCH /cc-router/accounts/:id` works for OpenAI accounts instead of
|
|
122
|
+
returning `404`, so a single OpenAI account can be enabled, disabled, or
|
|
123
|
+
capped without toggling the whole provider. `POST /cc-router/accounts` now
|
|
124
|
+
rejects an out-of-range percentage cap the same way `PATCH` does, rather
|
|
125
|
+
than silently coercing it.
|
|
126
|
+
- A Codex response that ends as `response.incomplete` — e.g. hitting the
|
|
127
|
+
output-token ceiling — is now delivered with its partial content and token
|
|
128
|
+
usage instead of being discarded. `/v1/responses` treated only
|
|
129
|
+
`response.completed` as a terminal event, so `response.incomplete` looked
|
|
130
|
+
identical to a stream that stopped mid-flight and turned a usable partial
|
|
131
|
+
answer into a `502 upstream_error`. A streamed `/v1/messages` turn that ends
|
|
132
|
+
incomplete now closes properly too — the Anthropic translation emitted no
|
|
133
|
+
`message_stop` for it, leaving the client waiting on a turn that was already
|
|
134
|
+
over. Both `/v1/messages` paths, streamed or collected, now report
|
|
135
|
+
`max_tokens` as the stop reason when the output-token ceiling was the cause,
|
|
136
|
+
instead of an `end_turn` that made a truncated answer look deliberate.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
15
140
|
## [0.9.0] — 2026-08-04
|
|
16
141
|
|
|
17
142
|
### Added
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Distribute Claude Code requests across Claude subscriptions, and expose an OpenA
|
|
|
18
18
|
### Features
|
|
19
19
|
|
|
20
20
|
- **Cache-aware session routing** — keep each Claude Code session on one account while distributing new sessions across 2-20 Claude Max accounts
|
|
21
|
-
- **Multi-provider routing** — route `openai/*` models to OpenAI ChatGPT/Codex subscription accounts and Claude models to Claude subscriptions
|
|
21
|
+
- **Multi-provider routing** — route `openai/*` and unprefixed `gpt-*` models to OpenAI ChatGPT/Codex subscription accounts and Claude models to Claude subscriptions
|
|
22
22
|
- **Transparent Claude proxy** — Claude Code works normally; streaming, thinking, tool use, prompt caching all pass through
|
|
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
|
|
@@ -273,6 +273,8 @@ See [docs/litellm-setup.md](docs/litellm-setup.md) for details.
|
|
|
273
273
|
|
|
274
274
|
CC-Router exposes an OpenAI Responses-compatible endpoint for Codex CLI at `/v1/responses`. This lets Codex use OpenAI ChatGPT/Codex subscription accounts through the same local router that Claude Code uses for Claude subscriptions.
|
|
275
275
|
|
|
276
|
+
**Features:** Sticky sessions pin each Codex conversation to one account for prompt-cache locality. Load- and headroom-aware account selection spreads new sessions across available capacity. Usage tracking from response headers reports account-level 5-hour and 7-day windows, dynamically discovered model-scoped metered buckets, credits, and plan. User caps (`sessionLimitPercent`/`weeklyLimitPercent`) apply to the default Codex bucket. The dashboard shows per-bucket rows, usage bars, credits, plan, and cooldown state for OpenAI accounts.
|
|
277
|
+
|
|
276
278
|
Configure Codex:
|
|
277
279
|
|
|
278
280
|
```bash
|
|
@@ -318,11 +320,20 @@ CC_ROUTER_TOKEN=cc-rtr-your-secret codex -m openai/gpt-5.5
|
|
|
318
320
|
|
|
319
321
|
Model prefixes:
|
|
320
322
|
|
|
321
|
-
|
|
|
323
|
+
| Model | Upstream |
|
|
322
324
|
|--------|----------|
|
|
323
325
|
| `openai/*` | OpenAI ChatGPT/Codex subscription route |
|
|
326
|
+
| `gpt-*` (no prefix) | OpenAI ChatGPT/Codex subscription route |
|
|
324
327
|
| `claude/*` | Claude subscription route |
|
|
325
328
|
| `anthropic/*` | Claude subscription route |
|
|
329
|
+
| anything else with no prefix | Claude subscription route |
|
|
330
|
+
|
|
331
|
+
The unprefixed `gpt-*` rule exists for clients that do not speak this
|
|
332
|
+
convention. The Codex CLI writes the bare slug from its own registry — either
|
|
333
|
+
`model = "gpt-5.6-sol"` in `~/.codex/config.toml` or whatever its `/model`
|
|
334
|
+
picker selects — so those names arrive without a prefix and would otherwise be
|
|
335
|
+
routed to Claude, where `/v1/responses` answers `501`. Configured
|
|
336
|
+
`openAIAliases` apply to the bare form too.
|
|
326
337
|
|
|
327
338
|
Examples after the configuration above:
|
|
328
339
|
|
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -31,7 +31,12 @@ export function registerAccounts(program) {
|
|
|
31
31
|
console.log(JSON.stringify(liveStats ?? buildStoredAccountsJson(stored, openAIStored), null, 2));
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
// The count has to describe the rows printed below it. Taking it from
|
|
35
|
+
// disk while listing the proxy's live pool made drift invisible — the
|
|
36
|
+
// header claimed four accounts above six rows.
|
|
37
|
+
console.log(chalk.bold(liveStats
|
|
38
|
+
? `\n Accounts (${liveStats.length} in the running proxy)\n`
|
|
39
|
+
: `\n Accounts (${stored.length + openAIStored.length} configured)\n`));
|
|
35
40
|
if (liveStats) {
|
|
36
41
|
console.log(chalk.green(" ● Proxy is running — showing live stats\n"));
|
|
37
42
|
for (const s of liveStats) {
|
|
@@ -52,6 +57,22 @@ export function registerAccounts(program) {
|
|
|
52
57
|
` errors: ${chalk.red(String(s.errorCount).padStart(3))}` +
|
|
53
58
|
` expires: ${exp}`);
|
|
54
59
|
}
|
|
60
|
+
// The proxy reads accounts.json once at startup, so anything that
|
|
61
|
+
// rewrites the file afterwards leaves the two out of step. Silence
|
|
62
|
+
// here is how an account can be routing live while its refresh token
|
|
63
|
+
// exists nowhere on disk — one restart from having to authenticate it
|
|
64
|
+
// again.
|
|
65
|
+
const { unpersisted, unloaded } = accountDrift(liveStats.map(s => s.id), [...stored.map(a => a.id), ...openAIStored.map(a => a.id)]);
|
|
66
|
+
if (unpersisted.length > 0) {
|
|
67
|
+
console.log(chalk.red(`\n ⚠ Not in accounts.json: ${unpersisted.join(", ")}`));
|
|
68
|
+
console.log(chalk.gray(" These live only in the running proxy. Restarting it loses their\n"
|
|
69
|
+
+ " credentials — re-add them, or update any one account to make the\n"
|
|
70
|
+
+ " proxy write its pool back to disk."));
|
|
71
|
+
}
|
|
72
|
+
if (unloaded.length > 0) {
|
|
73
|
+
console.log(chalk.yellow(`\n ⚠ In accounts.json but not loaded: ${unloaded.join(", ")}`));
|
|
74
|
+
console.log(chalk.gray(" Restart the proxy to pick them up: cc-router start"));
|
|
75
|
+
}
|
|
55
76
|
}
|
|
56
77
|
else {
|
|
57
78
|
console.log(chalk.gray(" (Proxy not running — showing stored configuration)\n"));
|
|
@@ -181,10 +202,7 @@ export function registerAccounts(program) {
|
|
|
181
202
|
}
|
|
182
203
|
const anthropicAccounts = loadAccounts();
|
|
183
204
|
const openAIAccounts = loadOpenAIAccounts();
|
|
184
|
-
const existingIds =
|
|
185
|
-
...anthropicAccounts.map(a => a.id),
|
|
186
|
-
...openAIAccounts.map(a => a.id),
|
|
187
|
-
];
|
|
205
|
+
const { ids: existingIds, openAIIds } = mergeAccountInventory(anthropicAccounts.map(a => a.id), openAIAccounts.map(a => a.id), await fetchLiveStats());
|
|
188
206
|
if (!existingIds.includes(id)) {
|
|
189
207
|
console.log(chalk.red(`✗ Account "${id}" not found.`));
|
|
190
208
|
console.log(chalk.gray(` Available: ${existingIds.join(", ")}`));
|
|
@@ -199,7 +217,7 @@ export function registerAccounts(program) {
|
|
|
199
217
|
console.log(chalk.gray("Cancelled."));
|
|
200
218
|
return;
|
|
201
219
|
}
|
|
202
|
-
const isOpenAI =
|
|
220
|
+
const isOpenAI = openAIIds.has(id);
|
|
203
221
|
try {
|
|
204
222
|
await removeAccountRuntimeAware(id);
|
|
205
223
|
}
|
|
@@ -223,6 +241,46 @@ function printAddOutcome(mode) {
|
|
|
223
241
|
? chalk.gray(" Loaded into the running proxy — available now, no restart needed.\n")
|
|
224
242
|
: chalk.gray(" Restart the proxy to load the new account: cc-router start\n"));
|
|
225
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Every account this CLI could act on, from both places one can live.
|
|
246
|
+
*
|
|
247
|
+
* The proxy loads accounts.json once at startup and holds that snapshot, so
|
|
248
|
+
* the two sources drift the moment the file changes underneath a running
|
|
249
|
+
* proxy — and they answer different questions. Removal prefers the live pool
|
|
250
|
+
* (see `removeAccountRuntimeAware`), so validating an id against disk alone
|
|
251
|
+
* rejected accounts that existed and were perfectly removable.
|
|
252
|
+
*/
|
|
253
|
+
export function mergeAccountInventory(storedAnthropicIds, storedOpenAIIds, live) {
|
|
254
|
+
const openAIIds = new Set(storedOpenAIIds);
|
|
255
|
+
for (const account of live ?? []) {
|
|
256
|
+
if (account.provider === "openai_subscription")
|
|
257
|
+
openAIIds.add(account.id);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
ids: [...new Set([
|
|
261
|
+
...storedAnthropicIds,
|
|
262
|
+
...storedOpenAIIds,
|
|
263
|
+
...(live ?? []).map(account => account.id),
|
|
264
|
+
])],
|
|
265
|
+
openAIIds,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Where the running proxy and accounts.json disagree.
|
|
270
|
+
*
|
|
271
|
+
* `unpersisted` is the dangerous direction: those accounts exist only in the
|
|
272
|
+
* proxy's memory, so a restart loses their refresh tokens and they have to be
|
|
273
|
+
* authenticated again. `unloaded` is merely stale — the records are safe on
|
|
274
|
+
* disk, the proxy just has not read them.
|
|
275
|
+
*/
|
|
276
|
+
export function accountDrift(liveIds, storedIds) {
|
|
277
|
+
const live = new Set(liveIds);
|
|
278
|
+
const stored = new Set(storedIds);
|
|
279
|
+
return {
|
|
280
|
+
unpersisted: liveIds.filter(id => !stored.has(id)),
|
|
281
|
+
unloaded: storedIds.filter(id => !live.has(id)),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
226
284
|
export function buildStoredAccountsJson(anthropicAccounts, openAIAccounts) {
|
|
227
285
|
return [
|
|
228
286
|
...anthropicAccounts.map(a => ({
|
package/dist/config/manager.js
CHANGED
|
@@ -137,11 +137,17 @@ export function loadOpenAIAccounts(path) {
|
|
|
137
137
|
refreshToken: a.refreshToken,
|
|
138
138
|
expiresAt: a.expiresAt,
|
|
139
139
|
enabled: a.enabled !== false,
|
|
140
|
+
...(Array.isArray(a.scopes) ? { scopes: a.scopes } : {}),
|
|
141
|
+
...(a.sessionLimitPercent !== undefined ? { sessionLimitPercent: a.sessionLimitPercent } : {}),
|
|
142
|
+
...(a.weeklyLimitPercent !== undefined ? { weeklyLimitPercent: a.weeklyLimitPercent } : {}),
|
|
140
143
|
}));
|
|
141
144
|
}
|
|
142
|
-
|
|
145
|
+
/** Persist OpenAI subscription accounts to an explicit accounts file, preserving
|
|
146
|
+
* every other provider's records already in that file. Shared by `saveOpenAIAccounts`
|
|
147
|
+
* (default path) and any caller bound to a custom `--accounts <path>`. */
|
|
148
|
+
export function saveOpenAIAccountsToPath(accounts, path) {
|
|
143
149
|
ensureConfigDir();
|
|
144
|
-
const existing =
|
|
150
|
+
const existing = readRawFromPath(path);
|
|
145
151
|
const nonOpenAI = existing.filter(a => a.provider !== "openai_subscription");
|
|
146
152
|
const records = accounts.map(a => ({
|
|
147
153
|
id: a.id,
|
|
@@ -149,10 +155,15 @@ export function saveOpenAIAccounts(accounts) {
|
|
|
149
155
|
accessToken: a.accessToken,
|
|
150
156
|
refreshToken: a.refreshToken,
|
|
151
157
|
expiresAt: a.expiresAt,
|
|
152
|
-
scopes: ["openid", "profile", "email", "offline_access"],
|
|
158
|
+
scopes: a.scopes ?? ["openid", "profile", "email", "offline_access"],
|
|
153
159
|
enabled: a.enabled,
|
|
160
|
+
...(a.sessionLimitPercent !== undefined ? { sessionLimitPercent: a.sessionLimitPercent } : {}),
|
|
161
|
+
...(a.weeklyLimitPercent !== undefined ? { weeklyLimitPercent: a.weeklyLimitPercent } : {}),
|
|
154
162
|
}));
|
|
155
|
-
writeAccountsAtomicToPath(
|
|
163
|
+
writeAccountsAtomicToPath(path, [...nonOpenAI, ...records]);
|
|
164
|
+
}
|
|
165
|
+
export function saveOpenAIAccounts(accounts) {
|
|
166
|
+
saveOpenAIAccountsToPath(accounts, ACCOUNTS_PATH);
|
|
156
167
|
}
|
|
157
168
|
function parseProxyConfig(raw) {
|
|
158
169
|
const parsed = JSON.parse(raw);
|
|
@@ -229,13 +240,18 @@ function deserialize(records) {
|
|
|
229
240
|
expiresAt: a.expiresAt,
|
|
230
241
|
scopes: a.scopes ?? ["user:inference", "user:profile"],
|
|
231
242
|
},
|
|
232
|
-
|
|
243
|
+
// An authExpired account must come back unhealthy. `needsRefresh()` skips
|
|
244
|
+
// it, so the startup refresh that would otherwise fail and clear `healthy`
|
|
245
|
+
// never runs — and TokenPool.hardBlock() gates only on `enabled && healthy`,
|
|
246
|
+
// so defaulting to true here would route live traffic to a dead token.
|
|
247
|
+
healthy: a.authExpired !== true,
|
|
233
248
|
busy: false,
|
|
234
249
|
requestCount: 0,
|
|
235
250
|
errorCount: 0,
|
|
236
251
|
lastUsed: 0,
|
|
237
252
|
lastRefresh: 0,
|
|
238
253
|
consecutiveErrors: 0,
|
|
254
|
+
authExpired: a.authExpired === true,
|
|
239
255
|
rateLimits: { ...DEFAULT_RATE_LIMITS },
|
|
240
256
|
enabled: a.enabled !== false, // default true
|
|
241
257
|
sessionLimitPercent: a.sessionLimitPercent !== undefined
|
|
@@ -258,5 +274,6 @@ export function serialize(accounts) {
|
|
|
258
274
|
enabled: a.enabled,
|
|
259
275
|
sessionLimitPercent: a.sessionLimitPercent,
|
|
260
276
|
weeklyLimitPercent: a.weeklyLimitPercent,
|
|
277
|
+
...(a.authExpired ? { authExpired: true } : {}),
|
|
261
278
|
}));
|
|
262
279
|
}
|
|
@@ -6,10 +6,29 @@ function cleanModel(model) {
|
|
|
6
6
|
const trimmed = model?.trim();
|
|
7
7
|
return trimmed ? trimmed : undefined;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Unprefixed models that belong to OpenAI anyway.
|
|
11
|
+
*
|
|
12
|
+
* Clients do not all speak this router's `provider/model` convention. The
|
|
13
|
+
* Codex CLI writes the bare slug from its own registry — `model =
|
|
14
|
+
* "gpt-5.6-sol"` in config.toml, or whatever its `/model` picker selects — and
|
|
15
|
+
* an unprefixed name used to fall through to Anthropic, where the Responses
|
|
16
|
+
* ingress answers `501`. No configuration could redirect it either, since
|
|
17
|
+
* `openAIAliases` is only consulted once a name is already prefixed.
|
|
18
|
+
*
|
|
19
|
+
* `gpt-` is unambiguous: no Claude model is named that way, so claiming it
|
|
20
|
+
* costs the Anthropic path nothing. Everything else unprefixed still goes to
|
|
21
|
+
* Anthropic, which is what existing setups rely on.
|
|
22
|
+
*/
|
|
23
|
+
function isBareOpenAIModel(publicModel) {
|
|
24
|
+
return publicModel.toLowerCase().startsWith("gpt-");
|
|
25
|
+
}
|
|
9
26
|
export function parseModelRef(model, config = {}) {
|
|
10
27
|
const publicModel = cleanModel(model) ?? cleanModel(config.anthropicDefaultModel) ?? "claude/sonnet";
|
|
11
|
-
if (publicModel.startsWith("openai/")) {
|
|
12
|
-
const openAIModel = publicModel.
|
|
28
|
+
if (publicModel.startsWith("openai/") || isBareOpenAIModel(publicModel)) {
|
|
29
|
+
const openAIModel = publicModel.startsWith("openai/")
|
|
30
|
+
? publicModel.slice("openai/".length)
|
|
31
|
+
: publicModel;
|
|
13
32
|
const defaultOpenAIModel = cleanModel(config.openAIDefaultModel);
|
|
14
33
|
return {
|
|
15
34
|
provider: "openai_subscription",
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic stop reason for a terminal Responses payload. Shared by both
|
|
3
|
+
* translation paths — this module for a collected response, and the streaming
|
|
4
|
+
* normalizer for a terminal SSE event — so a turn that ends the same way is
|
|
5
|
+
* reported the same way whether or not the client asked for a stream.
|
|
6
|
+
*
|
|
7
|
+
* Keys off `incomplete_details` rather than the event type or `status`: a
|
|
8
|
+
* completed response carries none, so the same call is correct for both, and
|
|
9
|
+
* the output-token ceiling is the one reason that maps onto an Anthropic stop
|
|
10
|
+
* reason of its own. Any other incomplete reason still delivered content, so
|
|
11
|
+
* `end_turn` stays the honest default.
|
|
12
|
+
*/
|
|
13
|
+
export function anthropicStopReasonForResponse(response) {
|
|
14
|
+
return response?.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : "end_turn";
|
|
15
|
+
}
|
|
1
16
|
export function openAIResponseToAnthropicMessage(response) {
|
|
2
17
|
const content = (response.output ?? [])
|
|
3
18
|
.filter(item => item.type === "message")
|
|
@@ -10,7 +25,7 @@ export function openAIResponseToAnthropicMessage(response) {
|
|
|
10
25
|
role: "assistant",
|
|
11
26
|
model: response.model ?? "",
|
|
12
27
|
content,
|
|
13
|
-
stop_reason:
|
|
28
|
+
stop_reason: anthropicStopReasonForResponse(response),
|
|
14
29
|
stop_sequence: null,
|
|
15
30
|
usage: {
|
|
16
31
|
input_tokens: response.usage?.input_tokens ?? 0,
|
|
@@ -2,14 +2,81 @@ import { parseSseLines } from "./sse.js";
|
|
|
2
2
|
function upstreamError(message) {
|
|
3
3
|
return { kind: "json", status: 502, body: { error: { type: "upstream_error", message } } };
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* SSE event types that represent a Responses stream reaching a terminal
|
|
7
|
+
* *result*, as opposed to a transport/backend failure.
|
|
8
|
+
*
|
|
9
|
+
* `response.completed` is the ordinary success terminal event.
|
|
10
|
+
* `response.incomplete` is also terminal: Codex/OpenAI emit it when
|
|
11
|
+
* generation stops without completing — most commonly hitting
|
|
12
|
+
* `max_output_tokens`, or a content filter — but the event still carries a
|
|
13
|
+
* full response object with `usage` and `incomplete_details.reason`. It is a
|
|
14
|
+
* *result* to relay, not an error, so it belongs here rather than alongside
|
|
15
|
+
* `response.failed`.
|
|
16
|
+
*
|
|
17
|
+
* `response.failed` and the bare `error` event are failures, not results:
|
|
18
|
+
* they carry no usable response body and are handled separately by every
|
|
19
|
+
* caller below.
|
|
20
|
+
*/
|
|
21
|
+
const TERMINAL_RESPONSE_EVENT_TYPES = new Set([
|
|
22
|
+
"response.completed",
|
|
23
|
+
"response.incomplete",
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Returns the `.response` payload carried by a terminal Responses SSE event
|
|
27
|
+
* (`response.completed` or `response.incomplete`), or `undefined` for any
|
|
28
|
+
* other event — including `response.failed`/`error`, which are failures and
|
|
29
|
+
* carry no usable response to return. Shared by every ingress that needs to
|
|
30
|
+
* recognize "the stream produced a result", so that notion cannot drift
|
|
31
|
+
* apart between the `/v1/responses` and `/v1/messages` paths.
|
|
32
|
+
*
|
|
33
|
+
* The event type alone does not make a result: it has to carry an actual
|
|
34
|
+
* response object. Upstream can emit `{"type":"response.incomplete",
|
|
35
|
+
* "response":null}` — or a string, a number, an array — and every consumer
|
|
36
|
+
* here asks whether the payload is `undefined`, so anything else would count
|
|
37
|
+
* as a terminal success. That would hand a `200` with a `null` body to a
|
|
38
|
+
* non-streaming caller and mark an observed stream complete, which is exactly
|
|
39
|
+
* what the terminal-event checks exist to prevent. A payload nothing can be
|
|
40
|
+
* read out of is not a result.
|
|
41
|
+
*
|
|
42
|
+
* `{}` is the same problem wearing an object's clothes: it satisfies a bare
|
|
43
|
+
* typeof check and then produces a `200` whose body is `{}`, or an empty
|
|
44
|
+
* assistant turn on the Messages path. `id` is the field that separates a
|
|
45
|
+
* Responses object from an empty husk — upstream stamps it from
|
|
46
|
+
* `response.created` onward — so requiring it is what makes "is this an
|
|
47
|
+
* object" mean "is this a response".
|
|
48
|
+
*/
|
|
49
|
+
export function terminalResponsePayload(event) {
|
|
50
|
+
if (typeof event !== "object" || event === null)
|
|
51
|
+
return undefined;
|
|
52
|
+
const typed = event;
|
|
53
|
+
if (typeof typed.type !== "string" || !TERMINAL_RESPONSE_EVENT_TYPES.has(typed.type)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const payload = typed.response;
|
|
57
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
58
|
+
return undefined;
|
|
59
|
+
const { id } = payload;
|
|
60
|
+
if (typeof id !== "string" || id.length === 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
return payload;
|
|
63
|
+
}
|
|
5
64
|
/**
|
|
6
65
|
* Collapse the Codex backend's forced SSE stream into a single Responses
|
|
7
66
|
* object for callers that did not ask to stream. The backend's terminal
|
|
8
|
-
* `response.completed` payload is returned
|
|
9
|
-
* reasoning, and usage
|
|
67
|
+
* `response.completed` or `response.incomplete` payload is returned
|
|
68
|
+
* verbatim, preserving tool calls, reasoning, and usage — including for a
|
|
69
|
+
* response that stopped early (e.g. hitting the output-token ceiling), which
|
|
70
|
+
* is a usable partial answer, not a transport failure.
|
|
10
71
|
*/
|
|
11
|
-
export async function collectCodexResponseStream(upstream
|
|
72
|
+
export async function collectCodexResponseStream(upstream,
|
|
73
|
+
/** Invoked the moment upstream announces a failure, so the caller keeps that
|
|
74
|
+
* verdict even when the read is later cut short — the catch below turns any
|
|
75
|
+
* such interruption into a generic "malformed stream" and would otherwise
|
|
76
|
+
* bury it. */
|
|
77
|
+
onUpstreamFailure) {
|
|
12
78
|
if (!upstream.ok) {
|
|
79
|
+
onUpstreamFailure?.();
|
|
13
80
|
const contentType = upstream.headers.get("content-type") ?? undefined;
|
|
14
81
|
return { kind: "text", status: upstream.status, contentType, body: await upstream.text() };
|
|
15
82
|
}
|
|
@@ -27,21 +94,25 @@ export async function collectCodexResponseStream(upstream) {
|
|
|
27
94
|
return upstreamError("Empty upstream body");
|
|
28
95
|
const decoder = new TextDecoder();
|
|
29
96
|
let remainder = "";
|
|
30
|
-
let
|
|
97
|
+
let terminalResponse;
|
|
31
98
|
let failure;
|
|
32
99
|
const applyEvent = (event) => {
|
|
100
|
+
const payload = terminalResponsePayload(event);
|
|
101
|
+
if (payload !== undefined) {
|
|
102
|
+
terminalResponse = payload;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
33
105
|
if (typeof event !== "object" || event === null)
|
|
34
106
|
return;
|
|
35
107
|
const e = event;
|
|
36
|
-
if (e.type === "response.
|
|
37
|
-
completed = e.response;
|
|
38
|
-
}
|
|
39
|
-
else if (e.type === "response.failed") {
|
|
108
|
+
if (e.type === "response.failed") {
|
|
40
109
|
const err = e.response?.error;
|
|
41
110
|
failure = err?.message ?? "Response failed";
|
|
111
|
+
onUpstreamFailure?.();
|
|
42
112
|
}
|
|
43
113
|
else if (e.type === "error") {
|
|
44
114
|
failure = e.error?.message ?? "Upstream error event";
|
|
115
|
+
onUpstreamFailure?.();
|
|
45
116
|
}
|
|
46
117
|
};
|
|
47
118
|
try {
|
|
@@ -65,7 +136,115 @@ export async function collectCodexResponseStream(upstream) {
|
|
|
65
136
|
}
|
|
66
137
|
if (failure !== undefined)
|
|
67
138
|
return upstreamError(failure);
|
|
68
|
-
if (
|
|
69
|
-
return upstreamError("Stream ended before response
|
|
70
|
-
return { kind: "json", status: upstream.status, body:
|
|
139
|
+
if (terminalResponse === undefined)
|
|
140
|
+
return upstreamError("Stream ended before any terminal response event");
|
|
141
|
+
return { kind: "json", status: upstream.status, body: terminalResponse };
|
|
142
|
+
}
|
|
143
|
+
function usageNumber(value) {
|
|
144
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Extract usage totals from a response-shaped object (i.e. something with a
|
|
148
|
+
* `.usage` field directly — the Responses `response.completed`/
|
|
149
|
+
* `response.incomplete` payload, or an object wrapping one). Shared by every
|
|
150
|
+
* ingress that needs to report Codex token usage from a fully-materialized
|
|
151
|
+
* body.
|
|
152
|
+
*/
|
|
153
|
+
export function usageFromResponseBody(body) {
|
|
154
|
+
if (typeof body !== "object" || body === null)
|
|
155
|
+
return undefined;
|
|
156
|
+
const usage = body.usage;
|
|
157
|
+
if (usage === undefined || usage === null || typeof usage !== "object")
|
|
158
|
+
return undefined;
|
|
159
|
+
return {
|
|
160
|
+
inputTokens: usageNumber(usage.input_tokens),
|
|
161
|
+
cachedInputTokens: usageNumber(usage.input_tokens_details?.cached_tokens),
|
|
162
|
+
outputTokens: usageNumber(usage.output_tokens),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Extract usage totals from a terminal Responses SSE event —
|
|
167
|
+
* `response.completed` or `response.incomplete`, see
|
|
168
|
+
* `TERMINAL_RESPONSE_EVENT_TYPES` above — or `undefined` for any other
|
|
169
|
+
* event. Single definition shared by every streaming ingress so
|
|
170
|
+
* `/v1/responses` and `/v1/messages` can never report different token totals
|
|
171
|
+
* for the same stream.
|
|
172
|
+
*/
|
|
173
|
+
export function usageFromTerminalEvent(event) {
|
|
174
|
+
return usageFromResponseBody(terminalResponsePayload(event));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Passive usage reader for the byte-transparent streaming path: it only
|
|
178
|
+
* observes chunks that are already being piped downstream unchanged. It also
|
|
179
|
+
* watches for a `response.failed`/`error` event — the same terminal-failure
|
|
180
|
+
* signal `collectCodexResponseStream` above already detects for the
|
|
181
|
+
* non-streaming path — so a stream that upstream answered with `200` but
|
|
182
|
+
* ended in failure can still be reported (for stats/activity only) as the
|
|
183
|
+
* failure it was, without altering a single byte written to the client.
|
|
184
|
+
*/
|
|
185
|
+
export function createCodexUsageObserver() {
|
|
186
|
+
const decoder = new TextDecoder();
|
|
187
|
+
let remainder = "";
|
|
188
|
+
let totals;
|
|
189
|
+
let failure;
|
|
190
|
+
let completed = false;
|
|
191
|
+
const applyEvent = (event) => {
|
|
192
|
+
totals = usageFromTerminalEvent(event) ?? totals;
|
|
193
|
+
if (typeof event !== "object" || event === null)
|
|
194
|
+
return;
|
|
195
|
+
const e = event;
|
|
196
|
+
if (e.type === "response.failed") {
|
|
197
|
+
const err = e.response?.error;
|
|
198
|
+
failure = err?.message ?? "Response failed";
|
|
199
|
+
}
|
|
200
|
+
else if (e.type === "error") {
|
|
201
|
+
failure = e.error?.message ?? "Upstream error event";
|
|
202
|
+
}
|
|
203
|
+
else if (terminalResponsePayload(event) !== undefined) {
|
|
204
|
+
completed = true;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
push(chunk) {
|
|
209
|
+
// Best-effort: a malformed SSE frame from upstream must never throw
|
|
210
|
+
// here. This observer only watches bytes that are already being
|
|
211
|
+
// relayed to the client verbatim — a parse failure just means that one
|
|
212
|
+
// frame goes uncaptured, never that the response breaks. Tolerant
|
|
213
|
+
// parsing keeps the rest of the chunk's valid events.
|
|
214
|
+
try {
|
|
215
|
+
const parsed = parseSseLines(remainder + decoder.decode(chunk, { stream: true }), { tolerant: true });
|
|
216
|
+
remainder = parsed.remainder;
|
|
217
|
+
parsed.events.forEach(applyEvent);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// swallow — passive observer, see comment above
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
finish() {
|
|
224
|
+
try {
|
|
225
|
+
const tail = decoder.decode();
|
|
226
|
+
if (tail || remainder) {
|
|
227
|
+
parseSseLines(remainder + tail + "\n", { tolerant: true }).events.forEach(applyEvent);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// swallow — passive observer, see comment above
|
|
232
|
+
}
|
|
233
|
+
remainder = "";
|
|
234
|
+
return totals;
|
|
235
|
+
},
|
|
236
|
+
failure() {
|
|
237
|
+
// Tolerant parsing drops a malformed frame instead of aborting, which
|
|
238
|
+
// also means a malformed *terminal* response event (`response.completed`
|
|
239
|
+
// or `response.incomplete`) frame vanishes silently. Without an
|
|
240
|
+
// observed terminal event the stream never actually produced a result,
|
|
241
|
+
// so — mirroring collectCodexResponseStream's non-streaming check —
|
|
242
|
+
// that is reported as a failure too, unless an explicit
|
|
243
|
+
// response.failed/error already said more about what went wrong.
|
|
244
|
+
return failure ?? (completed ? undefined : "Upstream stream ended before any terminal response event");
|
|
245
|
+
},
|
|
246
|
+
explicitFailure() {
|
|
247
|
+
return failure;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
71
250
|
}
|