clauderipple 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +96 -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/cli/src/tray.js +17 -2
  11. package/dist/router/src/admin.js +489 -56
  12. package/dist/router/src/agents.js +250 -0
  13. package/dist/router/src/bootstrap.js +24 -8
  14. package/dist/router/src/capabilities.js +214 -0
  15. package/dist/router/src/compat.js +5 -1
  16. package/dist/router/src/config.js +264 -11
  17. package/dist/router/src/index.js +14 -1
  18. package/dist/router/src/ingress/server.js +24 -14
  19. package/dist/router/src/picker.js +14 -6
  20. package/dist/router/src/pool.js +233 -0
  21. package/dist/router/src/presets.js +163 -2
  22. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  23. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  24. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  25. package/dist/router/src/providers/chatgpt/index.js +343 -12
  26. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  27. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  28. package/dist/router/src/providers/claude-oauth.js +61 -19
  29. package/dist/router/src/providers/openai/index.js +55 -11
  30. package/dist/router/src/providers/openai/translate.js +82 -14
  31. package/dist/router/src/providers/retry.js +88 -0
  32. package/dist/router/src/proxy.js +713 -82
  33. package/dist/router/src/requestlog.js +5 -2
  34. package/dist/router/src/routing.js +151 -17
  35. package/dist/router/src/version.js +1 -1
  36. package/dist/router/src/websearch.js +307 -0
  37. package/dist/router/src/x509.js +7 -2
  38. package/dist/ui/app.js +740 -160
  39. package/dist/ui/i18n.js +14 -6
  40. package/dist/ui/index.html +18 -5
  41. package/dist/ui/presets-fallback.js +2 -0
  42. package/dist/ui/style.css +133 -9
  43. package/docs/ARCHITECTURE.md +381 -20
  44. package/package.json +5 -1
@@ -20,16 +20,21 @@ import tls from "node:tls";
20
20
  import zlib from "node:zlib";
21
21
  import { UpstreamHealth } from "./health.js";
22
22
  import { BOOTSTRAP_PATH, injectBootstrap } from "./bootstrap.js";
23
- import { THREAD_UNSUPPORTED, effortOf, resolve, rewriteBody, stripThreadFields, threadDecision } from "./routing.js";
23
+ import { THREAD_UNSUPPORTED, effortOf, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
24
+ import { defaultAgentDir, withAgentAliases } from "./agents.js";
24
25
  import { forwardCompatibleHeader, resolveCompatibleCaps, sanitizeForCompatible } from "./compat.js";
25
26
  import { applyIdentityToAnthropicBody } from "./identity.js";
27
+ import { anthropicServerToolBackend, webPluginBackend, webSearchBlocks, webSearchErrorBlocks, webSearchMessage, webSearchQuery, webSearchSse } from "./websearch.js";
28
+ import { classify, CredentialPool, retryAfterMs } from "./pool.js";
26
29
  import { PRESETS } from "./presets.js";
27
30
  import { ChatGptAdapter } from "./providers/chatgpt/index.js";
28
31
  import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
29
- import { terminateHosts } from "./config.js";
32
+ import { conversationKey } from "./providers/chatgpt/translate.js";
33
+ import { providerFor, terminateHosts } from "./config.js";
30
34
  import { injectPickerModels, isBootstrapPath } from "./picker.js";
31
35
  import { ResponseUsageTap } from "./requestlog.js";
32
36
  import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
37
+ import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
33
38
  const MAX_BODY = 64 * 1024 * 1024;
34
39
  const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length"]);
35
40
  // The caller's own Anthropic credentials. A routed request authenticates as the provider, so these
@@ -38,6 +43,114 @@ const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "tra
38
43
  const CLIENT_AUTH = new Set(["authorization", "x-api-key"]);
39
44
  /** How much of an upstream error body is kept for the log. */
40
45
  const ERROR_HEAD_MAX = 4096;
46
+ /**
47
+ * Claude Code's Remote Control registration uses HTTPS absolute-form proxy requests instead of
48
+ * CONNECT (`POST https://api.anthropic.com/v1/environments/bridge HTTP/1.1`). Convert that legal
49
+ * forward-proxy form to the origin form expected inside a TLS connection. Credentials and the
50
+ * request body remain byte-for-byte client data; proxy-only headers never reach the destination.
51
+ */
52
+ export function absoluteProxyRequest(header) {
53
+ const lines = header.toString("latin1").split("\r\n");
54
+ const first = lines.shift() ?? "";
55
+ const match = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+)\s+(\S+)\s+(HTTP\/1\.[01])$/.exec(first);
56
+ if (!match)
57
+ return null;
58
+ let url;
59
+ try {
60
+ url = new URL(match[2]);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || !url.hostname)
66
+ return null;
67
+ const port = url.port ? Number(url.port) : 443;
68
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
69
+ return null;
70
+ // URL.hostname keeps brackets around IPv6 literals; net/tls expect the bare address while the
71
+ // HTTP Host field requires brackets. Keep those two representations separate.
72
+ const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
73
+ const headers = lines.filter((line) => !/^proxy-(?:authorization|connection)\s*:/i.test(line) &&
74
+ !/^host\s*:/i.test(line) &&
75
+ !/^connection\s*:/i.test(line));
76
+ const authority = url.port ? `${url.hostname}:${url.port}` : url.hostname;
77
+ const path = `${url.pathname || "/"}${url.search}`;
78
+ // One TLS connection serves one absolute-form request. Reusing it would send the next proxy-form
79
+ // request target directly to the origin, so explicitly ask both peers to close after the response.
80
+ const rewritten = [`${match[1]} ${path} ${match[3]}`, `Host: ${authority}`, "Connection: close", ...headers].join("\r\n") + "\r\n\r\n";
81
+ return { host, port, path, head: Buffer.from(rewritten, "latin1") };
82
+ }
83
+ /**
84
+ * The same header list with one credential swapped for another. Names carried by either credential
85
+ * are removed first: an observed Claude session can have fingerprint headers that a stored login
86
+ * does not, and leaving those behind would combine two distinct client identities on the retry.
87
+ */
88
+ export function withCredential(headers, credential, previous = {}) {
89
+ const replaced = new Set([...Object.keys(previous), ...Object.keys(credential)].map((k) => k.toLowerCase()));
90
+ const out = [];
91
+ for (let i = 0; i < headers.length; i += 2) {
92
+ if (replaced.has(headers[i].toLowerCase()))
93
+ continue;
94
+ out.push(headers[i], headers[i + 1]);
95
+ }
96
+ for (const [k, v] of Object.entries(credential))
97
+ out.push(k, v);
98
+ return out;
99
+ }
100
+ /**
101
+ * A Claude account's headers for one turn, with the client's beta flags kept. The account supplies
102
+ * identity; the flags belong to the request, because they switch on body fields the client sent
103
+ * (`cache_control.scope`, `context_management`, …) and the API refuses those fields without their
104
+ * flag — `Extra inputs are not permitted` (issue #15). The account's own OAuth flags are added to
105
+ * the client's, never used in their place. Only for native Claude accounts: a compatible vendor's
106
+ * beta filtering happens elsewhere and must not be undone here.
107
+ */
108
+ export function withClientBetas(credential, clientBeta) {
109
+ if (clientBeta === undefined)
110
+ return credential;
111
+ const name = Object.keys(credential).find((key) => key.toLowerCase() === "anthropic-beta") ?? "anthropic-beta";
112
+ const flags = (raw) => raw.split(",").map((flag) => flag.trim()).filter(Boolean);
113
+ const merged = [...new Set([...flags([].concat(clientBeta).join(",")), ...flags(credential[name] ?? "")])];
114
+ return { ...credential, [name]: merged.join(",") };
115
+ }
116
+ /**
117
+ * A body decoded for reading, as text. Bytes that cannot be decoded are read as they arrived.
118
+ *
119
+ * Text and not bytes, deliberately. What every caller wants from a compressed body is what it
120
+ * says, and handing the bytes back is a one-way door: invalid UTF-8 sequences become U+FFFD and
121
+ * never come back, while `content-encoding` still stands in the response — which is
122
+ * `ERR_CONTENT_DECODING_FAILED` in the client (2026-09-22). Returning text leaves nothing here
123
+ * that a later caller could mistake for something a client may be given.
124
+ *
125
+ * A body read to a cap stops mid-stream, and a strict decode of that throws (`Z_BUF_ERROR`,
126
+ * measured Node 24), so these decode with a flush: it yields whatever completed, which is the head
127
+ * of the message and the whole reason the body is read. An intact stream decodes identically.
128
+ */
129
+ export function decodeBodyToText(body, encoding) {
130
+ const enc = String(encoding ?? "").toLowerCase();
131
+ const zstd = zlib.zstdDecompressSync;
132
+ const decode = enc === "gzip" || enc === "x-gzip" ? (b) => zlib.gunzipSync(b, { finishFlush: zlib.constants.Z_SYNC_FLUSH })
133
+ : enc === "deflate" ? (b) => zlib.inflateSync(b, { finishFlush: zlib.constants.Z_SYNC_FLUSH })
134
+ : enc === "br" ? (b) => zlib.brotliDecompressSync(b, { finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH })
135
+ // Chromium 152 asks for zstd, so anything the app talks to may answer with it.
136
+ : enc === "zstd" && typeof zstd === "function" ? (b) => zstd(b, { finishFlush: zlib.constants.ZSTD_e_flush })
137
+ : undefined;
138
+ if (!decode)
139
+ return body.toString("utf8");
140
+ try {
141
+ const out = decode(body);
142
+ // A truncated zstd frame yields nothing at all and does not throw doing it, unlike the others
143
+ // (measured 2026-09-22). Describe that instead of returning an empty body, which would read as
144
+ // a provider that refused without saying anything.
145
+ if (out.length === 0 && body.length > 0)
146
+ return `(${body.length}B of ${enc} that stops mid-stream and cannot be decoded)`;
147
+ return out.toString("utf8");
148
+ }
149
+ catch {
150
+ // Nothing decodable even with a flush; fall through to what is readable in the bytes.
151
+ return body.toString("utf8");
152
+ }
153
+ }
41
154
  /**
42
155
  * A bounded, decoded upstream error excerpt with credentials completely masked. Providers may
43
156
  * echo the key they rejected, including an opaque vendor-specific key format.
@@ -48,20 +161,7 @@ export function errorSnippet(head, encoding, secrets = [], contentType) {
48
161
  // OpenRouter answering 200 HTML when the /api prefix was lost).
49
162
  if (/text\/html/i.test(String(contentType ?? "")))
50
163
  return `HTML page (${head.length}B) — the provider URL points at a website or a login page, not an API`;
51
- let out = head;
52
- const enc = String(encoding ?? "").toLowerCase();
53
- try {
54
- if (enc === "gzip" || enc === "x-gzip")
55
- out = zlib.gunzipSync(head);
56
- else if (enc === "deflate")
57
- out = zlib.inflateSync(head);
58
- else if (enc === "br")
59
- out = zlib.brotliDecompressSync(head);
60
- }
61
- catch {
62
- // A truncated compressed body decodes to nothing; fall through to what is readable.
63
- }
64
- const masked = redactErrorText(out.toString("utf8"), secrets, 300);
164
+ const masked = redactErrorText(decodeBodyToText(head, encoding), secrets, 300);
65
165
  return masked || "(empty body)";
66
166
  }
67
167
  export class Proxy {
@@ -75,7 +175,14 @@ export class Proxy {
75
175
  providerAgents = new Map();
76
176
  chatgptAdapters = new Map();
77
177
  openaiAdapters = new Map();
178
+ claudeAccounts;
78
179
  deps;
180
+ /**
181
+ * Cooldowns, quarantines and conversation stickiness for provider credentials. In memory: a
182
+ * cooldown that outlived a restart would make restarting worse, and a rejected credential earns
183
+ * its quarantine again on the first request.
184
+ */
185
+ pool = new CredentialPool();
79
186
  /** Latest rate-limit snapshot reported by any chatgpt provider (for the admin GUI). */
80
187
  get chatgptRateLimits() {
81
188
  const out = {};
@@ -83,6 +190,35 @@ export class Proxy {
83
190
  out[name] = a.adapter.lastRateLimits;
84
191
  return out;
85
192
  }
193
+ /**
194
+ * Ask one chatgpt provider for its quota now. Adapters are created on first use, so a provider
195
+ * nobody has called yet is instantiated from config here — otherwise the admin status would
196
+ * report "no quota" until the first GPT turn of the day.
197
+ */
198
+ chatgptFetchRateLimits(name) {
199
+ const cfg = this.deps.config().providers[name];
200
+ if (!cfg || cfg.type !== "chatgpt")
201
+ return Promise.resolve(null);
202
+ return this.chatgpt(name, cfg).fetchRateLimits();
203
+ }
204
+ /**
205
+ * The models that provider can reach, from the Codex backend's own catalogue, so a model OpenAI
206
+ * ships appears in the GUI and the picker without a release here. Null when the catalogue cannot
207
+ * be read; the caller falls back to a measured list.
208
+ */
209
+ chatgptFetchModels(name) {
210
+ const cfg = this.deps.config().providers[name];
211
+ if (!cfg || cfg.type !== "chatgpt")
212
+ return Promise.resolve(null);
213
+ return this.chatgpt(name, cfg).fetchModels();
214
+ }
215
+ /** Startup refresh: every configured chatgpt provider, in the background, never awaited. */
216
+ chatgptRefreshAll() {
217
+ for (const [name, p] of Object.entries(this.deps.config().providers)) {
218
+ if (p.type === "chatgpt")
219
+ void this.chatgptFetchRateLimits(name);
220
+ }
221
+ }
86
222
  chatgptAuthStatus() {
87
223
  const out = {};
88
224
  for (const [name, a] of this.chatgptAdapters)
@@ -109,6 +245,11 @@ export class Proxy {
109
245
  }
110
246
  constructor(deps) {
111
247
  this.deps = deps;
248
+ this.claudeAccounts = new ClaudeAccountAuthPool({
249
+ home: deps.home,
250
+ log: deps.log,
251
+ ...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
252
+ });
112
253
  this.httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => {
113
254
  void this.handle(req, res);
114
255
  });
@@ -180,6 +321,12 @@ export class Proxy {
180
321
  const line = head.subarray(0, end).toString("latin1").split("\r\n")[0] ?? "";
181
322
  const [method, target] = line.split(" ");
182
323
  if (method !== "CONNECT" || !target) {
324
+ const absolute = absoluteProxyRequest(head.subarray(0, end));
325
+ if (absolute) {
326
+ log.info(`ABSOLUTE ${method ?? "?"} https://${absolute.host}:${absolute.port}${absolute.path}`);
327
+ this.forwardAbsolute(sock, absolute, rest);
328
+ return;
329
+ }
183
330
  sock.end("HTTP/1.1 405 Method Not Allowed\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
184
331
  return;
185
332
  }
@@ -198,6 +345,32 @@ export class Proxy {
198
345
  };
199
346
  sock.on("data", onData);
200
347
  }
348
+ /** Forward an HTTPS absolute-form request without terminating it through the routing layer. */
349
+ forwardAbsolute(sock, request, rest) {
350
+ const servername = net.isIP(request.host) ? undefined : request.host;
351
+ const up = (this.deps.tlsConnect ?? tls.connect)({ host: request.host, port: request.port, ...(servername ? { servername } : {}), ALPNProtocols: ["http/1.1"] });
352
+ const kill = () => {
353
+ sock.destroy();
354
+ up.destroy();
355
+ };
356
+ up.on("error", (e) => {
357
+ this.deps.log.warn(`absolute upstream error ${request.host}:${request.port}: ${e.message}`);
358
+ if (sock.writable)
359
+ sock.end("HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
360
+ up.destroy();
361
+ });
362
+ sock.on("error", kill);
363
+ up.once("secureConnect", () => {
364
+ up.write(request.head);
365
+ if (rest.length)
366
+ up.write(rest);
367
+ sock.pipe(up);
368
+ up.pipe(sock);
369
+ sock.resume();
370
+ });
371
+ up.on("close", () => sock.destroy());
372
+ sock.on("close", () => up.destroy());
373
+ }
201
374
  terminate(sock, host) {
202
375
  let ctx;
203
376
  try {
@@ -275,8 +448,10 @@ export class Proxy {
275
448
  }
276
449
  // ---- per-request handling ----------------------------------------------------------
277
450
  async handle(req, res) {
278
- const cfg = this.deps.config();
279
451
  const log = this.deps.log;
452
+ // The agent files' derived aliases join the config's own, so a marker naming a worker resolves
453
+ // the same way here as it does everywhere else. Explicit aliases win; the scan is mtime-cached.
454
+ const cfg = withAgentAliases(this.deps.config(), this.deps.agentDir ?? defaultAgentDir(), log);
280
455
  const t0 = Date.now();
281
456
  const method = req.method ?? "?";
282
457
  const path = req.url ?? "/";
@@ -371,7 +546,41 @@ export class Proxy {
371
546
  json = null;
372
547
  }
373
548
  }
374
- const route = json ? resolve(model, json, cfg) : null;
549
+ // A WebSearch arrives as its own request aimed at ANTHROPIC_SMALL_FAST_MODEL, recognised by the
550
+ // forced `web_search` server tool rather than by its model id. Recognise it whether or not a
551
+ // backend is configured: `cfg.webSearch` decides who answers it, but knowing that this is a
552
+ // search is what lets the guard below refuse a provider that cannot run one. Tying the two
553
+ // together meant an unconfigured router could not tell a search from any other turn.
554
+ const search = json && isApiHost && pathname === "/v1/messages" ? webSearchQuery(json) : null;
555
+ if (search && cfg.webSearch) {
556
+ const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish);
557
+ if (served)
558
+ return;
559
+ // Fall through on failure: the ordinary path still answers, and a search that quietly
560
+ // returns nothing is worse than one that costs what it always cost.
561
+ }
562
+ const resolved = json ? resolve(model, json, cfg) : null;
563
+ // A non-Claude model this router cannot route is refused here, by name, instead of being
564
+ // forwarded to Anthropic. `PASS` on such a request answers 404 from Anthropic and looks like
565
+ // "the model vanished" (2026-09-20: an agent file named `deepseek` resolved, through a missing
566
+ // alias, to a model no provider declared — thirty of them came back 404). A native `claude-*`
567
+ // id is deliberately unrouted and must keep passing through (§5).
568
+ if (!resolved && isApiHost && isMessages && json && typeof model === "string" && !model.startsWith("claude-")) {
569
+ const reason = unroutableReason(model, json, cfg);
570
+ if (reason) {
571
+ const payload = JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: `ClaudeRipple: ${reason}` } });
572
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }).end(payload);
573
+ tag = `REFUSE ${model}`;
574
+ record = { ...record, provider: "refused" };
575
+ finish("400", payload.length, `ClaudeRipple: ${reason}`, true);
576
+ return;
577
+ }
578
+ }
579
+ // A slot with fallbacks picks its provider before anything is sent, so a primary that is rate
580
+ // limited for the next hour is skipped rather than rediscovered once per request. Failing over
581
+ // mid-turn is not possible — once a byte of the answer has gone out, replacing it would splice
582
+ // two answers together — so the choice has to be made here or not at all.
583
+ const route = resolved ? this.chooseTarget(resolved, cfg, log) : null;
375
584
  const source = typeof model === "string" ? model : "-";
376
585
  record = {
377
586
  ...record,
@@ -386,16 +595,44 @@ export class Proxy {
386
595
  // Do not inspect it elsewhere: it must never enter logs, RequestLog, picker diagnostics, or admin data.
387
596
  if (isApiHost && pathname === "/v1/messages" && !route)
388
597
  this.deps.observedClaudeCodeAuth?.observe(req.rawHeaders);
598
+ // A `WebSearch` side request routed to a provider that cannot run the server tool must not be
599
+ // sent. The tool is dropped on every translated path, which leaves "you are an assistant for
600
+ // performing a web search tool use / perform a web search for the query: …" with no tool
601
+ // attached — and a model told to search with nothing to search with narrates a tool call
602
+ // instead. OpenCode Go's DeepSeek answered exactly that, in its own markup, as HTTP 200
603
+ // (measured 2026-09-18), and the session reported the search tool as unresponsive.
604
+ //
605
+ // Say so in the shape the CLI prints. A search that fails visibly can be retried by a human;
606
+ // one that returns prose shaped like an answer cannot.
607
+ if (search && route && json && !this.canRunServerTools(route.provider, cfg)) {
608
+ const blocks = webSearchErrorBlocks(search, "unavailable");
609
+ const model = typeof json.model === "string" ? json.model : "unknown";
610
+ const wantStream = json.stream === true;
611
+ const payload = wantStream ? webSearchSse(model, blocks, 0) : JSON.stringify(webSearchMessage(model, blocks, 0));
612
+ res.writeHead(200, wantStream
613
+ ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
614
+ : { "content-type": "application/json" }).end(payload);
615
+ log.warn(`web search: ${route.provider} cannot run server tools and no webSearch backend is configured; refused rather than answered with invented results`);
616
+ finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false);
617
+ return;
618
+ }
389
619
  let compatCaps;
390
620
  let compatChanges = [];
621
+ /** The credential this attempt is using, and so the one a failure is charged to. */
622
+ let chosen = null;
623
+ let penalised = null;
391
624
  let target;
392
625
  if (route && json) {
393
- const provider = cfg.providers[route.provider];
394
- if (!provider) {
626
+ const configured = cfg.providers[route.provider];
627
+ if (!configured) {
395
628
  finish("500", 0, `unknown provider ${route.provider}`);
396
629
  res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
397
630
  return;
398
631
  }
632
+ // A model may speak another wire than the rest of its provider: one subscription serving
633
+ // several protocols is one provider, and this is where the model's own shape is folded in so
634
+ // the branches below pick the adapter it actually needs.
635
+ const provider = providerFor(configured, route.model);
399
636
  rewriteBody(json, route, cfg.effortClamp);
400
637
  if (provider.type === "chatgpt") {
401
638
  const td = threadDecision(json);
@@ -414,9 +651,14 @@ export class Proxy {
414
651
  tag = `CHATGPT ${route.tag} effort=${routeEffort ?? "-"}`;
415
652
  try {
416
653
  const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
654
+ // Without this the pool never hears about this provider, so it always looks healthy and a
655
+ // slot pointing at it can never fail over — which is most of the point on a subscription
656
+ // that runs out. The credential here is the adapter's own OAuth, so there is one of it.
657
+ this.recordOutcome(route.provider, o.status);
417
658
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
418
659
  }
419
660
  catch (e) {
661
+ this.recordOutcome(route.provider, 0);
420
662
  finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
421
663
  if (!res.headersSent)
422
664
  res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
@@ -442,9 +684,11 @@ export class Proxy {
442
684
  tag = `OPENAI ${route.tag} wire=${provider.wire ?? "chat"} effort=${routeEffort ?? "-"}`;
443
685
  try {
444
686
  const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
687
+ this.recordOutcome(route.provider, o.status);
445
688
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
446
689
  }
447
690
  catch (e) {
691
+ this.recordOutcome(route.provider, 0);
448
692
  finish("-", 0, `openai error ${e.code ?? ""} ${e.message}`);
449
693
  if (!res.headersSent)
450
694
  res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
@@ -453,48 +697,97 @@ export class Proxy {
453
697
  }
454
698
  return;
455
699
  }
456
- // Unreachable in practice: resolve() drops rules naming a native provider so the request
457
- // passes through instead. Kept as a guard — reaching a native endpoint from here would send
458
- // it a request assembled for a translating provider.
459
700
  if (provider.type === "anthropic") {
460
- finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
461
- res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "invalid_request_error", message: "native Anthropic provider is available through OpenAI ingress only" } }));
462
- return;
701
+ if (!provider.accountPool || provider.auth !== "claude-code") {
702
+ finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
703
+ res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "invalid_request_error", message: "native Anthropic provider is available through OpenAI ingress only" } }));
704
+ return;
705
+ }
706
+ // Native Messages need no translation. Only authentication changes, so server-side threads,
707
+ // prompt caching, beta features and the client's exact tool contract remain first-party.
708
+ const credentials = await this.claudeAccounts.credentials();
709
+ if (credentials.length === 0) {
710
+ const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
711
+ res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
712
+ finish("401", out.length, "no usable Claude account");
713
+ return;
714
+ }
715
+ body = Buffer.from(JSON.stringify(json));
716
+ chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
717
+ const using = chosen ?? credentials[0];
718
+ chosen = using;
719
+ penalised = { provider: route.provider, id: using.id };
720
+ target = {
721
+ protocol: "https:",
722
+ host: cfg.upstream,
723
+ port: this.deps.upstreamPort ?? 443,
724
+ agent: this.deps.upstreamAgent ?? this.agentFor(route.provider, "https:"),
725
+ extraHeaders: withClientBetas(using.headers, req.headers["anthropic-beta"]),
726
+ dropClientAuth: true,
727
+ dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
728
+ dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
729
+ };
730
+ record = { ...record, target: route.model, provider: route.provider };
731
+ const routeEffort = effortOf(json);
732
+ if (routeEffort)
733
+ record.effort = routeEffort;
734
+ tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
735
+ }
736
+ else {
737
+ const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
738
+ const modelEffortLevels = provider.models?.find((entry) => entry.id === route.model)?.effortLevels;
739
+ compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
740
+ const sanitized = sanitizeForCompatible(json, compatCaps);
741
+ json = sanitized.json;
742
+ compatChanges = sanitized.changes;
743
+ // The upstream would otherwise read Claude Code's own system prompt and answer that it is
744
+ // Claude. After sanitizing, so the effort named is the one the provider actually receives.
745
+ applyIdentityToAnthropicBody(json, {
746
+ model: route.model,
747
+ effort: effortOf(json),
748
+ identity: provider.identity,
749
+ instructionsAppend: provider.instructionsAppend,
750
+ });
751
+ body = Buffer.from(JSON.stringify(json));
752
+ const u = new URL(provider.url);
753
+ const protocol = u.protocol === "https:" ? "https:" : "http:";
754
+ // Which credential answers this turn. The conversation keeps the one it is on while that one
755
+ // is healthy, because moving it moves the prompt cache with it. Every credential cooling or
756
+ // quarantined leaves `chosen` null, and the request goes out on the configured headers so the
757
+ // provider — not us — gets to say no.
758
+ const credentials = this.credentialsOf(route.provider, provider);
759
+ // The same key the prompt cache is keyed on. `metadata.user_id` alone is one value for every
760
+ // conversation a user has, so using it raw would drag all of them onto one credential at once
761
+ // — the opposite of what stickiness is for (see conversationKey and ARCHITECTURE §4).
762
+ chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
763
+ // Nothing usable means every credential is cooling, and the turn still has to go somewhere:
764
+ // the first one, so the request carries real credentials rather than none, and so the answer
765
+ // is charged to the credential that actually produced it.
766
+ const using = chosen ?? credentials[0];
767
+ chosen = using;
768
+ penalised = { provider: route.provider, id: using.id };
769
+ target = {
770
+ protocol,
771
+ host: u.hostname,
772
+ port: Number(u.port) || (protocol === "https:" ? 443 : 80),
773
+ agent: this.agentFor(route.provider, protocol),
774
+ // The credential wins over the session header: they are different names in practice, but a
775
+ // vendor that reused one would mean the request going out unauthenticated.
776
+ extraHeaders: provider.sessionHeader
777
+ ? { [provider.sessionHeader]: conversationKey(json), ...using.headers }
778
+ : using.headers,
779
+ dropClientAuth: true,
780
+ // Providers mount their Anthropic-compatible API under a path (DeepSeek /anthropic, OpenRouter /api,
781
+ // Qwen /apps/anthropic): the CLI's /v1/messages is appended to it. Dropping it sent requests to the
782
+ // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
783
+ basePath: u.pathname.replace(/\/+$/, ""),
784
+ };
785
+ record = { ...record, target: route.model, provider: route.provider };
786
+ const routeEffort = effortOf(json);
787
+ if (routeEffort)
788
+ record.effort = routeEffort;
789
+ tag = `${route.provider.toUpperCase()} ${route.tag} effort=${routeEffort ?? "-"}`;
463
790
  }
464
- const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
465
- const modelEffortLevels = provider.models?.find((entry) => entry.id === route.model)?.effortLevels;
466
- compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
467
- const sanitized = sanitizeForCompatible(json, compatCaps);
468
- json = sanitized.json;
469
- compatChanges = sanitized.changes;
470
- // The upstream would otherwise read Claude Code's own system prompt and answer that it is
471
- // Claude. After sanitizing, so the effort named is the one the provider actually receives.
472
- applyIdentityToAnthropicBody(json, {
473
- model: route.model,
474
- effort: effortOf(json),
475
- identity: provider.identity,
476
- instructionsAppend: provider.instructionsAppend,
477
- });
478
- body = Buffer.from(JSON.stringify(json));
479
- const u = new URL(provider.url);
480
- const protocol = u.protocol === "https:" ? "https:" : "http:";
481
- target = {
482
- protocol,
483
- host: u.hostname,
484
- port: Number(u.port) || (protocol === "https:" ? 443 : 80),
485
- agent: this.agentFor(route.provider, protocol),
486
- extraHeaders: provider.headers ?? {},
487
- dropClientAuth: true,
488
- // Providers mount their Anthropic-compatible API under a path (DeepSeek /anthropic, OpenRouter /api,
489
- // Qwen /apps/anthropic): the CLI's /v1/messages is appended to it. Dropping it sent requests to the
490
- // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
491
- basePath: u.pathname.replace(/\/+$/, ""),
492
- };
493
- record = { ...record, target: route.model, provider: route.provider };
494
- const routeEffort = effortOf(json);
495
- if (routeEffort)
496
- record.effort = routeEffort;
497
- tag = `${route.provider.toUpperCase()} ${route.tag} effort=${routeEffort ?? "-"}`;
498
791
  }
499
792
  else if (isApiHost) {
500
793
  target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
@@ -517,7 +810,7 @@ export class Proxy {
517
810
  continue;
518
811
  // Bootstrap responses are edited: keep the client's accept-encoding as-is (some edges misbehave without it)
519
812
  // and decompress whatever comes back before editing.
520
- if (lk in target.extraHeaders)
813
+ if (lk in target.extraHeaders || target.dropHeaders?.has(lk) || target.dropHeaderPrefixes?.some((prefix) => lk.startsWith(prefix)))
521
814
  continue;
522
815
  if (target.dropClientAuth && CLIENT_AUTH.has(lk))
523
816
  continue;
@@ -547,22 +840,80 @@ export class Proxy {
547
840
  }
548
841
  log.info(`PICKER request ${method} ${path.slice(0, 80)} headers: ${shown.join(" | ")}`);
549
842
  }
550
- // Keep only actual credentials named by the outbound headers. This covers arbitrary vendor
551
- // key formats when a provider reflects the key in its error body.
552
- const errorSecrets = credentialHeaderValues(Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]]));
843
+ // Keep every credential this turn sends. A retry can use an opaque token that does not match a
844
+ // known token shape; retaining its actual value is what keeps a reflected refusal out of logs.
845
+ const errorSecrets = new Set(credentialHeaderValues(Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]])));
846
+ // Every retry is rebuilt from the first attempt's header list. Keep the union of identity header
847
+ // names already used so a current-session fingerprint cannot reappear on a third account.
848
+ let usedCredentialHeaders = { ...(chosen?.headers ?? {}) };
553
849
  const lib = target.protocol === "https:" ? https : http;
554
- const upReq = lib.request({
555
- protocol: target.protocol,
556
- host: target.host,
557
- port: target.port,
558
- method,
559
- path: target.basePath ? target.basePath + path : path,
560
- headers,
561
- agent: target.agent,
562
- ...(target.protocol === "https:" ? { servername: target.host } : {}),
563
- });
850
+ /**
851
+ * The attempt in flight. A routed turn may make more than one: when a credential is refused and
852
+ * nothing has reached the client yet, the next credential takes over inside the same turn, so
853
+ * the client is answered on its first ask instead of having to retry. `send` below replaces
854
+ * this; everything that reaches for the upstream reaches for it through here.
855
+ */
856
+ let upReq;
857
+ /** Credentials already spent on this turn, so a retry cannot pick one of them again. */
858
+ const tried = new Set(penalised ? [penalised.id] : []);
859
+ const send = (attemptHeaders) => {
860
+ upReq = lib.request({
861
+ protocol: target.protocol,
862
+ host: target.host,
863
+ port: target.port,
864
+ method,
865
+ path: target.basePath ? target.basePath + path : path,
866
+ headers: attemptHeaders,
867
+ agent: target.agent,
868
+ ...(target.protocol === "https:" ? { servername: target.host } : {}),
869
+ });
870
+ upReq.on("error", onUpstreamError);
871
+ upReq.on("response", onUpstreamResponse);
872
+ upReq.end(body);
873
+ };
874
+ /**
875
+ * The next credential to try inside this turn, or null to answer with what the provider said.
876
+ * Only for a routed request whose provider has one we have not already spent — a failure that
877
+ * is the request's own fault is not retried at all (see `classify`).
878
+ */
879
+ const nextCredential = (status, body = "") => {
880
+ if (!route || !penalised || res.headersSent)
881
+ return null;
882
+ if (!classify(status, undefined, body).retryable)
883
+ return null;
884
+ const owner = cfg.providers[route.provider];
885
+ if (!owner)
886
+ return null;
887
+ // The model's own shape again: its credential pool is the provider's, but an auth convention
888
+ // this model overrides has to be the one the retry actually sends.
889
+ const provider = providerFor(owner, route.model);
890
+ const native = provider.type === "anthropic" && provider.accountPool;
891
+ const available = native ? this.claudeAccounts.peekCredentials() : this.credentialsOf(route.provider, provider);
892
+ const rest = available.filter((c) => !tried.has(c.id));
893
+ if (rest.length === 0)
894
+ return null;
895
+ const next = this.pool.pick(route.provider, rest, conversationKey(json));
896
+ return next && native ? { ...next, headers: withClientBetas(next.headers, req.headers["anthropic-beta"]) } : next;
897
+ };
898
+ // Destroying the upstream makes it emit ECONNRESET (measured, Node 24.15), which is
899
+ // indistinguishable from the provider dropping us unless we remember that we did it. Without
900
+ // this flag every cancelled turn charged the credential a failure and moved the conversation
901
+ // off it, which costs the prompt cache — for a turn the user cancelled on purpose.
902
+ let clientAborted = false;
903
+ // Set when a 403 body was read ahead of the forward, so the forward knows to write that body
904
+ // instead of the stream it came from.
905
+ let headFilled = false;
906
+ // The 403 body exactly as it arrived. It is what the client is given: the judgement below
907
+ // reads a decoded copy, but a compressed body cannot survive a round trip through a UTF-8
908
+ // string — invalid sequences become U+FFFD and never come back. Returning that while
909
+ // `content-encoding` still stands is `ERR_CONTENT_DECODING_FAILED` in the app (2026-09-22).
910
+ let errRaw = Buffer.alloc(0);
911
+ // Set when the body was longer than the read cap, so its bytes are no longer a whole
912
+ // compressed stream and only the decoded head can be forwarded.
913
+ let errTruncated = false;
564
914
  const abortUpstream = () => {
565
- if (!upReq.destroyed)
915
+ clientAborted = true;
916
+ if (upReq && !upReq.destroyed)
566
917
  upReq.destroy();
567
918
  };
568
919
  req.on("aborted", abortUpstream);
@@ -572,9 +923,14 @@ export class Proxy {
572
923
  finish("-", 0, "client closed");
573
924
  }
574
925
  });
575
- upReq.on("error", (e) => {
926
+ const onUpstreamError = (e) => {
576
927
  if (target.host === cfg.upstream)
577
928
  this.deps.health.failure(e);
929
+ // Nothing reached the provider, so this says nothing about the credential — but it does say
930
+ // the route is unusable for a moment, and a pool with somewhere else to go should use it.
931
+ // Our own abort is not the provider's fault and must not be charged to it.
932
+ if (penalised && !clientAborted)
933
+ this.pool.penalise(penalised.provider, penalised.id, 0);
578
934
  finish("-", 0, `upstream error ${e.code ?? ""} ${e.message}`);
579
935
  if (!res.headersSent) {
580
936
  res.writeHead(502, { "content-type": "application/json", connection: "close" });
@@ -583,11 +939,100 @@ export class Proxy {
583
939
  else {
584
940
  res.destroy();
585
941
  }
586
- });
587
- upReq.on("response", (upRes) => {
942
+ };
943
+ const onUpstreamResponse = (upRes) => {
588
944
  if (target.host === cfg.upstream)
589
945
  this.deps.health.success();
590
946
  const status = upRes.statusCode ?? 0;
947
+ // A 403 is the one status whose meaning is in its body. A relay reporting a broken upstream
948
+ // must not be charged to the credential — that is what made one working key answer every turn
949
+ // with an authentication error for a minute (2026-09-21). Every other status is judged from
950
+ // the status alone, and its body is left for the forward below, untouched.
951
+ if (status === 403) {
952
+ bufferBody(upRes, ERROR_HEAD_MAX).then((whole) => onUpstreamJudged(upRes, status, whole)).catch((e) => {
953
+ log.warn(`upstream ${status}: could not read the body: ${e.message}`);
954
+ finish(String(status), 0, `upstream ${status}: body read failed`);
955
+ res.destroy();
956
+ });
957
+ return;
958
+ }
959
+ onUpstreamJudged(upRes, status, "");
960
+ };
961
+ /**
962
+ * The whole of a small body, read before anything is decided or written.
963
+ *
964
+ * A 403's meaning is in its body and cannot be judged before it arrives, so the bytes are read
965
+ * and handed back as the head: `unshift` after a stream has ended throws, and reading a head and
966
+ * forwarding the rest mid-flow is where a body can be lost to a pause nothing resumes. A 403 is
967
+ * a refusal, so its body is small.
968
+ *
969
+ * What is returned is the body **decoded for reading**, because the judgement is about what the
970
+ * body says. The bytes themselves are kept in `errRaw` for the client: a compressed body is
971
+ * arbitrary binary, and text is a one-way door for it.
972
+ *
973
+ * Past `max` there is nothing worth identifying, so the rest is dropped — but the stream is
974
+ * never destroyed. Tearing the connection down over a body size would turn a refusal the user
975
+ * can read into a socket error, which is a worse bug than the one this handles; an unbounded
976
+ * relay answer instead reaches the forward above the cap and is reported there.
977
+ */
978
+ const bufferBody = (stream, max) => new Promise((resolveBody) => {
979
+ const chunks = [];
980
+ let size = 0;
981
+ stream.on("data", (c) => {
982
+ const room = max - size;
983
+ if (room <= 0) {
984
+ errTruncated = true;
985
+ stream.resume();
986
+ return;
987
+ }
988
+ if (c.length > room)
989
+ errTruncated = true;
990
+ chunks.push(c.subarray(0, room));
991
+ size += Math.min(c.length, room);
992
+ });
993
+ const handOver = () => {
994
+ errRaw = Buffer.concat(chunks, size);
995
+ headFilled = true;
996
+ resolveBody(decodeBodyToText(errRaw, stream.headers["content-encoding"]));
997
+ };
998
+ stream.once("end", handOver);
999
+ // A dropped or failed stream still hands over what arrived; the caller's own upstream error
1000
+ // path is already attached, and duplicating it here would race it for the same response.
1001
+ stream.once("error", handOver);
1002
+ });
1003
+ const onUpstreamJudged = (upRes, status, errBody) => {
1004
+ // Charge the answer to the credential that produced it. A rate limit parks this one until its
1005
+ // stated reset so the next request takes another; an answer clears whatever it was carrying.
1006
+ // A 400 is our own request and is charged to nobody (see `classify`).
1007
+ if (penalised) {
1008
+ if (status >= 400) {
1009
+ this.pool.penalise(penalised.provider, penalised.id, status, retryAfterMs(upRes.headers), errBody);
1010
+ if (status === 401 && cfg.providers[penalised.provider]?.type === "anthropic")
1011
+ this.claudeAccounts.reject(penalised.id);
1012
+ }
1013
+ else
1014
+ this.pool.succeed(penalised.provider, penalised.id);
1015
+ }
1016
+ // Nothing has been written to the client yet, so a refused credential can still be replaced
1017
+ // and the client answered on its first ask. Only here: once the answer starts flowing the
1018
+ // turn is committed, because replacing a half-sent stream splices two answers together.
1019
+ const retry = status >= 400 ? nextCredential(status, errBody) : null;
1020
+ if (retry) {
1021
+ const previousId = retry.ownerId ? penalised.id.split(":", 1)[0] : penalised.id;
1022
+ log.info(`RETRY ${route.provider}: ${previousId} answered ${status}, trying ${retry.ownerId ?? retry.id}`);
1023
+ tried.add(retry.id);
1024
+ usedCredentialHeaders = { ...usedCredentialHeaders, ...(chosen?.headers ?? {}), ...retry.headers };
1025
+ chosen = retry;
1026
+ penalised = { provider: route.provider, id: retry.id };
1027
+ for (const secret of credentialHeaderValues(Object.entries(retry.headers)))
1028
+ errorSecrets.add(secret);
1029
+ upRes.resume();
1030
+ upRes.destroy();
1031
+ // Remove every identity header used by any earlier attempt. The baseline is the first request,
1032
+ // so removing only the immediately previous account would resurrect first-attempt fingerprints.
1033
+ send(withCredential(headers, retry.headers, usedCredentialHeaders));
1034
+ return;
1035
+ }
591
1036
  const outHeaders = [];
592
1037
  const r = upRes.rawHeaders;
593
1038
  for (let i = 0; i < r.length; i += 2) {
@@ -598,6 +1043,10 @@ export class Proxy {
598
1043
  continue;
599
1044
  if (isPickerBootstrap && (lk === "etag" || lk === "last-modified"))
600
1045
  continue;
1046
+ // A truncated 403 is forwarded decoded (its bytes are no longer a whole compressed
1047
+ // stream), so neither the upstream's encoding nor its length describes what is sent.
1048
+ if (errTruncated && (lk === "content-length" || lk === "content-encoding"))
1049
+ continue;
601
1050
  outHeaders.push(r[i], r[i + 1]);
602
1051
  }
603
1052
  let bytes = 0;
@@ -629,7 +1078,7 @@ export class Proxy {
629
1078
  }
630
1079
  }
631
1080
  else if (isPickerBootstrap && status !== 200 && status !== 304) {
632
- log.warn(`PICKER bootstrap upstream ${status}; headers: ${JSON.stringify(redactHeaders(upRes.headers))}; body: ${redactErrorText(out.toString("utf8"), errorSecrets, 300)}`);
1081
+ log.warn(`PICKER bootstrap upstream ${status}; headers: ${JSON.stringify(redactHeaders(upRes.headers))}; body: ${redactErrorText(out.toString("utf8"), [...errorSecrets], 300)}`);
633
1082
  }
634
1083
  else if (status === 200 && isPickerBootstrap) {
635
1084
  try {
@@ -668,6 +1117,17 @@ export class Proxy {
668
1117
  // code was recorded (2026-09-15).
669
1118
  const errorHead = [];
670
1119
  let errorHeadBytes = 0;
1120
+ // The 403 body was read to judge it, so it is written here instead of read again from a
1121
+ // stream that has already ended. The bytes go back exactly as they arrived, so a compressed
1122
+ // body still matches the `content-encoding` and `content-length` the upstream stated; only a
1123
+ // truncated one is sent decoded, and both headers were dropped above for it.
1124
+ if (headFilled) {
1125
+ const body = errTruncated ? Buffer.from(errBody, "utf8") : errRaw;
1126
+ bytes = body.length;
1127
+ res.end(body);
1128
+ finish(String(status), bytes, `upstream ${status}: ${redactErrorText(errBody, [...errorSecrets], 300)}`);
1129
+ return;
1130
+ }
671
1131
  upRes.on("data", (c) => {
672
1132
  bytes += c.length;
673
1133
  const writable = res.write(c);
@@ -686,14 +1146,185 @@ export class Proxy {
686
1146
  observedUsage = observed.usage;
687
1147
  observedStopReason = observed.stopReason;
688
1148
  res.end();
689
- finish(String(status), bytes, status >= 400 ? `upstream ${status}: ${errorSnippet(Buffer.concat(errorHead), upRes.headers["content-encoding"], errorSecrets, upRes.headers["content-type"])}` : undefined);
1149
+ finish(String(status), bytes, status >= 400 ? `upstream ${status}: ${errorSnippet(Buffer.concat(errorHead), upRes.headers["content-encoding"], [...errorSecrets], upRes.headers["content-type"])}` : undefined);
690
1150
  });
691
1151
  upRes.on("error", (e) => {
692
1152
  finish(String(status), bytes, `upstream stream error ${e.message}`);
693
1153
  res.destroy();
694
1154
  });
695
- });
696
- upReq.end(body);
1155
+ };
1156
+ send(headers);
1157
+ }
1158
+ /**
1159
+ * Answer a WebSearch side request from a provider's hosted search instead of letting it reach a
1160
+ * model. Returns false when it could not be served, and the caller falls back to the normal path:
1161
+ * an empty search result is the one outcome worth avoiding, because nothing anywhere reports it.
1162
+ */
1163
+ async serveWebSearch(res, json, query, settings, record, finish) {
1164
+ const configured = this.deps.config().providers[settings.provider];
1165
+ if (!configured) {
1166
+ this.deps.log.warn(`web search: unknown provider ${settings.provider}; leaving the request alone`);
1167
+ return false;
1168
+ }
1169
+ // Which backend is chosen below depends on the wire the search model speaks, which may not be
1170
+ // the provider's own.
1171
+ const provider = providerFor(configured, settings.model);
1172
+ const url = "url" in provider && typeof provider.url === "string" ? provider.url : undefined;
1173
+ if (!url && provider.type !== "chatgpt") {
1174
+ this.deps.log.warn(`web search: provider ${settings.provider} has no url; leaving the request alone`);
1175
+ return false;
1176
+ }
1177
+ // Which backend depends on how the provider is spoken to, not on the vendor. An
1178
+ // anthropic-compatible one is asked in Anthropic's own shape and hands back the blocks the CLI
1179
+ // already parses; an openai-compatible one is asked through its chat web plugin.
1180
+ const common = {
1181
+ name: settings.provider,
1182
+ url: url ?? "",
1183
+ headers: ("headers" in provider && provider.headers) || {},
1184
+ model: settings.model,
1185
+ ...(settings.maxResults ? { maxResults: settings.maxResults } : {}),
1186
+ };
1187
+ let backend;
1188
+ if (provider.type === "anthropic-compatible") {
1189
+ // Refuse before sending rather than after. A provider that cannot run the server tool is sent
1190
+ // "perform a web search" with no tool attached, and a model told to search with nothing to
1191
+ // search with narrates a tool call instead — an answer shaped like success, holding nothing.
1192
+ if (!this.canRunServerTools(settings.provider, this.deps.config())) {
1193
+ this.deps.log.warn(`web search: provider ${settings.provider} does not run server tools; leaving the request alone`);
1194
+ return false;
1195
+ }
1196
+ backend = anthropicServerToolBackend(common);
1197
+ }
1198
+ else if (provider.type === "openai-compatible") {
1199
+ backend = webPluginBackend(common);
1200
+ }
1201
+ else if (provider.type === "chatgpt") {
1202
+ backend = this.chatgpt(settings.provider, provider).webSearch(settings.model, settings.maxResults);
1203
+ }
1204
+ else {
1205
+ this.deps.log.warn(`web search: provider ${settings.provider} is a ${provider.type} provider, which has no search backend; leaving the request alone`);
1206
+ return false;
1207
+ }
1208
+ const model = typeof json.model === "string" ? json.model : "unknown";
1209
+ let blocks;
1210
+ try {
1211
+ const outcome = await backend.search(query);
1212
+ blocks = webSearchBlocks(query, outcome).blocks;
1213
+ }
1214
+ catch (e) {
1215
+ this.deps.log.warn(`web search via ${settings.provider}: ${e.message}`);
1216
+ return false;
1217
+ }
1218
+ record.target = `${settings.provider}/${settings.model}`;
1219
+ const wantStream = json.stream === true;
1220
+ const payload = wantStream ? webSearchSse(model, blocks, 1) : JSON.stringify(webSearchMessage(model, blocks, 1));
1221
+ const headers = wantStream
1222
+ ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
1223
+ : { "content-type": "application/json" };
1224
+ res.writeHead(200, headers).end(payload);
1225
+ finish("200", Buffer.byteLength(payload), `web search via ${settings.provider}`, false);
1226
+ return true;
1227
+ }
1228
+ /**
1229
+ * Whether this provider executes Anthropic's server-side tools (`web_search`) itself, rather than
1230
+ * having them dropped on the way out. Only an anthropic-compatible provider whose preset or caps
1231
+ * say so, measured per provider — `serverTools` in compat.ts. Two routes to the same vendor can
1232
+ * differ: DeepSeek's own endpoint runs it, the same model through OpenCode Go does not.
1233
+ */
1234
+ canRunServerTools(name, cfg) {
1235
+ const provider = cfg.providers[name];
1236
+ // A routed native account still calls Anthropic's Messages API unchanged, so Anthropic executes
1237
+ // its own server tools. Only translated/compatible providers need an explicit measured capability.
1238
+ if (provider?.type === "anthropic")
1239
+ return provider.accountPool === true && provider.auth === "claude-code";
1240
+ if (!provider || provider.type !== "anthropic-compatible")
1241
+ return false;
1242
+ const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
1243
+ return resolveCompatibleCaps(preset ? { ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, provider.caps).serverTools;
1244
+ }
1245
+ /**
1246
+ * The provider this turn actually goes to. The primary unless every one of its credentials is
1247
+ * cooling or quarantined, in which case the first fallback that has something usable takes it.
1248
+ *
1249
+ * When nothing anywhere is usable the primary is kept: the provider gets to refuse the request
1250
+ * rather than the router inventing a refusal, and the answer it gives is what updates the pool.
1251
+ */
1252
+ chooseTarget(resolved, cfg, log) {
1253
+ if (!resolved.fallbacks?.length)
1254
+ return resolved;
1255
+ const usable = (name) => {
1256
+ const provider = cfg.providers[name];
1257
+ if (!provider)
1258
+ return false;
1259
+ if (provider.type === "anthropic") {
1260
+ if (!provider.accountPool || provider.auth !== "claude-code")
1261
+ return false;
1262
+ const accounts = this.claudeAccounts.peekCredentials();
1263
+ return accounts.length > 0 && this.pool.hasUsable(name, accounts);
1264
+ }
1265
+ return this.pool.hasUsable(name, this.credentialsOf(name, provider));
1266
+ };
1267
+ if (usable(resolved.provider))
1268
+ return resolved;
1269
+ for (const f of resolved.fallbacks) {
1270
+ if (f.provider === resolved.provider && f.model === resolved.model)
1271
+ continue;
1272
+ if (!usable(f.provider))
1273
+ continue;
1274
+ log.info(`FAILOVER ${resolved.provider}/${resolved.model} exhausted → ${f.provider}/${f.model}`);
1275
+ // The tag names the model that answers, not the one that could not: a log line saying `->m1`
1276
+ // for a turn that ran on m2 is the kind of thing that costs an hour to disbelieve.
1277
+ const asked = resolved.tag.split("->")[0] ?? resolved.model;
1278
+ return {
1279
+ provider: f.provider,
1280
+ model: f.model,
1281
+ effort: f.effort ?? resolved.effort,
1282
+ tag: `${asked}->${f.model} (failover from ${resolved.provider})`,
1283
+ };
1284
+ }
1285
+ return resolved;
1286
+ }
1287
+ /**
1288
+ * Tell the pool how a translated provider's turn went. These adapters hold their own credential —
1289
+ * an OAuth grant, or configured headers — so there is one of it, but the pool still has to hear
1290
+ * about the result or `hasUsable` says yes forever and a slot can never fail over away.
1291
+ *
1292
+ * A status of 0 means the attempt threw before an answer, which is a connect failure.
1293
+ */
1294
+ recordOutcome(provider, status) {
1295
+ const id = "default";
1296
+ if (status >= 400 || status === 0)
1297
+ this.pool.penalise(provider, id, status);
1298
+ else if (status > 0)
1299
+ this.pool.succeed(provider, id);
1300
+ }
1301
+ /**
1302
+ * A provider's credentials as a pool. A provider that declares none has exactly the one it always
1303
+ * had, under a fixed id so its health survives config edits that do not touch it.
1304
+ */
1305
+ credentialsOf(name, provider) {
1306
+ const declared = provider.credentials;
1307
+ if (declared && declared.length > 0)
1308
+ return declared;
1309
+ const headers = ("headers" in provider && provider.headers) || {};
1310
+ return [{ id: "default", headers }];
1311
+ }
1312
+ /** Health of every credential the config declares, for the dashboard. */
1313
+ credentialHealth() {
1314
+ const cfg = this.deps.config();
1315
+ const out = {};
1316
+ for (const [name, provider] of Object.entries(cfg.providers)) {
1317
+ const creds = provider.type === "anthropic" && provider.accountPool
1318
+ ? this.claudeAccounts.peekCredentials()
1319
+ : this.credentialsOf(name, provider);
1320
+ if (creds.length === 0 || (creds.length === 1 && creds[0].id === "default"))
1321
+ continue; // nothing to report about a pool of one
1322
+ out[name] = this.pool.report(name, creds).map((report) => {
1323
+ const credential = creds.find((candidate) => candidate.id === report.id);
1324
+ return credential?.ownerId ? { ...report, id: credential.ownerId } : report;
1325
+ });
1326
+ }
1327
+ return out;
697
1328
  }
698
1329
  agentFor(provider, protocol) {
699
1330
  const key = `${provider}|${protocol}`;