@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 +88 -2
- package/README.md +2 -0
- package/dist/config/manager.js +15 -4
- 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/ui/Dashboard.js +93 -17
- package/package.json +1 -1
- package/dist/providers/openai/account-pool.js +0 -11
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
|
-
## [
|
|
9
|
+
## [0.10.0] — 2026-08-17
|
|
10
10
|
|
|
11
|
-
|
|
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
|
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);
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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" },
|
package/dist/protocol/sse.js
CHANGED
|
@@ -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 };
|