@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +13 -2
  3. package/dist/cli/cmd-accounts.js +64 -6
  4. package/dist/config/manager.js +22 -5
  5. package/dist/protocol/model-ref.js +21 -2
  6. package/dist/protocol/openai-response-to-anthropic.js +16 -1
  7. package/dist/protocol/openai-responses-collect.js +190 -11
  8. package/dist/protocol/openai-stream-to-anthropic.js +20 -2
  9. package/dist/protocol/sse.js +10 -1
  10. package/dist/providers/openai/account-state.js +377 -0
  11. package/dist/providers/openai/codex-transport.js +8 -0
  12. package/dist/providers/openai/failure-routing.js +141 -0
  13. package/dist/providers/openai/token-pool.js +401 -0
  14. package/dist/providers/openai/token-refresher.js +144 -11
  15. package/dist/providers/openai/usage.js +160 -0
  16. package/dist/proxy/account-add.js +10 -7
  17. package/dist/proxy/account-deletion.js +5 -1
  18. package/dist/proxy/account-patch.js +64 -0
  19. package/dist/proxy/account-pool.js +19 -0
  20. package/dist/proxy/anthropic-routing.js +2 -2
  21. package/dist/proxy/lease-lifecycle.js +6 -4
  22. package/dist/proxy/messages-cross-route.js +243 -62
  23. package/dist/proxy/openai-ingress.js +379 -0
  24. package/dist/proxy/openai-routing.js +61 -0
  25. package/dist/proxy/provider-routing.js +8 -4
  26. package/dist/proxy/responses-server.js +109 -48
  27. package/dist/proxy/server.js +241 -65
  28. package/dist/proxy/stats.js +30 -1
  29. package/dist/proxy/token-pool.js +3 -19
  30. package/dist/proxy/token-refresher.js +36 -0
  31. package/dist/ui/Dashboard.js +93 -17
  32. package/package.json +1 -1
  33. 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
- | Prefix | Upstream |
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
 
@@ -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
- console.log(chalk.bold(`\n Accounts (${stored.length + openAIStored.length} configured)\n`));
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 = openAIAccounts.some(account => account.id === id);
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 => ({
@@ -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
- export function saveOpenAIAccounts(accounts) {
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 = readAccountsRaw();
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(ACCOUNTS_PATH, [...nonOpenAI, ...records]);
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
- healthy: true,
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.slice("openai/".length);
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: "end_turn",
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 verbatim, preserving tool calls,
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 completed;
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.completed") {
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 (completed === undefined)
69
- return upstreamError("Stream ended before response.completed");
70
- return { kind: "json", status: upstream.status, body: completed };
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
  }