clauderipple 0.2.0 → 0.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.ko.md +48 -4
  3. package/README.md +58 -4
  4. package/dist/cli/src/claude-auth.js +3 -2
  5. package/dist/cli/src/codex.js +20 -1
  6. package/dist/cli/src/hooks/agent-title.js +1 -1
  7. package/dist/cli/src/index.js +4 -4
  8. package/dist/cli/src/schtasks.js +43 -1
  9. package/dist/cli/src/settings.js +73 -6
  10. package/dist/router/src/admin.js +489 -56
  11. package/dist/router/src/agents.js +250 -0
  12. package/dist/router/src/bootstrap.js +24 -8
  13. package/dist/router/src/capabilities.js +214 -0
  14. package/dist/router/src/compat.js +5 -1
  15. package/dist/router/src/config.js +264 -11
  16. package/dist/router/src/index.js +14 -1
  17. package/dist/router/src/ingress/server.js +24 -14
  18. package/dist/router/src/picker.js +14 -6
  19. package/dist/router/src/pool.js +233 -0
  20. package/dist/router/src/presets.js +156 -1
  21. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  22. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  23. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  24. package/dist/router/src/providers/chatgpt/index.js +343 -12
  25. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  26. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  27. package/dist/router/src/providers/claude-oauth.js +61 -19
  28. package/dist/router/src/providers/openai/index.js +55 -11
  29. package/dist/router/src/providers/openai/translate.js +82 -14
  30. package/dist/router/src/providers/retry.js +88 -0
  31. package/dist/router/src/proxy.js +697 -82
  32. package/dist/router/src/requestlog.js +5 -2
  33. package/dist/router/src/routing.js +151 -17
  34. package/dist/router/src/version.js +1 -1
  35. package/dist/router/src/websearch.js +307 -0
  36. package/dist/router/src/x509.js +7 -2
  37. package/dist/ui/app.js +740 -160
  38. package/dist/ui/i18n.js +14 -6
  39. package/dist/ui/index.html +18 -5
  40. package/dist/ui/presets-fallback.js +2 -0
  41. package/dist/ui/style.css +133 -9
  42. package/docs/ARCHITECTURE.md +381 -20
  43. package/package.json +5 -1
@@ -1,15 +1,28 @@
1
1
  // ChatGPT subscription adapter: serves an Anthropic Messages request by calling the
2
2
  // Codex backend (OpenAI Responses over SSE) and streaming the translated answer back.
3
+ import crypto from "node:crypto";
3
4
  import http from "node:http";
4
5
  import { CredentialStore } from "./auth.js";
5
6
  import { SseParser } from "./sse.js";
6
- import { StreamMapper, conversationKey, estimateTokens, formatSse, toResponsesRequest } from "./translate.js";
7
+ import { fetchWithRetry } from "../retry.js";
8
+ import { looksLikeAuth } from "../openai/index.js";
9
+ import { StreamMapper, conversationKey, estimateTokens, formatSse, serverToolNames, toResponsesRequest, toolNameRestoreMap } from "./translate.js";
7
10
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
11
+ import { codexClientVersion, parseCodexCatalog } from "./catalog.js";
8
12
  import fs from "node:fs";
9
13
  import path from "node:path";
10
14
  import { homeDir } from "../../config.js";
11
15
  export const DEFAULT_BASE = "https://chatgpt.com/backend-api";
12
16
  const PING_MS = 15_000;
17
+ /** Active quota lookup. Measured 2026-09-20: GET {base}/wham/usage → 200 JSON. The binary also
18
+ * carries `/api/codex/usage`, but that path answers 403 here; `wham` is the one that works. */
19
+ const USAGE_PATH = "/wham/usage";
20
+ const USAGE_TIMEOUT_MS = 10_000;
21
+ /** Model catalogue. Measured 2026-09-23: the backend filters this list by `client_version`, and an
22
+ * hour's cache is short enough that a model announced this morning shows up the same day. */
23
+ const MODELS_PATH = "/codex/models";
24
+ const MODELS_TIMEOUT_MS = 15_000;
25
+ const MODELS_CACHE_MS = 60 * 60 * 1000;
13
26
  function anthropicError(status, type, message) {
14
27
  return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
15
28
  }
@@ -22,8 +35,15 @@ function mapHttpError(status, text) {
22
35
  catch {
23
36
  /* keep raw */
24
37
  }
25
- if (status === 401 || status === 403)
38
+ if (status === 401)
26
39
  return anthropicError(401, "authentication_error", `ChatGPT: ${msg}`);
40
+ // Same reasoning as the openai-compatible adapter: a 403 is often about what the account may do,
41
+ // not about the credential, and naming it an auth error sends the user to check the wrong thing.
42
+ if (status === 403) {
43
+ return looksLikeAuth(text)
44
+ ? anthropicError(401, "authentication_error", `ChatGPT: ${msg}`)
45
+ : anthropicError(403, "permission_error", `ChatGPT: ${msg}`);
46
+ }
27
47
  if (status === 429)
28
48
  return anthropicError(429, "rate_limit_error", `ChatGPT: ${msg}`);
29
49
  if (status >= 500)
@@ -70,6 +90,40 @@ export function rateLimitsFromHeaders(h) {
70
90
  at: Date.now(),
71
91
  };
72
92
  }
93
+ /**
94
+ * Map the `/wham/usage` JSON body onto the same shape as `rateLimitsFromHeaders`, so `/api/status`
95
+ * and the GUI read one thing whether the snapshot came from response headers or the active call.
96
+ * Shape measured 2026-09-20: `{plan_type, rate_limit: {primary_window:{used_percent,
97
+ * limit_window_seconds, reset_after_seconds, reset_at}, secondary_window}}`; window minutes are
98
+ * derived (the body reports seconds), and as with the headers a secondary window only counts when
99
+ * it has a real length.
100
+ */
101
+ export function rateLimitsFromUsage(body) {
102
+ const b = body;
103
+ const win = (w) => {
104
+ const used = typeof w?.used_percent === "number" ? w.used_percent : undefined;
105
+ if (used === undefined)
106
+ return null;
107
+ const out = { used_percent: used };
108
+ if (typeof w.limit_window_seconds === "number")
109
+ out.window_minutes = Math.round(w.limit_window_seconds / 60);
110
+ if (typeof w.reset_after_seconds === "number")
111
+ out.reset_after_seconds = w.reset_after_seconds;
112
+ if (typeof w.reset_at === "number")
113
+ out.reset_at = w.reset_at;
114
+ return out;
115
+ };
116
+ const primary = win(b?.rate_limit?.primary_window);
117
+ if (!primary)
118
+ return null;
119
+ const secondary = win(b?.rate_limit?.secondary_window);
120
+ return {
121
+ type: "codex.rate_limits",
122
+ plan_type: typeof b?.plan_type === "string" ? b.plan_type : undefined,
123
+ rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
124
+ at: Date.now(),
125
+ };
126
+ }
73
127
  export class ChatGptAdapter {
74
128
  name;
75
129
  cfg;
@@ -85,8 +139,254 @@ export class ChatGptAdapter {
85
139
  describeAuth() {
86
140
  return this.creds.describe();
87
141
  }
142
+ /**
143
+ * Hosted web search through the same Codex backend and credential as ordinary ChatGPT turns.
144
+ * Wire measured 2026-09-20 against the live backend: a `web_search` Responses tool emits one
145
+ * `web_search_call`, URL citation annotations on the final output text, and
146
+ * `response.completed.response.tool_usage.web_search.num_requests`.
147
+ */
148
+ webSearch(model, maxResults) {
149
+ return {
150
+ name: this.name,
151
+ search: (query, signal) => this.searchWeb(model, query, maxResults, signal),
152
+ };
153
+ }
154
+ async searchWeb(model, query, maxResults = 10, signal) {
155
+ // The Responses tool exposes an allowed-domain filter but no exclusion filter. Ignoring a block
156
+ // would violate the caller's request; fail visibly so the proxy can choose another backend.
157
+ if (query.blockedDomains?.length)
158
+ throw new Error("ChatGPT web search does not support blocked_domains");
159
+ const tokens = await this.creds.get();
160
+ if (tokens instanceof Error)
161
+ throw tokens;
162
+ const id = crypto.randomUUID();
163
+ const filters = query.allowedDomains?.length ? { allowed_domains: query.allowedDomains } : undefined;
164
+ const tool = {
165
+ type: "web_search",
166
+ search_context_size: "low",
167
+ external_web_access: true,
168
+ ...(filters ? { filters } : {}),
169
+ };
170
+ const body = {
171
+ model,
172
+ instructions: "Perform the requested web search. Answer briefly and cite every source used.",
173
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: query.query }] }],
174
+ tools: [tool],
175
+ tool_choice: "required",
176
+ reasoning: { effort: "low", summary: "auto" },
177
+ text: { verbosity: "low" },
178
+ store: false,
179
+ stream: true,
180
+ prompt_cache_key: id,
181
+ client_metadata: { session_id: id, thread_id: id, turn_id: crypto.randomUUID(), "x-codex-window-id": `${id}:0` },
182
+ };
183
+ const timeout = AbortSignal.timeout(60_000);
184
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
185
+ const res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
186
+ method: "POST",
187
+ headers: {
188
+ "content-type": "application/json",
189
+ accept: "text/event-stream",
190
+ authorization: `Bearer ${tokens.accessToken}`,
191
+ "chatgpt-account-id": tokens.accountId,
192
+ "OpenAI-Beta": "responses=experimental",
193
+ originator: "codex_cli_rs",
194
+ "session-id": id,
195
+ "thread-id": id,
196
+ "x-client-request-id": id,
197
+ "x-codex-window-id": `${id}:0`,
198
+ },
199
+ body: JSON.stringify(body),
200
+ signal: requestSignal,
201
+ });
202
+ const rateLimits = rateLimitsFromHeaders(res.headers);
203
+ if (rateLimits)
204
+ this.lastRateLimits = rateLimits;
205
+ if (!res.ok || !res.body) {
206
+ const text = await res.text().catch(() => "");
207
+ if (res.status === 401)
208
+ this.creds.invalidate();
209
+ throw new Error(`ChatGPT web search: HTTP ${res.status}${text ? ` ${redactErrorText(text, [tokens.accessToken], 200)}` : ""}`);
210
+ }
211
+ const parser = new SseParser();
212
+ const reader = res.body.getReader();
213
+ const decoder = new TextDecoder();
214
+ const hits = [];
215
+ let text = "";
216
+ let searches = 0;
217
+ try {
218
+ for (;;) {
219
+ const { done, value } = await reader.read();
220
+ if (done)
221
+ break;
222
+ for (const event of parser.feed(decoder.decode(value, { stream: true }))) {
223
+ if (event.type === "response.output_text.delta")
224
+ text += String(event.delta ?? "");
225
+ if (event.type === "response.output_text.annotation.added") {
226
+ const a = event.annotation;
227
+ if (a?.type === "url_citation" && typeof a.url === "string")
228
+ hits.push({ title: typeof a.title === "string" && a.title ? a.title : a.url, url: a.url });
229
+ }
230
+ if (event.type === "response.completed") {
231
+ const completed = event.response;
232
+ searches = typeof completed?.tool_usage?.web_search?.num_requests === "number" ? completed.tool_usage.web_search.num_requests : searches;
233
+ }
234
+ if (event.type === "error")
235
+ throw new Error(`ChatGPT web search: ${String(event.error?.message ?? "backend error")}`);
236
+ }
237
+ }
238
+ }
239
+ finally {
240
+ try {
241
+ await reader.cancel();
242
+ }
243
+ catch { /* already closed */ }
244
+ }
245
+ const unique = new Map();
246
+ for (const hit of hits)
247
+ if (!unique.has(hit.url))
248
+ unique.set(hit.url, hit);
249
+ const selected = [...unique.values()].slice(0, maxResults);
250
+ if (searches < 1 || selected.length === 0)
251
+ throw new Error(`ChatGPT web search: no cited results returned (searches=${searches})`);
252
+ const prose = text.trim();
253
+ return prose ? { hits: selected, text: prose } : { hits: selected };
254
+ }
255
+ /** One in-flight lookup shared by every caller (the status route and the startup refresh). */
256
+ rateLimitsInFlight = null;
257
+ /**
258
+ * Ask the backend for the current quota instead of waiting for a request to carry it in the
259
+ * response headers. Without this, `/api/status` shows the last time GPT traffic flowed — 11
260
+ * hours stale in one measurement (2026-09-20) — and the product's GPT budget read is wrong.
261
+ * Never throws and never clears a good snapshot; returns the new one, or null on failure.
262
+ */
263
+ fetchRateLimits() {
264
+ if (this.rateLimitsInFlight)
265
+ return this.rateLimitsInFlight;
266
+ this.rateLimitsInFlight = this.fetchRateLimitsOnce().finally(() => {
267
+ this.rateLimitsInFlight = null;
268
+ });
269
+ return this.rateLimitsInFlight;
270
+ }
271
+ async fetchRateLimitsOnce() {
272
+ const tokens = await this.creds.get();
273
+ if (tokens instanceof Error) {
274
+ this.log.warn(`chatgpt ${this.name}: rate-limit fetch skipped: ${tokens.message}`);
275
+ return null;
276
+ }
277
+ const ac = new AbortController();
278
+ const timer = setTimeout(() => ac.abort(), USAGE_TIMEOUT_MS);
279
+ let res;
280
+ try {
281
+ res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}${USAGE_PATH}`, {
282
+ method: "GET",
283
+ headers: {
284
+ authorization: `Bearer ${tokens.accessToken}`,
285
+ "chatgpt-account-id": tokens.accountId,
286
+ originator: "codex_cli_rs",
287
+ accept: "application/json",
288
+ },
289
+ signal: ac.signal,
290
+ });
291
+ }
292
+ catch (e) {
293
+ this.log.warn(`chatgpt ${this.name}: rate-limit fetch failed: ${e.message}`);
294
+ return null;
295
+ }
296
+ finally {
297
+ clearTimeout(timer);
298
+ }
299
+ if (!res.ok) {
300
+ if (res.status === 401)
301
+ this.creds.invalidate();
302
+ this.log.warn(`chatgpt ${this.name}: rate-limit fetch HTTP ${res.status}`);
303
+ return null;
304
+ }
305
+ const parsed = rateLimitsFromUsage(await res.json().catch(() => null));
306
+ if (!parsed) {
307
+ this.log.warn(`chatgpt ${this.name}: rate-limit fetch returned no primary window`);
308
+ return null;
309
+ }
310
+ this.lastRateLimits = parsed;
311
+ return parsed;
312
+ }
313
+ /** One in-flight catalogue lookup shared by every caller, and the last good list for an hour. */
314
+ modelsInFlight = null;
315
+ modelsCache = null;
316
+ /**
317
+ * The models this subscription can actually reach, from the backend's own catalogue. Without it
318
+ * a model OpenAI ships is invisible here until a router release names it (gpt-6-sol and gpt-6-luna,
319
+ * 2026-09-23). Never throws; null on any failure, and null
320
+ * rather than [] when parsing yields nothing, so the caller falls back instead of showing nothing.
321
+ */
322
+ fetchModels() {
323
+ if (this.modelsCache && Date.now() - this.modelsCache.at < MODELS_CACHE_MS)
324
+ return Promise.resolve(this.modelsCache.models);
325
+ if (this.modelsInFlight)
326
+ return this.modelsInFlight;
327
+ // A failed refresh keeps the last list it read: an expired catalogue is still closer to the
328
+ // backend than the fallback written into this repo.
329
+ this.modelsInFlight = this.fetchModelsOnce().then((models) => models ?? this.modelsCache?.models ?? null).finally(() => {
330
+ this.modelsInFlight = null;
331
+ });
332
+ return this.modelsInFlight;
333
+ }
334
+ async fetchModelsOnce() {
335
+ const tokens = await this.creds.get();
336
+ if (tokens instanceof Error) {
337
+ this.log.warn(`chatgpt ${this.name}: model-catalog fetch skipped: ${tokens.message}`);
338
+ return null;
339
+ }
340
+ const ac = new AbortController();
341
+ const timer = setTimeout(() => ac.abort(), MODELS_TIMEOUT_MS);
342
+ let res;
343
+ try {
344
+ res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}${MODELS_PATH}?client_version=${encodeURIComponent(codexClientVersion())}`, {
345
+ method: "GET",
346
+ headers: {
347
+ authorization: `Bearer ${tokens.accessToken}`,
348
+ "chatgpt-account-id": tokens.accountId,
349
+ originator: "codex_cli_rs",
350
+ accept: "application/json",
351
+ },
352
+ signal: ac.signal,
353
+ });
354
+ }
355
+ catch (e) {
356
+ this.log.warn(`chatgpt ${this.name}: model-catalog fetch failed: ${e.message}`);
357
+ return null;
358
+ }
359
+ finally {
360
+ clearTimeout(timer);
361
+ }
362
+ if (!res.ok) {
363
+ // The status only: the body can echo the request, and the token rides on it.
364
+ if (res.status === 401)
365
+ this.creds.invalidate();
366
+ this.log.warn(`chatgpt ${this.name}: model-catalog fetch HTTP ${res.status}`);
367
+ return null;
368
+ }
369
+ const models = parseCodexCatalog(await res.json().catch(() => null));
370
+ if (models.length === 0) {
371
+ this.log.warn(`chatgpt ${this.name}: model catalog returned no listed models`);
372
+ return null;
373
+ }
374
+ this.modelsCache = { at: Date.now(), models };
375
+ return models;
376
+ }
88
377
  /** Last measured total input (uncached + cached) per conversation, for the next message_start estimate. */
89
378
  lastInputByKey = new Map();
379
+ /**
380
+ * The backend's `x-codex-turn-state` per conversation. Every response carries this opaque
381
+ * token and the Codex CLI sends it back on the conversation's next turn (it sits in the
382
+ * binary's request-header list beside `x-codex-installation-id`). Without it the backend
383
+ * answered `cached_tokens: 0` on every turn of a conversation whose prompt_cache_key,
384
+ * instructions, tools and input prefix were byte-identical 3–6s apart (measured 2026-09-20,
385
+ * five turns, GPT-6 Astra) — the same adapter read 93% on 2026-09-13, so the backend began
386
+ * keying cache affinity on this token in between. Keyed on the cache key, which is what a
387
+ * conversation is to us.
388
+ */
389
+ turnStateByKey = new Map();
90
390
  rememberInput(key, u) {
91
391
  const total = u.input_tokens + u.cache_read_input_tokens;
92
392
  if (total > 0) {
@@ -123,6 +423,11 @@ export class ChatGptAdapter {
123
423
  res.writeHead(e.status, { "content-type": "application/json" }).end(e.body);
124
424
  return { status: e.status, bytes: e.body.length, note: "no credentials" };
125
425
  }
426
+ // Dropping a tool the model was meant to have is worth a line: the alternative to this drop is
427
+ // an empty answer with nothing logged anywhere, which is what made it expensive to find.
428
+ const serverTools = serverToolNames(json.tools);
429
+ if (serverTools.size > 0)
430
+ this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
126
431
  const upstreamReq = toResponsesRequest(json, {
127
432
  model,
128
433
  effort: effort ?? this.cfg.defaultEffort ?? "high",
@@ -136,6 +441,7 @@ export class ChatGptAdapter {
136
441
  const ac = new AbortController();
137
442
  const onClose = () => ac.abort();
138
443
  res.on("close", onClose);
444
+ const turnState = this.turnStateByKey.get(cacheKey);
139
445
  const upstreamHeaders = {
140
446
  "content-type": "application/json",
141
447
  accept: "text/event-stream",
@@ -143,16 +449,25 @@ export class ChatGptAdapter {
143
449
  "chatgpt-account-id": tokens.accountId,
144
450
  "OpenAI-Beta": "responses=experimental",
145
451
  originator: "codex_cli_rs",
452
+ // The conversation's identity, as the Codex CLI states it. This is what the backend keys
453
+ // the prompt cache on since mid-September 2026 (see `conversationId` in translate.ts).
454
+ "session-id": cacheKey,
455
+ "thread-id": cacheKey,
456
+ "x-client-request-id": cacheKey,
457
+ "x-codex-window-id": `${cacheKey}:0`,
458
+ ...(turnState ? { "x-codex-turn-state": turnState } : {}),
146
459
  };
147
460
  const upstreamSecrets = credentialHeaderValues(Object.entries(upstreamHeaders));
148
461
  let upstream;
149
462
  try {
150
- upstream = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
463
+ // Retried here, before any status or byte reaches the client, so a relay's hiccup is absorbed
464
+ // inside the turn instead of arriving as an error the user has to retry by hand.
465
+ upstream = await fetchWithRetry(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
151
466
  method: "POST",
152
467
  headers: upstreamHeaders,
153
468
  body,
154
469
  signal: ac.signal,
155
- });
470
+ }, { log: (line) => this.log.info(`chatgpt ${this.name}: ${line}`) });
156
471
  }
157
472
  catch (e) {
158
473
  res.off("close", onClose);
@@ -166,6 +481,12 @@ export class ChatGptAdapter {
166
481
  const fromHeaders = rateLimitsFromHeaders(upstream.headers);
167
482
  if (fromHeaders)
168
483
  this.lastRateLimits = fromHeaders;
484
+ const nextTurnState = upstream.headers.get("x-codex-turn-state");
485
+ if (nextTurnState) {
486
+ this.turnStateByKey.set(cacheKey, nextTurnState);
487
+ if (this.turnStateByKey.size > 500)
488
+ this.turnStateByKey.delete(this.turnStateByKey.keys().next().value);
489
+ }
169
490
  if (!upstream.ok || !upstream.body) {
170
491
  const text = await upstream.text().catch(() => "");
171
492
  const safeText = redactErrorText(text, upstreamSecrets);
@@ -182,7 +503,7 @@ export class ChatGptAdapter {
182
503
  if (this.cfg.debugDump === "all")
183
504
  this.dump(upstream.status, json, upstreamReq, "");
184
505
  const wantStream = json.stream === true;
185
- const mapper = new StreamMapper(model, startInput);
506
+ const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json));
186
507
  const parser = new SseParser();
187
508
  let bytes = 0;
188
509
  let ping = null;
@@ -217,8 +538,12 @@ export class ChatGptAdapter {
217
538
  break;
218
539
  }
219
540
  if (!mapper.isFinished) {
220
- // Upstream ended without response.completed: treat as done with what we have.
221
- const tail = mapper.finish();
541
+ // Upstream ended without response.completed. This used to be finished as done-with-what-we-
542
+ // have, which gave the client an empty or cut-off turn it accepted as final (gpt-6-astra,
543
+ // 12 empty turns at a median 105s, 2026-09). Overloaded is what Claude Code retries.
544
+ const tail = parser.sawDone
545
+ ? mapper.finish()
546
+ : mapper.fail(`${model}: upstream stream ended before the response completed`, "server_is_overloaded");
222
547
  if (wantStream)
223
548
  for (const o of tail)
224
549
  bytes += write(res, formatSse(o));
@@ -226,7 +551,7 @@ export class ChatGptAdapter {
226
551
  }
227
552
  catch (e) {
228
553
  if (!ac.signal.aborted) {
229
- const tail = mapper.fail(`stream interrupted: ${e.message}`);
554
+ const tail = mapper.fail(`stream interrupted: ${e.message}`, "server_is_overloaded");
230
555
  if (wantStream)
231
556
  for (const o of tail)
232
557
  bytes += write(res, formatSse(o));
@@ -243,9 +568,12 @@ export class ChatGptAdapter {
243
568
  /* already closed */
244
569
  }
245
570
  }
571
+ const failure = mapper.failure;
572
+ const failedStatus = failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
246
573
  if (!wantStream) {
247
- const msg = JSON.stringify(mapper.message());
248
- res.writeHead(200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
574
+ // A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
575
+ const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
576
+ res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
249
577
  bytes = msg.length;
250
578
  }
251
579
  else if (!res.writableEnded) {
@@ -253,9 +581,12 @@ export class ChatGptAdapter {
253
581
  }
254
582
  const u = mapper.usage;
255
583
  return {
256
- status: 200,
584
+ // A stream has already sent 200; the record still says the turn failed.
585
+ status: failure ? failedStatus : 200,
257
586
  bytes,
258
- note: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}`,
587
+ note: failure
588
+ ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
589
+ : `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}`,
259
590
  usage: {
260
591
  input: u.input_tokens,
261
592
  cached: u.cache_read_input_tokens,
@@ -1,6 +1,8 @@
1
1
  // Minimal SSE parser: bytes in, `data:` payloads out. Handles multi-line data and CRLF.
2
2
  export class SseParser {
3
3
  buf = "";
4
+ /** The stream's own end marker arrived — a vendor that ends on `[DONE]` finished on purpose. */
5
+ sawDone = false;
4
6
  /** Feed a chunk; returns the parsed JSON objects of complete events (non-JSON data is skipped). */
5
7
  feed(chunk) {
6
8
  this.buf += chunk;
@@ -14,6 +16,8 @@ export class SseParser {
14
16
  .filter((l) => l.startsWith("data:"))
15
17
  .map((l) => l.slice(5).replace(/^ /, ""))
16
18
  .join("\n");
19
+ if (data === "[DONE]")
20
+ this.sawDone = true;
17
21
  if (!data || data === "[DONE]")
18
22
  continue;
19
23
  try {