@timo972/cc-router 0.12.0 → 0.12.1-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
@@ -8,22 +8,53 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
- ---
11
+ ### Added
12
+
13
+ - Dashboard `[R]` reloads account usage and due tokens without restarting the
14
+ router or dropping active requests and sticky sessions. It also refreshes
15
+ Grok snapshots, CLI routing state, and an already-loaded model list, with
16
+ progress and partial-failure summaries.
17
+ - `POST /cc-router/refresh` runs a shared Claude/OpenAI refresh pass, sweeps
18
+ expired cooldowns, and returns per-provider results. Concurrent reloads
19
+ join the same pass instead of starting duplicate work.
20
+ - OpenAI transport diagnostics include correlation IDs and refresh, response
21
+ header, and first-byte timings. Failure logs use bounded diagnostic fields
22
+ rather than raw upstream error messages.
23
+
24
+ ### Changed
12
25
 
13
- ## [0.12.0-rc.1] 2026-08-30
26
+ - The configured proxy request timeout now bounds Codex response headers and
27
+ upstream stream inactivity on both `/v1/responses` and OpenAI-routed
28
+ `/v1/messages`, including failover attempts. Progressing streams can outlive
29
+ that interval; time spent waiting for a slow client does not count as
30
+ upstream inactivity.
31
+ - Permanently rejected OpenAI refresh credentials are quarantined from
32
+ inference routing without changing the account's saved `enabled` setting.
33
+ Health reports expose the authentication state and failure category.
34
+ Successful refresh or credential replacement restores eligibility;
35
+ quarantine is runtime-only and does not survive a router restart.
14
36
 
15
37
  ### Fixed
16
38
 
17
- - OpenAI Responses function calls and their outputs remain top-level input
18
- items across the Anthropic Messages bridge. JSON and SSE responses now
19
- preserve call IDs, streamed or atomic arguments, refusal text, and
20
- `tool_use` stop reasons. Invalid metadata, malformed arguments, and tool
21
- streams that end before completion fail closed instead of fabricating a
22
- successful assistant turn.
39
+ - OpenAI OAuth refresh sends the required public `client_id` and has a
40
+ 15-second deadline covering response headers and JSON body parsing, so a
41
+ stalled refresh cannot retain its lock indefinitely.
42
+ - Refresh deduplication is scoped to the account object, preventing a deleted
43
+ and re-added account from sharing an old account's in-flight refresh.
44
+ - OpenAI usage authentication failures remain advisory rather than disabling
45
+ an account or overriding permanent credential quarantine. Background and
46
+ manual token refreshes continue to check quarantined accounts for recovery.
47
+ - OpenAI response relays honor downstream backpressure and cancel upstream
48
+ reads when the client disconnects. Failed partial streams close the
49
+ connection instead of appearing complete, and router-side stream failures
50
+ are no longer misclassified as client cancellations.
51
+ - Codex header timeouts return a safe HTTP 504 response, including after
52
+ account failover, while preserving the existing retry budget and shared
53
+ request correlation.
23
54
 
24
55
  ---
25
56
 
26
- ## [0.12.0-rc.0] — 2026-08-30
57
+ ## [0.12.0] — 2026-09-11
27
58
 
28
59
  ### Added
29
60
 
@@ -48,11 +79,19 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
48
79
  - Codex configuration rewrites now use TOML-aware parsing and validation rather
49
80
  than line-oriented edits, including safe CLI start/stop toggles for the
50
81
  managed block.
51
- - The status dashboard uses compact provider groups, keeps account headers
52
- visible in short terminals, and exposes the fleet-wide weekly-full count.
82
+ - The status dashboard has been redesigned with a more compact layout,
83
+ allowing even more accounts to be displayed at once. Compact provider
84
+ groups keep account headers visible in short terminals, and the dashboard
85
+ exposes the fleet-wide weekly-full count.
53
86
 
54
87
  ### Fixed
55
88
 
89
+ - OpenAI Responses function calls and their outputs remain top-level input
90
+ items across the Anthropic Messages bridge. JSON and SSE responses now
91
+ preserve call IDs, streamed or atomic arguments, refusal text, and
92
+ `tool_use` stop reasons. Invalid metadata, malformed arguments, and tool
93
+ streams that end before completion fail closed instead of fabricating a
94
+ successful assistant turn.
56
95
  - `/v1/models` reports real context windows and includes bare `gpt-*` slugs used
57
96
  by the Codex CLI.
58
97
  - Codex routing accepts both dashed session-header spellings, logs route
@@ -670,8 +709,7 @@ cache-aware session routing and a round of security hardening.
670
709
  - `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
671
710
  affected APIs are not used here.
672
711
 
673
- [0.12.0-rc.1]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0-rc.1
674
- [0.12.0-rc.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0-rc.0
712
+ [0.12.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0
675
713
  [0.11.0]: https://github.com/Timo972/cc-router/releases/tag/v0.11.0
676
714
  [0.9.0]: https://github.com/Timo972/cc-router/releases/tag/v0.9.0
677
715
  [0.8.3]: https://github.com/Timo972/cc-router/releases/tag/v0.8.3
@@ -53,6 +53,7 @@ export function createOpenAIAccount(record) {
53
53
  lastRefresh: 0,
54
54
  rateLimits,
55
55
  modelBuckets: new Map(),
56
+ authState: "ok",
56
57
  };
57
58
  }
58
59
  /**
@@ -1,18 +1,33 @@
1
+ import { createHeaderDeadline, withStreamIdleTimeout } from "../../proxy/transport-timing.js";
1
2
  const CODEX_RESPONSES_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
2
3
  const DEFAULT_CODEX_INSTRUCTIONS = "You are a concise coding assistant.";
3
4
  export async function forwardOpenAICodexResponse(opts) {
4
5
  const body = toCodexBackendRequest(opts.body);
5
- const upstream = await fetch(CODEX_RESPONSES_ENDPOINT, {
6
- method: "POST",
7
- headers: {
8
- authorization: `Bearer ${opts.account.accessToken}`,
9
- "content-type": "application/json",
10
- accept: "text/event-stream",
11
- },
12
- body: JSON.stringify(body),
13
- ...(opts.signal ? { signal: opts.signal } : {}),
14
- });
15
- return ensureEventStreamContentType(upstream);
6
+ const deadline = createHeaderDeadline(opts.timeoutMs, opts.signal);
7
+ let upstream;
8
+ try {
9
+ upstream = await fetch(CODEX_RESPONSES_ENDPOINT, {
10
+ method: "POST",
11
+ headers: {
12
+ authorization: `Bearer ${opts.account.accessToken}`,
13
+ "content-type": "application/json",
14
+ accept: "text/event-stream",
15
+ },
16
+ body: JSON.stringify(body),
17
+ ...(deadline.signal ? { signal: deadline.signal } : {}),
18
+ });
19
+ }
20
+ finally {
21
+ // The header deadline must not become an absolute generation deadline.
22
+ // Body progress and cancellation are owned by the stream wrapper below.
23
+ deadline.dispose();
24
+ }
25
+ const timedBody = withStreamIdleTimeout(upstream.body, opts.timeoutMs, opts.signal);
26
+ return ensureEventStreamContentType(new Response(timedBody, {
27
+ status: upstream.status,
28
+ statusText: upstream.statusText,
29
+ headers: upstream.headers,
30
+ }));
16
31
  }
17
32
  export function toCodexBackendRequest(body) {
18
33
  const { max_output_tokens: _maxOutputTokens, ...rest } = body;
@@ -1,6 +1,6 @@
1
1
  import { createOpenAIAccountRecord } from "./account-record.js";
2
2
  const DEFAULT_ISSUER = "https://auth.openai.com";
3
- const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
3
+ export const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
4
4
  const DEFAULT_SCOPE = "openid profile email offline_access";
5
5
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
6
6
  function issuerOf(opts) {
@@ -220,6 +220,8 @@ export class OpenAITokenPool {
220
220
  return this.accounts;
221
221
  }
222
222
  hardBlock(account, context) {
223
+ if (account.authState === "quarantined")
224
+ return { reason: "unavailable" };
223
225
  if (!account.enabled || !account.healthy)
224
226
  return { reason: "unavailable" };
225
227
  const nowMs = this.now();
@@ -1,8 +1,13 @@
1
1
  import { decodeOpenAIPlan } from "./usage.js";
2
+ import { createHeaderDeadline } from "../../proxy/transport-timing.js";
3
+ import { logError } from "../../proxy/logger.js";
4
+ import { createCorrelationId, formatTransportDiagnostic, safeCauseCode, } from "../../proxy/transport-diagnostics.js";
5
+ import { DEFAULT_CLIENT_ID } from "./device-oauth.js";
2
6
  const TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
3
7
  const REFRESH_BUFFER_MS = 10 * 60 * 1000;
4
8
  const CHECK_INTERVAL_MS = 5 * 60 * 1000;
5
- const refreshLocks = new Map();
9
+ export const OPENAI_REFRESH_TIMEOUT_MS = 15_000;
10
+ const refreshLocks = new WeakMap();
6
11
  /**
7
12
  * Accounts whose most recently rotated credentials have NOT been confirmed
8
13
  * on disk. Identity-keyed (by object reference, not `account.id`) so a
@@ -63,20 +68,22 @@ export function needsOpenAIRefresh(account) {
63
68
  return account.expiresAt - Date.now() < REFRESH_BUFFER_MS;
64
69
  }
65
70
  export async function refreshOpenAISubscriptionToken(account) {
66
- const existing = refreshLocks.get(account.id);
71
+ const existing = refreshLocks.get(account);
67
72
  if (existing)
68
73
  return existing;
69
74
  const promise = doRefresh(account);
70
- refreshLocks.set(account.id, promise);
75
+ refreshLocks.set(account, promise);
71
76
  try {
72
77
  return await promise;
73
78
  }
74
79
  finally {
75
- refreshLocks.delete(account.id);
80
+ refreshLocks.delete(account);
76
81
  }
77
82
  }
78
83
  export async function prepareOpenAIAccountForRequest(account, allAccounts, saveAccounts) {
79
- if (!needsOpenAIRefresh(account)) {
84
+ const runtime = account;
85
+ // A revoked but unexpired access token must not bypass the refresh gate.
86
+ if (!needsOpenAIRefresh(account) && runtime.authState !== "quarantined") {
80
87
  // No refresh due, but a previous rotation from this account never made it
81
88
  // to disk (e.g. a transient disk-full). This is the retry path: piggyback
82
89
  // on this otherwise-idle request to flush the still-current in-memory
@@ -104,63 +111,151 @@ export async function refreshAndPersistOpenAIAccount(account, allAccounts, saveA
104
111
  persistCredentials(account, allAccounts, saveAccounts);
105
112
  return ok;
106
113
  }
107
- export function startOpenAIRefreshLoop(accounts, saveAccounts) {
108
- const check = async () => {
109
- for (const account of accounts) {
110
- // One account's refresh throwing must not skip every account after it
111
- // in this tick isolate failures per-account.
112
- try {
113
- await prepareOpenAIAccountForRequest(account, accounts, saveAccounts);
114
- }
115
- catch (error) {
116
- console.error(error);
117
- }
114
+ /**
115
+ * One scheduled refresh pass over every OpenAI account: due tokens are
116
+ * refreshed, quarantined ones re-tried. Shared by the background loop and
117
+ * the manual reload endpoint so both isolate failures per account the same
118
+ * way. Returns how many accounts did not come out with usable credentials —
119
+ * an expected refresh rejection counts, not only a thrown error.
120
+ */
121
+ export async function refreshOpenAIAccountsOnce(accounts, saveAccounts, options = {}) {
122
+ let failed = 0;
123
+ for (const account of [...accounts]) {
124
+ // One account's refresh throwing must not skip every account after it
125
+ // in this tick — isolate failures per-account.
126
+ try {
127
+ if (!await prepareOpenAIAccountForRequest(account, accounts, saveAccounts))
128
+ failed++;
129
+ }
130
+ catch (error) {
131
+ failed++;
132
+ (options.onError ?? console.error)(error);
118
133
  }
119
- };
134
+ }
135
+ return { failed };
136
+ }
137
+ export function startOpenAIRefreshLoop(accounts, saveAccounts) {
138
+ const check = () => refreshOpenAIAccountsOnce(accounts, saveAccounts);
120
139
  const timer = setInterval(() => { check().catch(console.error); }, CHECK_INTERVAL_MS);
121
140
  queueMicrotask(() => { check().catch(console.error); });
122
141
  return () => clearInterval(timer);
123
142
  }
143
+ function refreshErrorCode(payload, depth = 0) {
144
+ if (typeof payload === "string")
145
+ return payload;
146
+ if (depth >= 3 || typeof payload !== "object" || payload === null)
147
+ return undefined;
148
+ const record = payload;
149
+ for (const key of ["code", "type", "error"]) {
150
+ const code = refreshErrorCode(record[key], depth + 1);
151
+ if (code)
152
+ return code;
153
+ }
154
+ return undefined;
155
+ }
156
+ function rejectIsPermanent(status, payload) {
157
+ if (status !== 400 && status !== 401)
158
+ return false;
159
+ const code = refreshErrorCode(payload);
160
+ // `token_expired` is the code the real endpoint returns for a refresh token it
161
+ // can no longer validate; like the OAuth2-standard codes it means re-auth, not
162
+ // a retriable blip, so it must quarantine rather than cooldown-loop forever.
163
+ return code === "invalid_grant" || code === "invalid_token" || code === "token_revoked" || code === "token_expired";
164
+ }
165
+ function markRefreshFailure(account, permanent) {
166
+ const runtime = account;
167
+ if (permanent) {
168
+ runtime.authFailure = "permanent";
169
+ runtime.authState = "quarantined";
170
+ }
171
+ else if (runtime.authState !== "quarantined") {
172
+ runtime.authFailure = "transient";
173
+ }
174
+ }
175
+ function logRefreshFailure(account, correlationId, status, error) {
176
+ logError(account.id, status !== undefined && status >= 400 ? status : 0, formatTransportDiagnostic({
177
+ correlationId,
178
+ operation: "refresh",
179
+ ...(status !== undefined ? { status } : {}),
180
+ causeCode: safeCauseCode(error),
181
+ }));
182
+ }
124
183
  async function doRefresh(account) {
125
184
  const body = new URLSearchParams({
126
185
  grant_type: "refresh_token",
127
186
  refresh_token: account.refreshToken,
187
+ // The token endpoint validates client_id before the grant; without it the
188
+ // refresh 400s as `missing_required_parameter` and never reaches token
189
+ // validation. Same public client the tokens were minted under (device-oauth).
190
+ client_id: DEFAULT_CLIENT_ID,
128
191
  });
129
192
  let data;
193
+ let responseStatus;
194
+ const correlationId = createCorrelationId();
195
+ const deadline = createHeaderDeadline(OPENAI_REFRESH_TIMEOUT_MS);
130
196
  try {
131
197
  const res = await fetch(TOKEN_ENDPOINT, {
132
198
  method: "POST",
133
199
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
134
200
  body: body.toString(),
201
+ signal: deadline.signal,
135
202
  });
136
- if (!res.ok)
203
+ responseStatus = res.status;
204
+ if (!res.ok) {
205
+ let payload;
206
+ try {
207
+ payload = await res.json();
208
+ }
209
+ catch { /* intentionally do not retain response bodies */ }
210
+ markRefreshFailure(account, rejectIsPermanent(res.status, payload));
211
+ logRefreshFailure(account, correlationId, res.status);
137
212
  return false;
213
+ }
138
214
  data = await res.json();
139
215
  }
140
- catch {
216
+ catch (error) {
141
217
  // Network failure (or malformed response body) must resolve to `false`,
142
218
  // exactly like a non-ok HTTP response — never propagate as a rejection.
219
+ markRefreshFailure(account, false);
220
+ logRefreshFailure(account, correlationId, responseStatus, error);
143
221
  return false;
144
222
  }
223
+ finally {
224
+ // OAuth is a small JSON exchange: unlike inference, the deadline covers
225
+ // both headers and body parsing so a stalled body cannot retain the lock.
226
+ deadline.dispose();
227
+ }
145
228
  // A 200 with an unusable payload is a failed refresh, not a successful one.
146
229
  // Writing it through would leave `expiresAt` as NaN, which then reads as
147
230
  // "never needs refreshing" in `needsOpenAIRefresh` and permanently strands
148
231
  // the account on a broken token.
149
- if (typeof data?.access_token !== "string" || data.access_token.length === 0)
232
+ if (typeof data?.access_token !== "string" || data.access_token.length === 0) {
233
+ markRefreshFailure(account, false);
234
+ logRefreshFailure(account, correlationId, 200);
150
235
  return false;
236
+ }
151
237
  // The lifetime has to be positive and has to still name a finite instant
152
238
  // once converted. A zero or negative `expires_in` would report success on a
153
239
  // token that is already due for another refresh, so every request re-enters
154
240
  // the refresh path; a value big enough to overflow the multiplication would
155
241
  // set `expiresAt` to Infinity, which `needsOpenAIRefresh` can never reach —
156
242
  // the same permanent strand as NaN, from the opposite direction.
157
- if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in))
243
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) {
244
+ markRefreshFailure(account, false);
245
+ logRefreshFailure(account, correlationId, 200);
158
246
  return false;
159
- if (data.expires_in <= 0)
247
+ }
248
+ if (data.expires_in <= 0) {
249
+ markRefreshFailure(account, false);
250
+ logRefreshFailure(account, correlationId, 200);
160
251
  return false;
252
+ }
161
253
  const expiresAt = Date.now() + data.expires_in * 1000;
162
- if (!Number.isFinite(expiresAt))
254
+ if (!Number.isFinite(expiresAt)) {
255
+ markRefreshFailure(account, false);
256
+ logRefreshFailure(account, correlationId, 200);
163
257
  return false;
258
+ }
164
259
  account.accessToken = data.access_token;
165
260
  account.refreshToken = data.refresh_token ?? account.refreshToken;
166
261
  account.expiresAt = expiresAt;
@@ -175,6 +270,8 @@ async function doRefresh(account) {
175
270
  runtime.consecutiveErrors = 0;
176
271
  if (runtime.lastRefresh !== undefined)
177
272
  runtime.lastRefresh = Date.now();
273
+ runtime.authState = "ok";
274
+ runtime.authFailure = undefined;
178
275
  // The rotated access token can carry a different plan than the one decoded
179
276
  // at account creation (e.g. a Plus->Pro upgrade). Mirrors createOpenAIAccount's
180
277
  // semantics: only overwrite when the new token actually decodes a plan claim —
@@ -66,8 +66,21 @@ export class OpenAIUsageRefresher extends UsageRefresher {
66
66
  },
67
67
  cancelledResult: () => ({ ok: false, reason: "network" }),
68
68
  applyResult: (account, result) => {
69
- if (result.ok)
69
+ if (result.ok) {
70
70
  applyCodexRateLimits(account, result.update, now());
71
+ // A successful authenticated poll clears only the advisory usage
72
+ // trouble state; permanent OAuth quarantine is owned by refresh.
73
+ if (account.authFailure === "transient")
74
+ account.authFailure = undefined;
75
+ }
76
+ else if (result.reason === "auth") {
77
+ // A usage endpoint 403 can be entitlement/scope related. Surface it
78
+ // as transient diagnostic state but never quarantine on this alone.
79
+ // Refresh owns permanent quarantine, so an advisory poll must not
80
+ // overwrite that stronger diagnosis.
81
+ if (account.authState !== "quarantined")
82
+ account.authFailure = "transient";
83
+ }
71
84
  },
72
85
  ...(options.now !== undefined ? { now: options.now } : {}),
73
86
  ...(options.startupStaggerMs !== undefined ? { startupStaggerMs: options.startupStaggerMs } : {}),
@@ -12,6 +12,7 @@ import { stats, applyCodexUsage } from "./stats.js";
12
12
  import { extractCodexSessionKey } from "./openai-routing.js";
13
13
  import { sendAnthropicNoEligibleResponse, detectAnthropicClientSource } from "./anthropic-routing.js";
14
14
  import { mirrorUpstreamHeaders, runOpenAIIngress, } from "./openai-ingress.js";
15
+ import { waitForWritable } from "./transport-timing.js";
15
16
  const MESSAGES_ENVELOPE = {
16
17
  wrap: (type, message) => ({ type: "error", error: { type, message } }),
17
18
  sendNoEligible: (error, res, nowMs) => sendAnthropicNoEligibleResponse(error, res, nowMs),
@@ -62,7 +63,7 @@ function extractUpstreamErrorMessage(bodyText, status) {
62
63
  * the Codex backend signals in-stream failures as a `response.failed`/`error`
63
64
  * SSE event on an HTTP 200, and those must not be reported as success.
64
65
  */
65
- async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report) {
66
+ async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report, now) {
66
67
  const onUsage = (usage) => applyCodexUsage(entry, usage);
67
68
  // A non-OK response is never a Responses payload, whatever its content-type
68
69
  // — parsing it as an event stream or a completed response would translate a
@@ -97,7 +98,7 @@ async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, repo
97
98
  // Headers are already flushed by the time a mid-stream failure surfaces,
98
99
  // so the client keeps the partial stream; reporting 502 here keeps the
99
100
  // activity log and error totals honest about what happened.
100
- const failure = await sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report);
101
+ const failure = await sendOpenAIStreamAsAnthropic(upstream, res, entry, onUsage, report, now);
101
102
  return { statusCode: failure === undefined ? upstream.status : 502 };
102
103
  }
103
104
  const collected = await collectOpenAIStreamAsAnthropicMessage(upstream, report);
@@ -303,7 +304,7 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
303
304
  };
304
305
  }
305
306
  /** Returns the upstream failure message when the stream ended in one. */
306
- async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
307
+ async function sendOpenAIStreamAsAnthropic(upstream, res, entry, onUsage, report, now) {
307
308
  res.status(upstream.status);
308
309
  res.setHeader("content-type", "text/event-stream");
309
310
  res.setHeader("cache-control", "no-cache");
@@ -327,6 +328,7 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
327
328
  let totals;
328
329
  let failure;
329
330
  let completed = false;
331
+ let cleanEof = false;
330
332
  const pendingToolCalls = new Set();
331
333
  const writeProtocolError = () => {
332
334
  res.write(encodeSseEvent({
@@ -362,13 +364,23 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
362
364
  pendingToolCalls.delete(typed.output_index ?? 0);
363
365
  }
364
366
  };
365
- const relayEvents = (events) => {
367
+ const relayEvents = async (events) => {
366
368
  for (const event of events) {
367
369
  inspect(event);
368
370
  for (const mapped of normalizer.convert(event)) {
369
- res.write(encodeSseEvent(mapped));
371
+ if (!res.write(encodeSseEvent(mapped))) {
372
+ // Do not consume another translated event or upstream chunk while
373
+ // Node is buffering for a slow client. A close/error while waiting
374
+ // ends the relay and explicitly releases the upstream reader.
375
+ const drained = await waitForWritable(res);
376
+ if (!drained) {
377
+ await reader.cancel().catch(() => { });
378
+ return false;
379
+ }
380
+ }
370
381
  }
371
382
  }
383
+ return true;
372
384
  };
373
385
  try {
374
386
  while (true) {
@@ -380,17 +392,23 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
380
392
  const { value, done } = await reader.read();
381
393
  if (done)
382
394
  break;
395
+ if (entry.firstByteDurationMs === undefined)
396
+ entry.firstByteDurationMs = now() - entry.ts;
383
397
  // Tolerant: one malformed frame must not abort the relay (which would
384
398
  // silently truncate the client's stream) nor discard the valid events
385
399
  // decoded from the same chunk.
386
400
  const parsed = parseSseLines(remainder + decoder.decode(value, { stream: true }), { tolerant: true });
387
401
  remainder = parsed.remainder;
388
- relayEvents(parsed.events);
402
+ if (!await relayEvents(parsed.events))
403
+ return undefined;
389
404
  }
390
405
  const tail = decoder.decode();
391
406
  if (tail || remainder) {
392
- relayEvents(parseSseLines(remainder + tail + "\n", { tolerant: true }).events);
407
+ if (!await relayEvents(parseSseLines(remainder + tail + "\n", { tolerant: true }).events)) {
408
+ return undefined;
409
+ }
393
410
  }
411
+ cleanEof = true;
394
412
  if (!completed && pendingToolCalls.size > 0 && failure === undefined) {
395
413
  failure = "OpenAI function call ended before completion";
396
414
  report.upstreamReportedFailure = true;
@@ -403,9 +421,12 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
403
421
  failure = error.message;
404
422
  report.upstreamReportedFailure = true;
405
423
  writeProtocolError();
424
+ cleanEof = true;
425
+ await reader.cancel().catch(() => { });
406
426
  }
407
427
  finally {
408
- res.end();
428
+ if (cleanEof && !res.destroyed && !res.writableEnded)
429
+ res.end();
409
430
  onUsage?.(totals);
410
431
  }
411
432
  // Mirrors collectOpenAIStreamAsAnthropicMessage: tolerant parsing skips a
@@ -466,6 +487,7 @@ export function mountMessagesCrossProviderRoute(app, opts) {
466
487
  now,
467
488
  envelope: MESSAGES_ENVELOPE,
468
489
  onUpstreamAuthFailure: opts.onUpstreamAuthFailure,
490
+ timeoutMs: opts.timeoutMs,
469
491
  ...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}),
470
492
  ...(opts.sameAccountRetryDelayMs !== undefined
471
493
  ? { sameAccountRetryDelayMs: opts.sameAccountRetryDelayMs }
@@ -473,7 +495,7 @@ export function mountMessagesCrossProviderRoute(app, opts) {
473
495
  ...(opts.retryRefreshTimeoutMs !== undefined
474
496
  ? { retryRefreshTimeoutMs: opts.retryRefreshTimeoutMs }
475
497
  : {}),
476
- relay: (upstream, res, entry, report) => sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report),
498
+ relay: (upstream, res, entry, report) => sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report, now),
477
499
  });
478
500
  });
479
501
  }