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
@@ -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,98 @@ 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 body decoded for reading, as text. Bytes that cannot be decoded are read as they arrived.
102
+ *
103
+ * Text and not bytes, deliberately. What every caller wants from a compressed body is what it
104
+ * says, and handing the bytes back is a one-way door: invalid UTF-8 sequences become U+FFFD and
105
+ * never come back, while `content-encoding` still stands in the response — which is
106
+ * `ERR_CONTENT_DECODING_FAILED` in the client (2026-09-22). Returning text leaves nothing here
107
+ * that a later caller could mistake for something a client may be given.
108
+ *
109
+ * A body read to a cap stops mid-stream, and a strict decode of that throws (`Z_BUF_ERROR`,
110
+ * measured Node 24), so these decode with a flush: it yields whatever completed, which is the head
111
+ * of the message and the whole reason the body is read. An intact stream decodes identically.
112
+ */
113
+ export function decodeBodyToText(body, encoding) {
114
+ const enc = String(encoding ?? "").toLowerCase();
115
+ const zstd = zlib.zstdDecompressSync;
116
+ const decode = enc === "gzip" || enc === "x-gzip" ? (b) => zlib.gunzipSync(b, { finishFlush: zlib.constants.Z_SYNC_FLUSH })
117
+ : enc === "deflate" ? (b) => zlib.inflateSync(b, { finishFlush: zlib.constants.Z_SYNC_FLUSH })
118
+ : enc === "br" ? (b) => zlib.brotliDecompressSync(b, { finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH })
119
+ // Chromium 152 asks for zstd, so anything the app talks to may answer with it.
120
+ : enc === "zstd" && typeof zstd === "function" ? (b) => zstd(b, { finishFlush: zlib.constants.ZSTD_e_flush })
121
+ : undefined;
122
+ if (!decode)
123
+ return body.toString("utf8");
124
+ try {
125
+ const out = decode(body);
126
+ // A truncated zstd frame yields nothing at all and does not throw doing it, unlike the others
127
+ // (measured 2026-09-22). Describe that instead of returning an empty body, which would read as
128
+ // a provider that refused without saying anything.
129
+ if (out.length === 0 && body.length > 0)
130
+ return `(${body.length}B of ${enc} that stops mid-stream and cannot be decoded)`;
131
+ return out.toString("utf8");
132
+ }
133
+ catch {
134
+ // Nothing decodable even with a flush; fall through to what is readable in the bytes.
135
+ return body.toString("utf8");
136
+ }
137
+ }
41
138
  /**
42
139
  * A bounded, decoded upstream error excerpt with credentials completely masked. Providers may
43
140
  * echo the key they rejected, including an opaque vendor-specific key format.
@@ -48,20 +145,7 @@ export function errorSnippet(head, encoding, secrets = [], contentType) {
48
145
  // OpenRouter answering 200 HTML when the /api prefix was lost).
49
146
  if (/text\/html/i.test(String(contentType ?? "")))
50
147
  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);
148
+ const masked = redactErrorText(decodeBodyToText(head, encoding), secrets, 300);
65
149
  return masked || "(empty body)";
66
150
  }
67
151
  export class Proxy {
@@ -75,7 +159,14 @@ export class Proxy {
75
159
  providerAgents = new Map();
76
160
  chatgptAdapters = new Map();
77
161
  openaiAdapters = new Map();
162
+ claudeAccounts;
78
163
  deps;
164
+ /**
165
+ * Cooldowns, quarantines and conversation stickiness for provider credentials. In memory: a
166
+ * cooldown that outlived a restart would make restarting worse, and a rejected credential earns
167
+ * its quarantine again on the first request.
168
+ */
169
+ pool = new CredentialPool();
79
170
  /** Latest rate-limit snapshot reported by any chatgpt provider (for the admin GUI). */
80
171
  get chatgptRateLimits() {
81
172
  const out = {};
@@ -83,6 +174,35 @@ export class Proxy {
83
174
  out[name] = a.adapter.lastRateLimits;
84
175
  return out;
85
176
  }
177
+ /**
178
+ * Ask one chatgpt provider for its quota now. Adapters are created on first use, so a provider
179
+ * nobody has called yet is instantiated from config here — otherwise the admin status would
180
+ * report "no quota" until the first GPT turn of the day.
181
+ */
182
+ chatgptFetchRateLimits(name) {
183
+ const cfg = this.deps.config().providers[name];
184
+ if (!cfg || cfg.type !== "chatgpt")
185
+ return Promise.resolve(null);
186
+ return this.chatgpt(name, cfg).fetchRateLimits();
187
+ }
188
+ /**
189
+ * The models that provider can reach, from the Codex backend's own catalogue, so a model OpenAI
190
+ * ships appears in the GUI and the picker without a release here. Null when the catalogue cannot
191
+ * be read; the caller falls back to a measured list.
192
+ */
193
+ chatgptFetchModels(name) {
194
+ const cfg = this.deps.config().providers[name];
195
+ if (!cfg || cfg.type !== "chatgpt")
196
+ return Promise.resolve(null);
197
+ return this.chatgpt(name, cfg).fetchModels();
198
+ }
199
+ /** Startup refresh: every configured chatgpt provider, in the background, never awaited. */
200
+ chatgptRefreshAll() {
201
+ for (const [name, p] of Object.entries(this.deps.config().providers)) {
202
+ if (p.type === "chatgpt")
203
+ void this.chatgptFetchRateLimits(name);
204
+ }
205
+ }
86
206
  chatgptAuthStatus() {
87
207
  const out = {};
88
208
  for (const [name, a] of this.chatgptAdapters)
@@ -109,6 +229,11 @@ export class Proxy {
109
229
  }
110
230
  constructor(deps) {
111
231
  this.deps = deps;
232
+ this.claudeAccounts = new ClaudeAccountAuthPool({
233
+ home: deps.home,
234
+ log: deps.log,
235
+ ...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
236
+ });
112
237
  this.httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => {
113
238
  void this.handle(req, res);
114
239
  });
@@ -180,6 +305,12 @@ export class Proxy {
180
305
  const line = head.subarray(0, end).toString("latin1").split("\r\n")[0] ?? "";
181
306
  const [method, target] = line.split(" ");
182
307
  if (method !== "CONNECT" || !target) {
308
+ const absolute = absoluteProxyRequest(head.subarray(0, end));
309
+ if (absolute) {
310
+ log.info(`ABSOLUTE ${method ?? "?"} https://${absolute.host}:${absolute.port}${absolute.path}`);
311
+ this.forwardAbsolute(sock, absolute, rest);
312
+ return;
313
+ }
183
314
  sock.end("HTTP/1.1 405 Method Not Allowed\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
184
315
  return;
185
316
  }
@@ -198,6 +329,32 @@ export class Proxy {
198
329
  };
199
330
  sock.on("data", onData);
200
331
  }
332
+ /** Forward an HTTPS absolute-form request without terminating it through the routing layer. */
333
+ forwardAbsolute(sock, request, rest) {
334
+ const servername = net.isIP(request.host) ? undefined : request.host;
335
+ const up = (this.deps.tlsConnect ?? tls.connect)({ host: request.host, port: request.port, ...(servername ? { servername } : {}), ALPNProtocols: ["http/1.1"] });
336
+ const kill = () => {
337
+ sock.destroy();
338
+ up.destroy();
339
+ };
340
+ up.on("error", (e) => {
341
+ this.deps.log.warn(`absolute upstream error ${request.host}:${request.port}: ${e.message}`);
342
+ if (sock.writable)
343
+ sock.end("HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
344
+ up.destroy();
345
+ });
346
+ sock.on("error", kill);
347
+ up.once("secureConnect", () => {
348
+ up.write(request.head);
349
+ if (rest.length)
350
+ up.write(rest);
351
+ sock.pipe(up);
352
+ up.pipe(sock);
353
+ sock.resume();
354
+ });
355
+ up.on("close", () => sock.destroy());
356
+ sock.on("close", () => up.destroy());
357
+ }
201
358
  terminate(sock, host) {
202
359
  let ctx;
203
360
  try {
@@ -275,8 +432,10 @@ export class Proxy {
275
432
  }
276
433
  // ---- per-request handling ----------------------------------------------------------
277
434
  async handle(req, res) {
278
- const cfg = this.deps.config();
279
435
  const log = this.deps.log;
436
+ // The agent files' derived aliases join the config's own, so a marker naming a worker resolves
437
+ // the same way here as it does everywhere else. Explicit aliases win; the scan is mtime-cached.
438
+ const cfg = withAgentAliases(this.deps.config(), this.deps.agentDir ?? defaultAgentDir(), log);
280
439
  const t0 = Date.now();
281
440
  const method = req.method ?? "?";
282
441
  const path = req.url ?? "/";
@@ -371,7 +530,41 @@ export class Proxy {
371
530
  json = null;
372
531
  }
373
532
  }
374
- const route = json ? resolve(model, json, cfg) : null;
533
+ // A WebSearch arrives as its own request aimed at ANTHROPIC_SMALL_FAST_MODEL, recognised by the
534
+ // forced `web_search` server tool rather than by its model id. Recognise it whether or not a
535
+ // backend is configured: `cfg.webSearch` decides who answers it, but knowing that this is a
536
+ // search is what lets the guard below refuse a provider that cannot run one. Tying the two
537
+ // together meant an unconfigured router could not tell a search from any other turn.
538
+ const search = json && isApiHost && pathname === "/v1/messages" ? webSearchQuery(json) : null;
539
+ if (search && cfg.webSearch) {
540
+ const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish);
541
+ if (served)
542
+ return;
543
+ // Fall through on failure: the ordinary path still answers, and a search that quietly
544
+ // returns nothing is worse than one that costs what it always cost.
545
+ }
546
+ const resolved = json ? resolve(model, json, cfg) : null;
547
+ // A non-Claude model this router cannot route is refused here, by name, instead of being
548
+ // forwarded to Anthropic. `PASS` on such a request answers 404 from Anthropic and looks like
549
+ // "the model vanished" (2026-09-20: an agent file named `deepseek` resolved, through a missing
550
+ // alias, to a model no provider declared — thirty of them came back 404). A native `claude-*`
551
+ // id is deliberately unrouted and must keep passing through (§5).
552
+ if (!resolved && isApiHost && isMessages && json && typeof model === "string" && !model.startsWith("claude-")) {
553
+ const reason = unroutableReason(model, json, cfg);
554
+ if (reason) {
555
+ const payload = JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: `ClaudeRipple: ${reason}` } });
556
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }).end(payload);
557
+ tag = `REFUSE ${model}`;
558
+ record = { ...record, provider: "refused" };
559
+ finish("400", payload.length, `ClaudeRipple: ${reason}`, true);
560
+ return;
561
+ }
562
+ }
563
+ // A slot with fallbacks picks its provider before anything is sent, so a primary that is rate
564
+ // limited for the next hour is skipped rather than rediscovered once per request. Failing over
565
+ // mid-turn is not possible — once a byte of the answer has gone out, replacing it would splice
566
+ // two answers together — so the choice has to be made here or not at all.
567
+ const route = resolved ? this.chooseTarget(resolved, cfg, log) : null;
375
568
  const source = typeof model === "string" ? model : "-";
376
569
  record = {
377
570
  ...record,
@@ -386,16 +579,44 @@ export class Proxy {
386
579
  // Do not inspect it elsewhere: it must never enter logs, RequestLog, picker diagnostics, or admin data.
387
580
  if (isApiHost && pathname === "/v1/messages" && !route)
388
581
  this.deps.observedClaudeCodeAuth?.observe(req.rawHeaders);
582
+ // A `WebSearch` side request routed to a provider that cannot run the server tool must not be
583
+ // sent. The tool is dropped on every translated path, which leaves "you are an assistant for
584
+ // performing a web search tool use / perform a web search for the query: …" with no tool
585
+ // attached — and a model told to search with nothing to search with narrates a tool call
586
+ // instead. OpenCode Go's DeepSeek answered exactly that, in its own markup, as HTTP 200
587
+ // (measured 2026-09-18), and the session reported the search tool as unresponsive.
588
+ //
589
+ // Say so in the shape the CLI prints. A search that fails visibly can be retried by a human;
590
+ // one that returns prose shaped like an answer cannot.
591
+ if (search && route && json && !this.canRunServerTools(route.provider, cfg)) {
592
+ const blocks = webSearchErrorBlocks(search, "unavailable");
593
+ const model = typeof json.model === "string" ? json.model : "unknown";
594
+ const wantStream = json.stream === true;
595
+ const payload = wantStream ? webSearchSse(model, blocks, 0) : JSON.stringify(webSearchMessage(model, blocks, 0));
596
+ res.writeHead(200, wantStream
597
+ ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
598
+ : { "content-type": "application/json" }).end(payload);
599
+ log.warn(`web search: ${route.provider} cannot run server tools and no webSearch backend is configured; refused rather than answered with invented results`);
600
+ finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false);
601
+ return;
602
+ }
389
603
  let compatCaps;
390
604
  let compatChanges = [];
605
+ /** The credential this attempt is using, and so the one a failure is charged to. */
606
+ let chosen = null;
607
+ let penalised = null;
391
608
  let target;
392
609
  if (route && json) {
393
- const provider = cfg.providers[route.provider];
394
- if (!provider) {
610
+ const configured = cfg.providers[route.provider];
611
+ if (!configured) {
395
612
  finish("500", 0, `unknown provider ${route.provider}`);
396
613
  res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
397
614
  return;
398
615
  }
616
+ // A model may speak another wire than the rest of its provider: one subscription serving
617
+ // several protocols is one provider, and this is where the model's own shape is folded in so
618
+ // the branches below pick the adapter it actually needs.
619
+ const provider = providerFor(configured, route.model);
399
620
  rewriteBody(json, route, cfg.effortClamp);
400
621
  if (provider.type === "chatgpt") {
401
622
  const td = threadDecision(json);
@@ -414,9 +635,14 @@ export class Proxy {
414
635
  tag = `CHATGPT ${route.tag} effort=${routeEffort ?? "-"}`;
415
636
  try {
416
637
  const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
638
+ // Without this the pool never hears about this provider, so it always looks healthy and a
639
+ // slot pointing at it can never fail over — which is most of the point on a subscription
640
+ // that runs out. The credential here is the adapter's own OAuth, so there is one of it.
641
+ this.recordOutcome(route.provider, o.status);
417
642
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
418
643
  }
419
644
  catch (e) {
645
+ this.recordOutcome(route.provider, 0);
420
646
  finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
421
647
  if (!res.headersSent)
422
648
  res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
@@ -442,9 +668,11 @@ export class Proxy {
442
668
  tag = `OPENAI ${route.tag} wire=${provider.wire ?? "chat"} effort=${routeEffort ?? "-"}`;
443
669
  try {
444
670
  const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
671
+ this.recordOutcome(route.provider, o.status);
445
672
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
446
673
  }
447
674
  catch (e) {
675
+ this.recordOutcome(route.provider, 0);
448
676
  finish("-", 0, `openai error ${e.code ?? ""} ${e.message}`);
449
677
  if (!res.headersSent)
450
678
  res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
@@ -453,48 +681,97 @@ export class Proxy {
453
681
  }
454
682
  return;
455
683
  }
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
684
  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;
685
+ if (!provider.accountPool || provider.auth !== "claude-code") {
686
+ finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
687
+ 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" } }));
688
+ return;
689
+ }
690
+ // Native Messages need no translation. Only authentication changes, so server-side threads,
691
+ // prompt caching, beta features and the client's exact tool contract remain first-party.
692
+ const credentials = await this.claudeAccounts.credentials();
693
+ if (credentials.length === 0) {
694
+ const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
695
+ res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
696
+ finish("401", out.length, "no usable Claude account");
697
+ return;
698
+ }
699
+ body = Buffer.from(JSON.stringify(json));
700
+ chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
701
+ const using = chosen ?? credentials[0];
702
+ chosen = using;
703
+ penalised = { provider: route.provider, id: using.id };
704
+ target = {
705
+ protocol: "https:",
706
+ host: cfg.upstream,
707
+ port: this.deps.upstreamPort ?? 443,
708
+ agent: this.deps.upstreamAgent ?? this.agentFor(route.provider, "https:"),
709
+ extraHeaders: using.headers,
710
+ dropClientAuth: true,
711
+ dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
712
+ dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
713
+ };
714
+ record = { ...record, target: route.model, provider: route.provider };
715
+ const routeEffort = effortOf(json);
716
+ if (routeEffort)
717
+ record.effort = routeEffort;
718
+ tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
719
+ }
720
+ else {
721
+ const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
722
+ const modelEffortLevels = provider.models?.find((entry) => entry.id === route.model)?.effortLevels;
723
+ compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
724
+ const sanitized = sanitizeForCompatible(json, compatCaps);
725
+ json = sanitized.json;
726
+ compatChanges = sanitized.changes;
727
+ // The upstream would otherwise read Claude Code's own system prompt and answer that it is
728
+ // Claude. After sanitizing, so the effort named is the one the provider actually receives.
729
+ applyIdentityToAnthropicBody(json, {
730
+ model: route.model,
731
+ effort: effortOf(json),
732
+ identity: provider.identity,
733
+ instructionsAppend: provider.instructionsAppend,
734
+ });
735
+ body = Buffer.from(JSON.stringify(json));
736
+ const u = new URL(provider.url);
737
+ const protocol = u.protocol === "https:" ? "https:" : "http:";
738
+ // Which credential answers this turn. The conversation keeps the one it is on while that one
739
+ // is healthy, because moving it moves the prompt cache with it. Every credential cooling or
740
+ // quarantined leaves `chosen` null, and the request goes out on the configured headers so the
741
+ // provider — not us — gets to say no.
742
+ const credentials = this.credentialsOf(route.provider, provider);
743
+ // The same key the prompt cache is keyed on. `metadata.user_id` alone is one value for every
744
+ // conversation a user has, so using it raw would drag all of them onto one credential at once
745
+ // — the opposite of what stickiness is for (see conversationKey and ARCHITECTURE §4).
746
+ chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
747
+ // Nothing usable means every credential is cooling, and the turn still has to go somewhere:
748
+ // the first one, so the request carries real credentials rather than none, and so the answer
749
+ // is charged to the credential that actually produced it.
750
+ const using = chosen ?? credentials[0];
751
+ chosen = using;
752
+ penalised = { provider: route.provider, id: using.id };
753
+ target = {
754
+ protocol,
755
+ host: u.hostname,
756
+ port: Number(u.port) || (protocol === "https:" ? 443 : 80),
757
+ agent: this.agentFor(route.provider, protocol),
758
+ // The credential wins over the session header: they are different names in practice, but a
759
+ // vendor that reused one would mean the request going out unauthenticated.
760
+ extraHeaders: provider.sessionHeader
761
+ ? { [provider.sessionHeader]: conversationKey(json), ...using.headers }
762
+ : using.headers,
763
+ dropClientAuth: true,
764
+ // Providers mount their Anthropic-compatible API under a path (DeepSeek /anthropic, OpenRouter /api,
765
+ // Qwen /apps/anthropic): the CLI's /v1/messages is appended to it. Dropping it sent requests to the
766
+ // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
767
+ basePath: u.pathname.replace(/\/+$/, ""),
768
+ };
769
+ record = { ...record, target: route.model, provider: route.provider };
770
+ const routeEffort = effortOf(json);
771
+ if (routeEffort)
772
+ record.effort = routeEffort;
773
+ tag = `${route.provider.toUpperCase()} ${route.tag} effort=${routeEffort ?? "-"}`;
463
774
  }
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
775
  }
499
776
  else if (isApiHost) {
500
777
  target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
@@ -517,7 +794,7 @@ export class Proxy {
517
794
  continue;
518
795
  // Bootstrap responses are edited: keep the client's accept-encoding as-is (some edges misbehave without it)
519
796
  // and decompress whatever comes back before editing.
520
- if (lk in target.extraHeaders)
797
+ if (lk in target.extraHeaders || target.dropHeaders?.has(lk) || target.dropHeaderPrefixes?.some((prefix) => lk.startsWith(prefix)))
521
798
  continue;
522
799
  if (target.dropClientAuth && CLIENT_AUTH.has(lk))
523
800
  continue;
@@ -547,22 +824,80 @@ export class Proxy {
547
824
  }
548
825
  log.info(`PICKER request ${method} ${path.slice(0, 80)} headers: ${shown.join(" | ")}`);
549
826
  }
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]]));
827
+ // Keep every credential this turn sends. A retry can use an opaque token that does not match a
828
+ // known token shape; retaining its actual value is what keeps a reflected refusal out of logs.
829
+ const errorSecrets = new Set(credentialHeaderValues(Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]])));
830
+ // Every retry is rebuilt from the first attempt's header list. Keep the union of identity header
831
+ // names already used so a current-session fingerprint cannot reappear on a third account.
832
+ let usedCredentialHeaders = { ...(chosen?.headers ?? {}) };
553
833
  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
- });
834
+ /**
835
+ * The attempt in flight. A routed turn may make more than one: when a credential is refused and
836
+ * nothing has reached the client yet, the next credential takes over inside the same turn, so
837
+ * the client is answered on its first ask instead of having to retry. `send` below replaces
838
+ * this; everything that reaches for the upstream reaches for it through here.
839
+ */
840
+ let upReq;
841
+ /** Credentials already spent on this turn, so a retry cannot pick one of them again. */
842
+ const tried = new Set(penalised ? [penalised.id] : []);
843
+ const send = (attemptHeaders) => {
844
+ upReq = lib.request({
845
+ protocol: target.protocol,
846
+ host: target.host,
847
+ port: target.port,
848
+ method,
849
+ path: target.basePath ? target.basePath + path : path,
850
+ headers: attemptHeaders,
851
+ agent: target.agent,
852
+ ...(target.protocol === "https:" ? { servername: target.host } : {}),
853
+ });
854
+ upReq.on("error", onUpstreamError);
855
+ upReq.on("response", onUpstreamResponse);
856
+ upReq.end(body);
857
+ };
858
+ /**
859
+ * The next credential to try inside this turn, or null to answer with what the provider said.
860
+ * Only for a routed request whose provider has one we have not already spent — a failure that
861
+ * is the request's own fault is not retried at all (see `classify`).
862
+ */
863
+ const nextCredential = (status, body = "") => {
864
+ if (!route || !penalised || res.headersSent)
865
+ return null;
866
+ if (!classify(status, undefined, body).retryable)
867
+ return null;
868
+ const owner = cfg.providers[route.provider];
869
+ if (!owner)
870
+ return null;
871
+ // The model's own shape again: its credential pool is the provider's, but an auth convention
872
+ // this model overrides has to be the one the retry actually sends.
873
+ const provider = providerFor(owner, route.model);
874
+ const available = provider.type === "anthropic" && provider.accountPool
875
+ ? this.claudeAccounts.peekCredentials()
876
+ : this.credentialsOf(route.provider, provider);
877
+ const rest = available.filter((c) => !tried.has(c.id));
878
+ if (rest.length === 0)
879
+ return null;
880
+ return this.pool.pick(route.provider, rest, conversationKey(json));
881
+ };
882
+ // Destroying the upstream makes it emit ECONNRESET (measured, Node 24.15), which is
883
+ // indistinguishable from the provider dropping us unless we remember that we did it. Without
884
+ // this flag every cancelled turn charged the credential a failure and moved the conversation
885
+ // off it, which costs the prompt cache — for a turn the user cancelled on purpose.
886
+ let clientAborted = false;
887
+ // Set when a 403 body was read ahead of the forward, so the forward knows to write that body
888
+ // instead of the stream it came from.
889
+ let headFilled = false;
890
+ // The 403 body exactly as it arrived. It is what the client is given: the judgement below
891
+ // reads a decoded copy, but a compressed body cannot survive a round trip through a UTF-8
892
+ // string — invalid sequences become U+FFFD and never come back. Returning that while
893
+ // `content-encoding` still stands is `ERR_CONTENT_DECODING_FAILED` in the app (2026-09-22).
894
+ let errRaw = Buffer.alloc(0);
895
+ // Set when the body was longer than the read cap, so its bytes are no longer a whole
896
+ // compressed stream and only the decoded head can be forwarded.
897
+ let errTruncated = false;
564
898
  const abortUpstream = () => {
565
- if (!upReq.destroyed)
899
+ clientAborted = true;
900
+ if (upReq && !upReq.destroyed)
566
901
  upReq.destroy();
567
902
  };
568
903
  req.on("aborted", abortUpstream);
@@ -572,9 +907,14 @@ export class Proxy {
572
907
  finish("-", 0, "client closed");
573
908
  }
574
909
  });
575
- upReq.on("error", (e) => {
910
+ const onUpstreamError = (e) => {
576
911
  if (target.host === cfg.upstream)
577
912
  this.deps.health.failure(e);
913
+ // Nothing reached the provider, so this says nothing about the credential — but it does say
914
+ // the route is unusable for a moment, and a pool with somewhere else to go should use it.
915
+ // Our own abort is not the provider's fault and must not be charged to it.
916
+ if (penalised && !clientAborted)
917
+ this.pool.penalise(penalised.provider, penalised.id, 0);
578
918
  finish("-", 0, `upstream error ${e.code ?? ""} ${e.message}`);
579
919
  if (!res.headersSent) {
580
920
  res.writeHead(502, { "content-type": "application/json", connection: "close" });
@@ -583,11 +923,100 @@ export class Proxy {
583
923
  else {
584
924
  res.destroy();
585
925
  }
586
- });
587
- upReq.on("response", (upRes) => {
926
+ };
927
+ const onUpstreamResponse = (upRes) => {
588
928
  if (target.host === cfg.upstream)
589
929
  this.deps.health.success();
590
930
  const status = upRes.statusCode ?? 0;
931
+ // A 403 is the one status whose meaning is in its body. A relay reporting a broken upstream
932
+ // must not be charged to the credential — that is what made one working key answer every turn
933
+ // with an authentication error for a minute (2026-09-21). Every other status is judged from
934
+ // the status alone, and its body is left for the forward below, untouched.
935
+ if (status === 403) {
936
+ bufferBody(upRes, ERROR_HEAD_MAX).then((whole) => onUpstreamJudged(upRes, status, whole)).catch((e) => {
937
+ log.warn(`upstream ${status}: could not read the body: ${e.message}`);
938
+ finish(String(status), 0, `upstream ${status}: body read failed`);
939
+ res.destroy();
940
+ });
941
+ return;
942
+ }
943
+ onUpstreamJudged(upRes, status, "");
944
+ };
945
+ /**
946
+ * The whole of a small body, read before anything is decided or written.
947
+ *
948
+ * A 403's meaning is in its body and cannot be judged before it arrives, so the bytes are read
949
+ * and handed back as the head: `unshift` after a stream has ended throws, and reading a head and
950
+ * forwarding the rest mid-flow is where a body can be lost to a pause nothing resumes. A 403 is
951
+ * a refusal, so its body is small.
952
+ *
953
+ * What is returned is the body **decoded for reading**, because the judgement is about what the
954
+ * body says. The bytes themselves are kept in `errRaw` for the client: a compressed body is
955
+ * arbitrary binary, and text is a one-way door for it.
956
+ *
957
+ * Past `max` there is nothing worth identifying, so the rest is dropped — but the stream is
958
+ * never destroyed. Tearing the connection down over a body size would turn a refusal the user
959
+ * can read into a socket error, which is a worse bug than the one this handles; an unbounded
960
+ * relay answer instead reaches the forward above the cap and is reported there.
961
+ */
962
+ const bufferBody = (stream, max) => new Promise((resolveBody) => {
963
+ const chunks = [];
964
+ let size = 0;
965
+ stream.on("data", (c) => {
966
+ const room = max - size;
967
+ if (room <= 0) {
968
+ errTruncated = true;
969
+ stream.resume();
970
+ return;
971
+ }
972
+ if (c.length > room)
973
+ errTruncated = true;
974
+ chunks.push(c.subarray(0, room));
975
+ size += Math.min(c.length, room);
976
+ });
977
+ const handOver = () => {
978
+ errRaw = Buffer.concat(chunks, size);
979
+ headFilled = true;
980
+ resolveBody(decodeBodyToText(errRaw, stream.headers["content-encoding"]));
981
+ };
982
+ stream.once("end", handOver);
983
+ // A dropped or failed stream still hands over what arrived; the caller's own upstream error
984
+ // path is already attached, and duplicating it here would race it for the same response.
985
+ stream.once("error", handOver);
986
+ });
987
+ const onUpstreamJudged = (upRes, status, errBody) => {
988
+ // Charge the answer to the credential that produced it. A rate limit parks this one until its
989
+ // stated reset so the next request takes another; an answer clears whatever it was carrying.
990
+ // A 400 is our own request and is charged to nobody (see `classify`).
991
+ if (penalised) {
992
+ if (status >= 400) {
993
+ this.pool.penalise(penalised.provider, penalised.id, status, retryAfterMs(upRes.headers), errBody);
994
+ if (status === 401 && cfg.providers[penalised.provider]?.type === "anthropic")
995
+ this.claudeAccounts.reject(penalised.id);
996
+ }
997
+ else
998
+ this.pool.succeed(penalised.provider, penalised.id);
999
+ }
1000
+ // Nothing has been written to the client yet, so a refused credential can still be replaced
1001
+ // and the client answered on its first ask. Only here: once the answer starts flowing the
1002
+ // turn is committed, because replacing a half-sent stream splices two answers together.
1003
+ const retry = status >= 400 ? nextCredential(status, errBody) : null;
1004
+ if (retry) {
1005
+ const previousId = retry.ownerId ? penalised.id.split(":", 1)[0] : penalised.id;
1006
+ log.info(`RETRY ${route.provider}: ${previousId} answered ${status}, trying ${retry.ownerId ?? retry.id}`);
1007
+ tried.add(retry.id);
1008
+ usedCredentialHeaders = { ...usedCredentialHeaders, ...(chosen?.headers ?? {}), ...retry.headers };
1009
+ chosen = retry;
1010
+ penalised = { provider: route.provider, id: retry.id };
1011
+ for (const secret of credentialHeaderValues(Object.entries(retry.headers)))
1012
+ errorSecrets.add(secret);
1013
+ upRes.resume();
1014
+ upRes.destroy();
1015
+ // Remove every identity header used by any earlier attempt. The baseline is the first request,
1016
+ // so removing only the immediately previous account would resurrect first-attempt fingerprints.
1017
+ send(withCredential(headers, retry.headers, usedCredentialHeaders));
1018
+ return;
1019
+ }
591
1020
  const outHeaders = [];
592
1021
  const r = upRes.rawHeaders;
593
1022
  for (let i = 0; i < r.length; i += 2) {
@@ -598,6 +1027,10 @@ export class Proxy {
598
1027
  continue;
599
1028
  if (isPickerBootstrap && (lk === "etag" || lk === "last-modified"))
600
1029
  continue;
1030
+ // A truncated 403 is forwarded decoded (its bytes are no longer a whole compressed
1031
+ // stream), so neither the upstream's encoding nor its length describes what is sent.
1032
+ if (errTruncated && (lk === "content-length" || lk === "content-encoding"))
1033
+ continue;
601
1034
  outHeaders.push(r[i], r[i + 1]);
602
1035
  }
603
1036
  let bytes = 0;
@@ -629,7 +1062,7 @@ export class Proxy {
629
1062
  }
630
1063
  }
631
1064
  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)}`);
1065
+ log.warn(`PICKER bootstrap upstream ${status}; headers: ${JSON.stringify(redactHeaders(upRes.headers))}; body: ${redactErrorText(out.toString("utf8"), [...errorSecrets], 300)}`);
633
1066
  }
634
1067
  else if (status === 200 && isPickerBootstrap) {
635
1068
  try {
@@ -668,6 +1101,17 @@ export class Proxy {
668
1101
  // code was recorded (2026-09-15).
669
1102
  const errorHead = [];
670
1103
  let errorHeadBytes = 0;
1104
+ // The 403 body was read to judge it, so it is written here instead of read again from a
1105
+ // stream that has already ended. The bytes go back exactly as they arrived, so a compressed
1106
+ // body still matches the `content-encoding` and `content-length` the upstream stated; only a
1107
+ // truncated one is sent decoded, and both headers were dropped above for it.
1108
+ if (headFilled) {
1109
+ const body = errTruncated ? Buffer.from(errBody, "utf8") : errRaw;
1110
+ bytes = body.length;
1111
+ res.end(body);
1112
+ finish(String(status), bytes, `upstream ${status}: ${redactErrorText(errBody, [...errorSecrets], 300)}`);
1113
+ return;
1114
+ }
671
1115
  upRes.on("data", (c) => {
672
1116
  bytes += c.length;
673
1117
  const writable = res.write(c);
@@ -686,14 +1130,185 @@ export class Proxy {
686
1130
  observedUsage = observed.usage;
687
1131
  observedStopReason = observed.stopReason;
688
1132
  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);
1133
+ finish(String(status), bytes, status >= 400 ? `upstream ${status}: ${errorSnippet(Buffer.concat(errorHead), upRes.headers["content-encoding"], [...errorSecrets], upRes.headers["content-type"])}` : undefined);
690
1134
  });
691
1135
  upRes.on("error", (e) => {
692
1136
  finish(String(status), bytes, `upstream stream error ${e.message}`);
693
1137
  res.destroy();
694
1138
  });
695
- });
696
- upReq.end(body);
1139
+ };
1140
+ send(headers);
1141
+ }
1142
+ /**
1143
+ * Answer a WebSearch side request from a provider's hosted search instead of letting it reach a
1144
+ * model. Returns false when it could not be served, and the caller falls back to the normal path:
1145
+ * an empty search result is the one outcome worth avoiding, because nothing anywhere reports it.
1146
+ */
1147
+ async serveWebSearch(res, json, query, settings, record, finish) {
1148
+ const configured = this.deps.config().providers[settings.provider];
1149
+ if (!configured) {
1150
+ this.deps.log.warn(`web search: unknown provider ${settings.provider}; leaving the request alone`);
1151
+ return false;
1152
+ }
1153
+ // Which backend is chosen below depends on the wire the search model speaks, which may not be
1154
+ // the provider's own.
1155
+ const provider = providerFor(configured, settings.model);
1156
+ const url = "url" in provider && typeof provider.url === "string" ? provider.url : undefined;
1157
+ if (!url && provider.type !== "chatgpt") {
1158
+ this.deps.log.warn(`web search: provider ${settings.provider} has no url; leaving the request alone`);
1159
+ return false;
1160
+ }
1161
+ // Which backend depends on how the provider is spoken to, not on the vendor. An
1162
+ // anthropic-compatible one is asked in Anthropic's own shape and hands back the blocks the CLI
1163
+ // already parses; an openai-compatible one is asked through its chat web plugin.
1164
+ const common = {
1165
+ name: settings.provider,
1166
+ url: url ?? "",
1167
+ headers: ("headers" in provider && provider.headers) || {},
1168
+ model: settings.model,
1169
+ ...(settings.maxResults ? { maxResults: settings.maxResults } : {}),
1170
+ };
1171
+ let backend;
1172
+ if (provider.type === "anthropic-compatible") {
1173
+ // Refuse before sending rather than after. A provider that cannot run the server tool is sent
1174
+ // "perform a web search" with no tool attached, and a model told to search with nothing to
1175
+ // search with narrates a tool call instead — an answer shaped like success, holding nothing.
1176
+ if (!this.canRunServerTools(settings.provider, this.deps.config())) {
1177
+ this.deps.log.warn(`web search: provider ${settings.provider} does not run server tools; leaving the request alone`);
1178
+ return false;
1179
+ }
1180
+ backend = anthropicServerToolBackend(common);
1181
+ }
1182
+ else if (provider.type === "openai-compatible") {
1183
+ backend = webPluginBackend(common);
1184
+ }
1185
+ else if (provider.type === "chatgpt") {
1186
+ backend = this.chatgpt(settings.provider, provider).webSearch(settings.model, settings.maxResults);
1187
+ }
1188
+ else {
1189
+ this.deps.log.warn(`web search: provider ${settings.provider} is a ${provider.type} provider, which has no search backend; leaving the request alone`);
1190
+ return false;
1191
+ }
1192
+ const model = typeof json.model === "string" ? json.model : "unknown";
1193
+ let blocks;
1194
+ try {
1195
+ const outcome = await backend.search(query);
1196
+ blocks = webSearchBlocks(query, outcome).blocks;
1197
+ }
1198
+ catch (e) {
1199
+ this.deps.log.warn(`web search via ${settings.provider}: ${e.message}`);
1200
+ return false;
1201
+ }
1202
+ record.target = `${settings.provider}/${settings.model}`;
1203
+ const wantStream = json.stream === true;
1204
+ const payload = wantStream ? webSearchSse(model, blocks, 1) : JSON.stringify(webSearchMessage(model, blocks, 1));
1205
+ const headers = wantStream
1206
+ ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
1207
+ : { "content-type": "application/json" };
1208
+ res.writeHead(200, headers).end(payload);
1209
+ finish("200", Buffer.byteLength(payload), `web search via ${settings.provider}`, false);
1210
+ return true;
1211
+ }
1212
+ /**
1213
+ * Whether this provider executes Anthropic's server-side tools (`web_search`) itself, rather than
1214
+ * having them dropped on the way out. Only an anthropic-compatible provider whose preset or caps
1215
+ * say so, measured per provider — `serverTools` in compat.ts. Two routes to the same vendor can
1216
+ * differ: DeepSeek's own endpoint runs it, the same model through OpenCode Go does not.
1217
+ */
1218
+ canRunServerTools(name, cfg) {
1219
+ const provider = cfg.providers[name];
1220
+ // A routed native account still calls Anthropic's Messages API unchanged, so Anthropic executes
1221
+ // its own server tools. Only translated/compatible providers need an explicit measured capability.
1222
+ if (provider?.type === "anthropic")
1223
+ return provider.accountPool === true && provider.auth === "claude-code";
1224
+ if (!provider || provider.type !== "anthropic-compatible")
1225
+ return false;
1226
+ const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
1227
+ return resolveCompatibleCaps(preset ? { ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, provider.caps).serverTools;
1228
+ }
1229
+ /**
1230
+ * The provider this turn actually goes to. The primary unless every one of its credentials is
1231
+ * cooling or quarantined, in which case the first fallback that has something usable takes it.
1232
+ *
1233
+ * When nothing anywhere is usable the primary is kept: the provider gets to refuse the request
1234
+ * rather than the router inventing a refusal, and the answer it gives is what updates the pool.
1235
+ */
1236
+ chooseTarget(resolved, cfg, log) {
1237
+ if (!resolved.fallbacks?.length)
1238
+ return resolved;
1239
+ const usable = (name) => {
1240
+ const provider = cfg.providers[name];
1241
+ if (!provider)
1242
+ return false;
1243
+ if (provider.type === "anthropic") {
1244
+ if (!provider.accountPool || provider.auth !== "claude-code")
1245
+ return false;
1246
+ const accounts = this.claudeAccounts.peekCredentials();
1247
+ return accounts.length > 0 && this.pool.hasUsable(name, accounts);
1248
+ }
1249
+ return this.pool.hasUsable(name, this.credentialsOf(name, provider));
1250
+ };
1251
+ if (usable(resolved.provider))
1252
+ return resolved;
1253
+ for (const f of resolved.fallbacks) {
1254
+ if (f.provider === resolved.provider && f.model === resolved.model)
1255
+ continue;
1256
+ if (!usable(f.provider))
1257
+ continue;
1258
+ log.info(`FAILOVER ${resolved.provider}/${resolved.model} exhausted → ${f.provider}/${f.model}`);
1259
+ // The tag names the model that answers, not the one that could not: a log line saying `->m1`
1260
+ // for a turn that ran on m2 is the kind of thing that costs an hour to disbelieve.
1261
+ const asked = resolved.tag.split("->")[0] ?? resolved.model;
1262
+ return {
1263
+ provider: f.provider,
1264
+ model: f.model,
1265
+ effort: f.effort ?? resolved.effort,
1266
+ tag: `${asked}->${f.model} (failover from ${resolved.provider})`,
1267
+ };
1268
+ }
1269
+ return resolved;
1270
+ }
1271
+ /**
1272
+ * Tell the pool how a translated provider's turn went. These adapters hold their own credential —
1273
+ * an OAuth grant, or configured headers — so there is one of it, but the pool still has to hear
1274
+ * about the result or `hasUsable` says yes forever and a slot can never fail over away.
1275
+ *
1276
+ * A status of 0 means the attempt threw before an answer, which is a connect failure.
1277
+ */
1278
+ recordOutcome(provider, status) {
1279
+ const id = "default";
1280
+ if (status >= 400 || status === 0)
1281
+ this.pool.penalise(provider, id, status);
1282
+ else if (status > 0)
1283
+ this.pool.succeed(provider, id);
1284
+ }
1285
+ /**
1286
+ * A provider's credentials as a pool. A provider that declares none has exactly the one it always
1287
+ * had, under a fixed id so its health survives config edits that do not touch it.
1288
+ */
1289
+ credentialsOf(name, provider) {
1290
+ const declared = provider.credentials;
1291
+ if (declared && declared.length > 0)
1292
+ return declared;
1293
+ const headers = ("headers" in provider && provider.headers) || {};
1294
+ return [{ id: "default", headers }];
1295
+ }
1296
+ /** Health of every credential the config declares, for the dashboard. */
1297
+ credentialHealth() {
1298
+ const cfg = this.deps.config();
1299
+ const out = {};
1300
+ for (const [name, provider] of Object.entries(cfg.providers)) {
1301
+ const creds = provider.type === "anthropic" && provider.accountPool
1302
+ ? this.claudeAccounts.peekCredentials()
1303
+ : this.credentialsOf(name, provider);
1304
+ if (creds.length === 0 || (creds.length === 1 && creds[0].id === "default"))
1305
+ continue; // nothing to report about a pool of one
1306
+ out[name] = this.pool.report(name, creds).map((report) => {
1307
+ const credential = creds.find((candidate) => candidate.id === report.id);
1308
+ return credential?.ownerId ? { ...report, id: credential.ownerId } : report;
1309
+ });
1310
+ }
1311
+ return out;
697
1312
  }
698
1313
  agentFor(provider, protocol) {
699
1314
  const key = `${provider}|${protocol}`;