@timo972/cc-router 0.9.0 → 0.10.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 CHANGED
@@ -6,9 +6,95 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ---
8
8
 
9
- ## [Unreleased]
9
+ ## [0.10.0] — 2026-08-17
10
10
 
11
- Nothing yet.
11
+ ### Added
12
+
13
+ - OpenAI/Codex sticky session routing: sessions pin to one account for prompt-cache
14
+ locality (`session_id` → `x-claude-code-session-id` → `prompt_cache_key`), with
15
+ load- and headroom-aware selection for new sessions.
16
+ - Codex usage tracking from `x-codex-*` response headers: default 5h/weekly windows
17
+ plus dynamically discovered model-scoped metered buckets, credits, and plan.
18
+ - Scoped cooldowns on upstream failures: bucket-scoped via `x-codex-active-limit`,
19
+ account-global otherwise; local 429/503 responses when no account is eligible.
20
+ - Dashboard: OpenAI accounts now show 5h/weekly bars, per-bucket rows, credits,
21
+ plan, request/error/in-flight/session counts, and cooldown state.
22
+
23
+ ### Changed
24
+
25
+ - OpenAI account records persist `scopes`, `sessionLimitPercent`, and
26
+ `weeklyLimitPercent`.
27
+ - The stateless OpenAI round-robin picker was removed in favor of
28
+ `OpenAITokenPool`.
29
+
30
+ ### Fixed
31
+
32
+ - An unexpected failure partway through an OpenAI request — an upstream
33
+ connection error, a rejected token refresh, a mid-stream abort — no longer
34
+ takes down the proxy. Both `/v1/responses` and the `/v1/messages` OpenAI
35
+ branch awaited the upstream call without catching a rejection, so a single
36
+ network blip could kill the daemon and lose every account's routing state.
37
+ - `/v1/messages` no longer reports an upstream OpenAI failure as a success. A
38
+ stream ending in `response.failed`, an `error` event, a JSON error body, or
39
+ no completion event at all — a stream that stopped mid-flight, or an
40
+ event-stream response with no body — was translated into an empty Anthropic
41
+ message with HTTP 200; each now surfaces as an error response, so a rate
42
+ limit reads as a rate limit instead of an empty assistant turn. A non-2xx
43
+ upstream response (401, 429, 5xx) is now relayed with its real status, error
44
+ message, and safe headers — `Retry-After` included, so a client can honor the
45
+ backoff the server asked for — instead of being parsed as an event stream and
46
+ reported as a success or a generic failure; non-2xx Codex responses also keep
47
+ their real content type instead of being rewritten to `text/event-stream`.
48
+ - A terminal event that carries no response object is no longer treated as a
49
+ successful result. `{"type":"response.completed","response":null}` — or any
50
+ other non-object payload — satisfied the completion check, so a
51
+ non-streaming request got HTTP 200 with a `null` body, and `/v1/messages` got
52
+ a fabricated empty assistant turn; a streamed `/v1/messages` turn was closed
53
+ with `message_stop` and `end_turn`, telling the client a truncated answer had
54
+ finished normally. The collected paths now report the `502` that a stream
55
+ ending without a terminal event already did, and the streamed path ends
56
+ without `message_stop`, which is what clients already surface as a
57
+ truncation.
58
+ - A client that disconnects mid-response no longer leaves the upstream Codex
59
+ request running. Nothing propagated the disconnect, so the relay drained the
60
+ whole upstream body into a closed socket and held that connection open for a
61
+ response nobody would receive; the request is now cancelled as soon as the
62
+ client goes away.
63
+ - A single malformed SSE frame no longer truncates a `/v1/messages` stream.
64
+ Parsing a chunk was all-or-nothing, so one bad frame discarded the valid
65
+ events beside it and ended the response as a clean `200` the client could
66
+ not tell apart from a complete answer.
67
+ - OpenAI credentials are written back to the accounts file the proxy was
68
+ started with. Under `--accounts <path>` accounts were read from that file
69
+ but every refresh, add, delete, and update wrote the default
70
+ `accounts.json` — discarding the change and copying OAuth tokens into an
71
+ unrelated file.
72
+ - OpenAI token refresh survives a malformed token response. A payload missing
73
+ `expires_in` produced a `NaN` expiry that read as "never needs refreshing",
74
+ so the account kept presenting a stale token indefinitely — as did a lifetime
75
+ large enough to overflow into an infinite expiry, while a zero or negative
76
+ one reported success on a token that was already due for another refresh.
77
+ Each is now treated as the failed refresh it is; a failure to
78
+ persist rotated credentials no longer fails the request that triggered the
79
+ refresh, and the write is now retried on subsequent requests (and the
80
+ background refresh loop) until it succeeds, so a rotated refresh token
81
+ still reaches disk.
82
+ - `PATCH /cc-router/accounts/:id` works for OpenAI accounts instead of
83
+ returning `404`, so a single OpenAI account can be enabled, disabled, or
84
+ capped without toggling the whole provider. `POST /cc-router/accounts` now
85
+ rejects an out-of-range percentage cap the same way `PATCH` does, rather
86
+ than silently coercing it.
87
+ - A Codex response that ends as `response.incomplete` — e.g. hitting the
88
+ output-token ceiling — is now delivered with its partial content and token
89
+ usage instead of being discarded. `/v1/responses` treated only
90
+ `response.completed` as a terminal event, so `response.incomplete` looked
91
+ identical to a stream that stopped mid-flight and turned a usable partial
92
+ answer into a `502 upstream_error`. A streamed `/v1/messages` turn that ends
93
+ incomplete now closes properly too — the Anthropic translation emitted no
94
+ `message_stop` for it, leaving the client waiting on a turn that was already
95
+ over. Both `/v1/messages` paths, streamed or collected, now report
96
+ `max_tokens` as the stop reason when the output-token ceiling was the cause,
97
+ instead of an `end_turn` that made a truncated answer look deliberate.
12
98
 
13
99
  ---
14
100
 
package/README.md CHANGED
@@ -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
@@ -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);
@@ -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
  }
@@ -1,3 +1,5 @@
1
+ import { anthropicStopReasonForResponse } from "./openai-response-to-anthropic.js";
2
+ import { terminalResponsePayload } from "./openai-responses-collect.js";
1
3
  export function createOpenAIStreamToAnthropicNormalizer() {
2
4
  let textBlockStarted = false;
3
5
  const ensureTextBlockStarted = () => {
@@ -46,7 +48,21 @@ export function createOpenAIStreamToAnthropicNormalizer() {
46
48
  },
47
49
  ];
48
50
  }
49
- if (event.type === "response.completed") {
51
+ // Both terminal Responses events must close the Anthropic message.
52
+ // Emitting nothing for `response.incomplete` would end the HTTP stream
53
+ // without `message_stop`, leaving the client waiting on a turn that is
54
+ // already over.
55
+ //
56
+ // The same predicate the collector and the usage observer use, not the
57
+ // event type alone: a terminal frame carrying no response object
58
+ // (`"response":null`, an array, a string) is not a result, and those two
59
+ // already treat it as a failed stream. Closing the message here anyway
60
+ // would emit `stop_reason: end_turn` — telling the client a truncated
61
+ // turn ended normally, the one outcome worse than a truncated stream.
62
+ // Emitting nothing ends the body without `message_stop`, which is what
63
+ // a stream that never reached a terminal event looks like, and what
64
+ // clients already detect and surface as an error.
65
+ if (terminalResponsePayload(event) !== undefined) {
50
66
  const usage = event.response?.usage ?? {};
51
67
  const prefix = textBlockStarted
52
68
  ? [{ type: "content_block_stop", index: 0 }]
@@ -56,7 +72,9 @@ export function createOpenAIStreamToAnthropicNormalizer() {
56
72
  ...prefix,
57
73
  {
58
74
  type: "message_delta",
59
- delta: { stop_reason: "end_turn", stop_sequence: null },
75
+ // Same helper the collected-response translator uses, so an
76
+ // incomplete turn reports the same stop reason on both paths.
77
+ delta: { stop_reason: anthropicStopReasonForResponse(event.response), stop_sequence: null },
60
78
  usage: { output_tokens: usage.output_tokens ?? 0 },
61
79
  },
62
80
  { type: "message_stop" },
@@ -1,4 +1,4 @@
1
- export function parseSseLines(input) {
1
+ export function parseSseLines(input, options = {}) {
2
2
  const lines = input.split("\n");
3
3
  const remainder = lines.pop() ?? "";
4
4
  const events = [];
@@ -8,6 +8,15 @@ export function parseSseLines(input) {
8
8
  const payload = line.slice(6).trim();
9
9
  if (!payload || payload === "[DONE]")
10
10
  continue;
11
+ if (options.tolerant) {
12
+ try {
13
+ events.push(JSON.parse(payload));
14
+ }
15
+ catch {
16
+ // Advisory parse — drop just this frame, keep the rest of the chunk.
17
+ }
18
+ continue;
19
+ }
11
20
  events.push(JSON.parse(payload));
12
21
  }
13
22
  return { events, remainder };