clauderipple 0.12.0 → 0.12.2

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.
@@ -35,7 +35,8 @@ import { GrokAdapter } from "./providers/grok/index.js";
35
35
  import { conversationKey } from "./providers/chatgpt/translate.js";
36
36
  import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
37
37
  import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
38
- import { effortLevelsForModel } from "./admin.js";
38
+ import { effortLevels, effortLevelsForModel } from "./admin.js";
39
+ import { isWebFetchRequest } from "./webfetch.js";
39
40
  import { ResponseUsageTap, requestId } from "./requestlog.js";
40
41
  import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
41
42
  import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
@@ -806,765 +807,826 @@ export class Proxy {
806
807
  // Fall through on failure: the ordinary path still answers, and a search that quietly
807
808
  // returns nothing is worse than one that costs what it always cost.
808
809
  }
809
- const resolved = json ? resolve(model, json, cfg) : null;
810
- // A non-Claude model this router cannot route is refused here, by name, instead of being
811
- // forwarded to Anthropic. `PASS` on such a request answers 404 from Anthropic and looks like
812
- // "the model vanished" (2026-09-20: an agent file named `deepseek` resolved, through a missing
813
- // alias, to a model no provider declared — thirty of them came back 404). A native `claude-*`
814
- // id is deliberately unrouted and must keep passing through (§5).
815
- if (!resolved && isApiHost && isMessages && json && typeof model === "string" && !model.startsWith("claude-")) {
816
- const reason = unroutableReason(model, json, cfg);
817
- if (reason) {
818
- const payload = JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: `ClaudeRipple: ${reason}` } });
819
- res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }).end(payload);
820
- tag = `REFUSE ${model}`;
821
- record = { ...record, provider: "refused" };
822
- finish("400", payload.length, `ClaudeRipple: ${reason}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unroutable" });
823
- return;
810
+ const webFetch = !!(cfg.webSearch && json && pathname === "/v1/messages" && isWebFetchRequest(json));
811
+ let fetchRoute = null;
812
+ if (webFetch)
813
+ record.purpose = "web-fetch";
814
+ if (webFetch && cfg.webSearch) {
815
+ const settings = cfg.webSearch;
816
+ if (this.targetUsable(settings, cfg)) {
817
+ const ladder = effortLevels(cfg).providers[settings.provider];
818
+ const levels = ladder?.models?.[settings.model] ?? ladder?.default ?? [];
819
+ fetchRoute = { ...settings, effort: levels.includes("low") ? "low" : undefined, tag: `${String(model)}->${settings.model} (web fetch)` };
820
+ }
821
+ else {
822
+ unwrittenFallbackNote = `web fetch via ${settings.provider} unavailable; original destination fallback`;
823
+ log.warn(`web fetch via ${settings.provider}: provider unusable; leaving the request alone`);
824
824
  }
825
825
  }
826
- // A slot with fallbacks picks its provider before anything is sent, so a primary that is rate
827
- // limited for the next hour is skipped rather than rediscovered once per request. A distinct
828
- // prompt refusal can also select one fallback below, but only before any response is written.
829
- let route = resolved ? this.chooseTarget(resolved, cfg, log) : null;
830
- /** The request before any target rewrote it, kept only when a Google target may hand the turn back. */
831
- let pristine;
832
- const source = typeof model === "string" ? model : "-";
833
- record = {
834
- ...record,
835
- source,
836
- target: source,
837
- stream: json?.stream === true,
838
- };
839
- const requestedEffort = effortOf(json ?? {});
840
- if (requestedEffort)
841
- record.effort = requestedEffort;
842
- // Only a real, un-routed Claude Code Messages request may refresh this RAM-only source.
843
- // Do not inspect it elsewhere: it must never enter logs, RequestLog, picker diagnostics, or admin data.
844
- if (isApiHost && pathname === "/v1/messages" && !route)
845
- this.deps.observedClaudeCodeAuth?.observe(req.rawHeaders);
846
- // A `WebSearch` side request routed to a provider that cannot run the server tool must not be
847
- // sent. The tool is dropped on every translated path, which leaves "you are an assistant for
848
- // performing a web search tool use / perform a web search for the query: …" with no tool
849
- // attached — and a model told to search with nothing to search with narrates a tool call
850
- // instead. OpenCode Go's DeepSeek answered exactly that, in its own markup, as HTTP 200
851
- // (measured 2026-09-18), and the session reported the search tool as unresponsive.
852
- //
853
- // Say so in the shape the CLI prints. A search that fails visibly can be retried by a human;
854
- // one that returns prose shaped like an answer cannot.
855
- if (search && route && json && !this.canRunServerTools(route.provider, cfg)) {
856
- // A ChatGPT provider has a search of its own — the backend `webSearch` names — so a search
857
- // routed there is served by it, not refused. Requiring `webSearch` as well refused every
858
- // search of a `smallFast` slot pointed at Luna while the Clients screen called that provider
859
- // searchable (2026-10-07: 41 searches failed with nothing on screen to say why). A configured
860
- // backend on this same provider has already had its try above.
861
- // The same holds for a plan that searches on another of its endpoints (`anthropicSearch`).
862
- const own = (cfg.providers[route.provider]?.type === "chatgpt" || searchProviderFor(cfg.providers[route.provider], route.model) !== undefined) &&
863
- cfg.webSearch?.provider !== route.provider;
864
- if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish, markSearchAttempted)))
865
- return;
866
- const blocks = webSearchErrorBlocks(search, "unavailable");
867
- const model = typeof json.model === "string" ? json.model : "unknown";
868
- const wantStream = json.stream === true;
869
- const payload = wantStream ? webSearchSse(model, blocks, 0) : JSON.stringify(webSearchMessage(model, blocks, 0));
870
- res.writeHead(200, wantStream
871
- ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
872
- : { "content-type": "application/json" }).end(payload);
873
- log.warn(`web search: ${route.provider} cannot run server tools and no webSearch backend is configured; refused rather than answered with invented results`);
874
- record = { ...record, target: route.model, provider: route.provider, purpose: "web-search" };
875
- finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "web-search-unsupported" });
876
- return;
877
- }
878
- let compatCaps;
879
- let compatChanges = [];
880
- /** The credential this attempt is using, and so the one a failure is charged to. */
881
- let chosen = null;
882
- let penalised = null;
883
- let target;
884
- // Re-enter only for the one unwritten Google turn (prompt refusal or no model capacity). Each target sees the original
885
- // request, not the primary's rewritten effort, stripped thread, or translated body.
886
- for (;;) {
887
- if (pristine)
888
- json = structuredClone(pristine);
889
- if (route && json) {
890
- record = { ...record, target: route.model, provider: route.provider };
891
- delete record.effort;
892
- const configured = cfg.providers[route.provider];
893
- if (!configured) {
894
- finish("500", 0, `unknown provider ${route.provider}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unknown-provider" });
895
- res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
826
+ const originalBody = body;
827
+ const originalJson = fetchRoute ? structuredClone(json) : null;
828
+ const originalRecord = { ...record };
829
+ const response = res;
830
+ const complete = finish;
831
+ // Every provider still uses the ordinary path below. Only this opt-in side request may
832
+ // withhold an error response and retry the original destination, before the first byte.
833
+ const run = async (fetchTarget) => {
834
+ let attemptDone = false;
835
+ const res = fetchTarget ? deferErrorResponse(response, () => attemptDone) : response;
836
+ const finish = (status, bytes, note, failed, extra) => {
837
+ if (attemptDone)
896
838
  return;
897
- }
898
- // A model may speak another wire than the rest of its provider: one subscription serving
899
- // several protocols is one provider, and this is where the model's own shape is folded in so
900
- // the branches below pick the adapter it actually needs.
901
- const provider = providerFor(configured, route.model);
902
- // Only the Google adapter can return a turn unwritten, so only it pays for the copy.
903
- if (!pristine && provider.type === "google")
904
- pristine = structuredClone(json);
905
- rewriteBody(json, route, cfg.effortClamp);
906
- if (provider.type === "chatgpt") {
907
- const td = threadDecision(json);
908
- if (td === "refuse") {
909
- const out = JSON.stringify(THREAD_UNSUPPORTED);
910
- res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
911
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
912
- return;
913
- }
914
- if (td === "strip")
915
- stripThreadFields(json);
916
- const routeEffort = effortOf(json);
917
- if (routeEffort)
918
- record.effort = routeEffort;
919
- tag = `CHATGPT ${route.tag} effort=${routeEffort ?? "-"}`;
920
- try {
921
- // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
922
- // here fails over once every account is out (chooseTarget asks the adapter).
923
- const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
924
- if (!path.startsWith("/v1/messages/count_tokens"))
925
- this.providerHealth.observe(route.provider, o.status, o.exhaustion);
926
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
927
- }
928
- catch (e) {
929
- finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
930
- if (!res.headersSent)
931
- res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
839
+ if (fetchTarget && !response.headersSent && !response.destroyed && !req.aborted && (Number(status) >= 400 || !Number.isFinite(Number(status)) || Number(status) <= 0)) {
840
+ attemptDone = true;
841
+ // A later local refusal must not claim that no upstream was called on this request.
842
+ searchAttempted ||= extra?.upstreamCalled !== false;
843
+ log.warn(`web fetch via ${fetchTarget.provider}: ${note ?? status}; falling back to original destination`);
844
+ body = originalBody;
845
+ json = structuredClone(originalJson);
846
+ record = { ...originalRecord, purpose: "web-fetch" };
847
+ unwrittenFallbackNote = `web fetch via ${fetchTarget.provider} failed; original destination fallback`;
848
+ void run(null).catch((error) => {
849
+ complete("502", 0, `web fetch fallback error: ${error.message}`);
850
+ if (!response.headersSent)
851
+ response.writeHead(502).end();
932
852
  else
933
- res.destroy();
934
- }
853
+ response.destroy();
854
+ });
855
+ return;
856
+ }
857
+ if (!fetchTarget) {
858
+ complete(status, bytes, note, failed, extra);
935
859
  return;
936
860
  }
937
- if (provider.type === "openai-compatible") {
938
- const td = threadDecision(json);
939
- if (td === "refuse") {
940
- const out = JSON.stringify(THREAD_UNSUPPORTED);
941
- res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
942
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
861
+ complete(status, bytes, [`web fetch via ${fetchTarget.provider}`, note].filter(Boolean).join("; "), failed ?? (!!note || Number(status) >= 400), extra);
862
+ };
863
+ if (fetchTarget && json) {
864
+ record.purpose = "web-fetch";
865
+ const output = { ...json.output_config };
866
+ delete output.effort;
867
+ json.output_config = output;
868
+ }
869
+ const resolved = fetchTarget ?? (json ? resolve(model, json, cfg) : null);
870
+ // A non-Claude model this router cannot route is refused here, by name, instead of being
871
+ // forwarded to Anthropic. `PASS` on such a request answers 404 from Anthropic and looks like
872
+ // "the model vanished" (2026-09-20: an agent file named `deepseek` resolved, through a missing
873
+ // alias, to a model no provider declared — thirty of them came back 404). A native `claude-*`
874
+ // id is deliberately unrouted and must keep passing through (§5).
875
+ if (!resolved && isApiHost && isMessages && json && typeof model === "string" && !model.startsWith("claude-")) {
876
+ const reason = unroutableReason(model, json, cfg);
877
+ if (reason) {
878
+ const payload = JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: `ClaudeRipple: ${reason}` } });
879
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }).end(payload);
880
+ tag = `REFUSE ${model}`;
881
+ record = { ...record, provider: "refused" };
882
+ finish("400", payload.length, `ClaudeRipple: ${reason}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unroutable" });
883
+ return;
884
+ }
885
+ }
886
+ // A slot with fallbacks picks its provider before anything is sent, so a primary that is rate
887
+ // limited for the next hour is skipped rather than rediscovered once per request. A distinct
888
+ // prompt refusal can also select one fallback below, but only before any response is written.
889
+ let route = resolved ? this.chooseTarget(resolved, cfg, log) : null;
890
+ /** The request before any target rewrote it, kept only when a Google target may hand the turn back. */
891
+ let pristine;
892
+ const source = typeof model === "string" ? model : "-";
893
+ record = {
894
+ ...record,
895
+ source,
896
+ target: source,
897
+ stream: json?.stream === true,
898
+ };
899
+ const requestedEffort = effortOf(json ?? {});
900
+ if (requestedEffort)
901
+ record.effort = requestedEffort;
902
+ // Only a real, un-routed Claude Code Messages request may refresh this RAM-only source.
903
+ // Do not inspect it elsewhere: it must never enter logs, RequestLog, picker diagnostics, or admin data.
904
+ if (isApiHost && pathname === "/v1/messages" && !route)
905
+ this.deps.observedClaudeCodeAuth?.observe(req.rawHeaders);
906
+ // A `WebSearch` side request routed to a provider that cannot run the server tool must not be
907
+ // sent. The tool is dropped on every translated path, which leaves "you are an assistant for
908
+ // performing a web search tool use / perform a web search for the query: …" with no tool
909
+ // attached — and a model told to search with nothing to search with narrates a tool call
910
+ // instead. OpenCode Go's DeepSeek answered exactly that, in its own markup, as HTTP 200
911
+ // (measured 2026-09-18), and the session reported the search tool as unresponsive.
912
+ //
913
+ // Say so in the shape the CLI prints. A search that fails visibly can be retried by a human;
914
+ // one that returns prose shaped like an answer cannot.
915
+ if (search && route && json && !this.canRunServerTools(route.provider, cfg)) {
916
+ // A ChatGPT provider has a search of its own — the backend `webSearch` names — so a search
917
+ // routed there is served by it, not refused. Requiring `webSearch` as well refused every
918
+ // search of a `smallFast` slot pointed at Luna while the Clients screen called that provider
919
+ // searchable (2026-10-07: 41 searches failed with nothing on screen to say why). A configured
920
+ // backend on this same provider has already had its try above.
921
+ // The same holds for a plan that searches on another of its endpoints (`anthropicSearch`).
922
+ const own = (cfg.providers[route.provider]?.type === "chatgpt" || searchProviderFor(cfg.providers[route.provider], route.model) !== undefined) &&
923
+ cfg.webSearch?.provider !== route.provider;
924
+ if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish, markSearchAttempted)))
925
+ return;
926
+ const blocks = webSearchErrorBlocks(search, "unavailable");
927
+ const model = typeof json.model === "string" ? json.model : "unknown";
928
+ const wantStream = json.stream === true;
929
+ const payload = wantStream ? webSearchSse(model, blocks, 0) : JSON.stringify(webSearchMessage(model, blocks, 0));
930
+ res.writeHead(200, wantStream
931
+ ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
932
+ : { "content-type": "application/json" }).end(payload);
933
+ log.warn(`web search: ${route.provider} cannot run server tools and no webSearch backend is configured; refused rather than answered with invented results`);
934
+ record = { ...record, target: route.model, provider: route.provider, purpose: "web-search" };
935
+ finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "web-search-unsupported" });
936
+ return;
937
+ }
938
+ let compatCaps;
939
+ let compatChanges = [];
940
+ /** The credential this attempt is using, and so the one a failure is charged to. */
941
+ let chosen = null;
942
+ let penalised = null;
943
+ let target;
944
+ // Re-enter only for the one unwritten Google turn (prompt refusal or no model capacity). Each target sees the original
945
+ // request, not the primary's rewritten effort, stripped thread, or translated body.
946
+ for (;;) {
947
+ if (pristine)
948
+ json = structuredClone(pristine);
949
+ if (route && json) {
950
+ record = { ...record, target: route.model, provider: route.provider };
951
+ delete record.effort;
952
+ const configured = cfg.providers[route.provider];
953
+ if (!configured) {
954
+ finish("500", 0, `unknown provider ${route.provider}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unknown-provider" });
955
+ res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
943
956
  return;
944
957
  }
945
- if (td === "strip")
946
- stripThreadFields(json);
947
- const routeEffort = effortOf(json);
948
- if (routeEffort)
949
- record.effort = routeEffort;
950
- tag = `OPENAI ${route.tag} wire=${provider.wire ?? "chat"} effort=${routeEffort ?? "-"}`;
951
- try {
952
- const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
953
- this.recordOutcome(route.provider, o.status);
954
- if (!path.startsWith("/v1/messages/count_tokens"))
955
- this.providerHealth.observe(route.provider, o.status, o.exhaustion);
956
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
957
- }
958
- catch (e) {
959
- this.recordOutcome(route.provider, 0);
960
- finish("-", 0, `openai error ${e.code ?? ""} ${e.message}`);
961
- if (!res.headersSent)
962
- res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
963
- else
964
- res.destroy();
958
+ // A model may speak another wire than the rest of its provider: one subscription serving
959
+ // several protocols is one provider, and this is where the model's own shape is folded in so
960
+ // the branches below pick the adapter it actually needs.
961
+ const provider = providerFor(configured, route.model);
962
+ // Only the Google adapter can return a turn unwritten, so only it pays for the copy.
963
+ if (!pristine && provider.type === "google")
964
+ pristine = structuredClone(json);
965
+ rewriteBody(json, route, cfg.effortClamp);
966
+ if (provider.type === "chatgpt") {
967
+ const td = threadDecision(json);
968
+ if (td === "refuse") {
969
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
970
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
971
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
972
+ return;
973
+ }
974
+ if (td === "strip")
975
+ stripThreadFields(json);
976
+ const routeEffort = effortOf(json);
977
+ if (routeEffort)
978
+ record.effort = routeEffort;
979
+ tag = `CHATGPT ${route.tag} effort=${routeEffort ?? "-"}`;
980
+ try {
981
+ // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
982
+ // here fails over once every account is out (chooseTarget asks the adapter).
983
+ const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
984
+ if (!path.startsWith("/v1/messages/count_tokens"))
985
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
986
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
987
+ }
988
+ catch (e) {
989
+ finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
990
+ if (!res.headersSent)
991
+ res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
992
+ else
993
+ res.destroy();
994
+ }
995
+ return;
965
996
  }
966
- return;
967
- }
968
- if (provider.type === "google") {
969
- // Google holds no server-side thread either, so a `continue` is refused as it is for the
970
- // other translated providers; the CLI resends the turn with the full history.
971
- const td = threadDecision(json);
972
- if (td === "refuse") {
973
- const out = JSON.stringify(THREAD_UNSUPPORTED);
974
- res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
975
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
997
+ if (provider.type === "openai-compatible") {
998
+ const td = threadDecision(json);
999
+ if (td === "refuse") {
1000
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
1001
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1002
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
1003
+ return;
1004
+ }
1005
+ if (td === "strip")
1006
+ stripThreadFields(json);
1007
+ const routeEffort = effortOf(json);
1008
+ if (routeEffort)
1009
+ record.effort = routeEffort;
1010
+ tag = `OPENAI ${route.tag} wire=${provider.wire ?? "chat"} effort=${routeEffort ?? "-"}`;
1011
+ try {
1012
+ const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
1013
+ this.recordOutcome(route.provider, o.status);
1014
+ if (!path.startsWith("/v1/messages/count_tokens"))
1015
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
1016
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
1017
+ }
1018
+ catch (e) {
1019
+ this.recordOutcome(route.provider, 0);
1020
+ finish("-", 0, `openai error ${e.code ?? ""} ${e.message}`);
1021
+ if (!res.headersSent)
1022
+ res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
1023
+ else
1024
+ res.destroy();
1025
+ }
976
1026
  return;
977
1027
  }
978
- if (td === "strip")
979
- stripThreadFields(json);
980
- const routeEffort = effortOf(json);
981
- if (routeEffort)
982
- record.effort = routeEffort;
983
- tag = `GOOGLE ${route.tag} effort=${routeEffort ?? "-"}`;
984
- try {
985
- const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
986
- if (o.kind === "prompt-incompatible" || o.kind === "model-capacity") {
987
- const why = o.kind === "prompt-incompatible" ? "prompt" : "capacity";
988
- const alreadyRetried = unwrittenSeen;
989
- unwrittenSeen = true;
990
- if (res.destroyed || res.writableEnded || res.headersSent) {
991
- finish("0", 0, `client closed before ${why} fallback`, true);
992
- if (!res.destroyed && !res.writableEnded)
993
- res.destroy();
1028
+ if (provider.type === "google") {
1029
+ // Google holds no server-side thread either, so a `continue` is refused as it is for the
1030
+ // other translated providers; the CLI resends the turn with the full history.
1031
+ const td = threadDecision(json);
1032
+ if (td === "refuse") {
1033
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
1034
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1035
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
1036
+ return;
1037
+ }
1038
+ if (td === "strip")
1039
+ stripThreadFields(json);
1040
+ const routeEffort = effortOf(json);
1041
+ if (routeEffort)
1042
+ record.effort = routeEffort;
1043
+ tag = `GOOGLE ${route.tag} effort=${routeEffort ?? "-"}`;
1044
+ try {
1045
+ const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
1046
+ if (o.kind === "prompt-incompatible" || o.kind === "model-capacity") {
1047
+ const why = o.kind === "prompt-incompatible" ? "prompt" : "capacity";
1048
+ const alreadyRetried = unwrittenSeen;
1049
+ unwrittenSeen = true;
1050
+ if (res.destroyed || res.writableEnded || res.headersSent) {
1051
+ finish("0", 0, `client closed before ${why} fallback`, true);
1052
+ if (!res.destroyed && !res.writableEnded)
1053
+ res.destroy();
1054
+ return;
1055
+ }
1056
+ const fallback = !alreadyRetried && resolved
1057
+ ? this.unwrittenFallback(resolved, route, cfg, why) : null;
1058
+ if (fallback) {
1059
+ unwrittenFallbackNote = `${o.note}; fallback ${route.provider}/${route.model} → ${fallback.provider}/${fallback.model}`;
1060
+ log.info(`FAILOVER ${unwrittenFallbackNote}`);
1061
+ route = fallback;
1062
+ continue;
1063
+ }
1064
+ // Neither is about the account or the provider (a refusal is request-specific, missing
1065
+ // capacity is the model's on Google's side): neither cool nor mark success.
1066
+ res.writeHead(o.status, { "content-type": "application/json" }).end(o.errorBody);
1067
+ finish(String(o.status), Buffer.byteLength(o.errorBody), o.note, true, o.kind === "prompt-incompatible" ? { refusal: "prompt-incompatible" } : undefined);
994
1068
  return;
995
1069
  }
996
- const fallback = !alreadyRetried && resolved
997
- ? this.unwrittenFallback(resolved, route, cfg, why) : null;
998
- if (fallback) {
999
- unwrittenFallbackNote = `${o.note}; fallback ${route.provider}/${route.model} → ${fallback.provider}/${fallback.model}`;
1000
- log.info(`FAILOVER ${unwrittenFallbackNote}`);
1001
- route = fallback;
1002
- continue;
1003
- }
1004
- // Neither is about the account or the provider (a refusal is request-specific, missing
1005
- // capacity is the model's on Google's side): neither cool nor mark success.
1006
- res.writeHead(o.status, { "content-type": "application/json" }).end(o.errorBody);
1007
- finish(String(o.status), Buffer.byteLength(o.errorBody), o.note, true, o.kind === "prompt-incompatible" ? { refusal: "prompt-incompatible" } : undefined);
1008
- return;
1070
+ this.recordOutcome(route.provider, o.status);
1071
+ if (!path.startsWith("/v1/messages/count_tokens"))
1072
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
1073
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
1009
1074
  }
1010
- this.recordOutcome(route.provider, o.status);
1011
- if (!path.startsWith("/v1/messages/count_tokens"))
1012
- this.providerHealth.observe(route.provider, o.status, o.exhaustion);
1013
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
1014
- }
1015
- catch (e) {
1016
- this.recordOutcome(route.provider, 0);
1017
- finish("-", 0, `google error ${e.code ?? ""} ${e.message}`);
1018
- if (!res.headersSent)
1019
- res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
1020
- else
1021
- res.destroy();
1075
+ catch (e) {
1076
+ this.recordOutcome(route.provider, 0);
1077
+ finish("-", 0, `google error ${e.code ?? ""} ${e.message}`);
1078
+ if (!res.headersSent)
1079
+ res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
1080
+ else
1081
+ res.destroy();
1082
+ }
1083
+ return;
1022
1084
  }
1023
- return;
1024
- }
1025
- if (provider.type === "grok") {
1026
- // The chat proxy keeps no server-side thread, so a `continue` is refused like on the other
1027
- // translated providers and the CLI resends the turn with the full history.
1028
- const td = threadDecision(json);
1029
- if (td === "refuse") {
1030
- const out = JSON.stringify(THREAD_UNSUPPORTED);
1031
- res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1032
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
1085
+ if (provider.type === "grok") {
1086
+ // The chat proxy keeps no server-side thread, so a `continue` is refused like on the other
1087
+ // translated providers and the CLI resends the turn with the full history.
1088
+ const td = threadDecision(json);
1089
+ if (td === "refuse") {
1090
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
1091
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1092
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
1093
+ return;
1094
+ }
1095
+ if (td === "strip")
1096
+ stripThreadFields(json);
1097
+ record = { ...record, target: route.model, provider: route.provider };
1098
+ const routeEffort = effortOf(json);
1099
+ if (routeEffort)
1100
+ record.effort = routeEffort;
1101
+ tag = `GROK ${route.tag} effort=${routeEffort ?? "-"}`;
1102
+ try {
1103
+ const o = await this.grok(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
1104
+ this.recordOutcome(route.provider, o.status);
1105
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
1106
+ }
1107
+ catch (e) {
1108
+ this.recordOutcome(route.provider, 0);
1109
+ finish("-", 0, `grok error ${e.code ?? ""} ${e.message}`);
1110
+ if (!res.headersSent)
1111
+ res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
1112
+ else
1113
+ res.destroy();
1114
+ }
1033
1115
  return;
1034
1116
  }
1035
- if (td === "strip")
1036
- stripThreadFields(json);
1037
- record = { ...record, target: route.model, provider: route.provider };
1038
- const routeEffort = effortOf(json);
1039
- if (routeEffort)
1040
- record.effort = routeEffort;
1041
- tag = `GROK ${route.tag} effort=${routeEffort ?? "-"}`;
1042
- try {
1043
- const o = await this.grok(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
1044
- this.recordOutcome(route.provider, o.status);
1045
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
1117
+ if (provider.type === "anthropic") {
1118
+ if (!provider.accountPool || provider.auth !== "claude-code") {
1119
+ finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
1120
+ 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" } }));
1121
+ return;
1122
+ }
1123
+ // Native Messages need no translation. Only authentication changes, so server-side threads,
1124
+ // prompt caching, beta features and the client's exact tool contract remain first-party.
1125
+ const credentials = await this.claudeAccounts.credentials();
1126
+ if (credentials.length === 0) {
1127
+ const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
1128
+ res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1129
+ finish("401", out.length, "no usable Claude account", true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "no-account" });
1130
+ return;
1131
+ }
1132
+ dropForeignThinking(json);
1133
+ body = Buffer.from(JSON.stringify(json));
1134
+ chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
1135
+ const using = chosen ?? credentials[0];
1136
+ chosen = using;
1137
+ penalised = { provider: route.provider, id: using.id };
1138
+ target = {
1139
+ protocol: "https:",
1140
+ host: cfg.upstream,
1141
+ port: this.deps.upstreamPort ?? 443,
1142
+ agent: this.deps.upstreamAgent ?? this.agentFor(route.provider, "https:"),
1143
+ extraHeaders: withClientBetas(using.headers, req.headers["anthropic-beta"]),
1144
+ dropClientAuth: true,
1145
+ dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
1146
+ dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
1147
+ };
1148
+ const routeEffort = effortOf(json);
1149
+ if (routeEffort)
1150
+ record.effort = routeEffort;
1151
+ tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
1046
1152
  }
1047
- catch (e) {
1048
- this.recordOutcome(route.provider, 0);
1049
- finish("-", 0, `grok error ${e.code ?? ""} ${e.message}`);
1050
- if (!res.headersSent)
1051
- res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
1052
- else
1053
- res.destroy();
1153
+ else {
1154
+ // An Anthropic-shaped endpoint keeps no server-side thread either, so a `continue` is refused
1155
+ // here as it is for the translated providers. sanitizeForCompatible used to strip the field
1156
+ // and forward the rest, which is only the delta: DeepSeek (via Bailian) received two orphan
1157
+ // tool_results with no task and answered that none had been given (measured 2026-09-24, CLI
1158
+ // 2.1.280). Refused, the CLI resends the turn with the full history.
1159
+ if (threadDecision(json) === "refuse") {
1160
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
1161
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1162
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
1163
+ return;
1164
+ }
1165
+ const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
1166
+ const routeModel = route.model;
1167
+ const modelEffortLevels = provider.models?.find((entry) => entry.id === routeModel)?.effortLevels;
1168
+ compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
1169
+ const sanitized = sanitizeForCompatible(json, compatCaps);
1170
+ json = sanitized.json;
1171
+ compatChanges = sanitized.changes;
1172
+ // The upstream would otherwise read Claude Code's own system prompt and answer that it is
1173
+ // Claude. After sanitizing, so the effort named is the one the provider actually receives.
1174
+ applyIdentityToAnthropicBody(json, {
1175
+ model: route.model,
1176
+ effort: effortOf(json),
1177
+ identity: provider.identity,
1178
+ instructionsAppend: provider.instructionsAppend,
1179
+ });
1180
+ body = Buffer.from(JSON.stringify(json));
1181
+ const u = new URL(provider.url);
1182
+ const protocol = u.protocol === "https:" ? "https:" : "http:";
1183
+ // Which credential answers this turn. The conversation keeps the one it is on while that one
1184
+ // is healthy, because moving it moves the prompt cache with it. Every credential cooling or
1185
+ // quarantined leaves `chosen` null, and the request goes out on the configured headers so the
1186
+ // provider — not us — gets to say no.
1187
+ const credentials = this.credentialsOf(route.provider, provider);
1188
+ // The same key the prompt cache is keyed on. `metadata.user_id` alone is one value for every
1189
+ // conversation a user has, so using it raw would drag all of them onto one credential at once
1190
+ // — the opposite of what stickiness is for (see conversationKey and ARCHITECTURE §4).
1191
+ chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
1192
+ // Nothing usable means every credential is cooling, and the turn still has to go somewhere:
1193
+ // the first one, so the request carries real credentials rather than none, and so the answer
1194
+ // is charged to the credential that actually produced it.
1195
+ const using = chosen ?? credentials[0];
1196
+ chosen = using;
1197
+ penalised = { provider: route.provider, id: using.id };
1198
+ target = {
1199
+ protocol,
1200
+ host: u.hostname,
1201
+ port: Number(u.port) || (protocol === "https:" ? 443 : 80),
1202
+ agent: this.agentFor(route.provider, protocol),
1203
+ // The credential wins over the session header: they are different names in practice, but a
1204
+ // vendor that reused one would mean the request going out unauthenticated.
1205
+ extraHeaders: provider.sessionHeader
1206
+ ? { [provider.sessionHeader]: conversationKey(json), ...using.headers }
1207
+ : using.headers,
1208
+ dropClientAuth: true,
1209
+ // Providers mount their Anthropic-compatible API under a path (DeepSeek /anthropic, OpenRouter /api,
1210
+ // Qwen /apps/anthropic): the CLI's /v1/messages is appended to it. Dropping it sent requests to the
1211
+ // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
1212
+ basePath: u.pathname.replace(/\/+$/, ""),
1213
+ };
1214
+ const routeEffort = effortOf(json);
1215
+ if (routeEffort)
1216
+ record.effort = routeEffort;
1217
+ tag = `${route.provider.toUpperCase()} ${route.tag} effort=${routeEffort ?? "-"}`;
1054
1218
  }
1055
- return;
1056
1219
  }
1057
- if (provider.type === "anthropic") {
1058
- if (!provider.accountPool || provider.auth !== "claude-code") {
1059
- finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
1060
- 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" } }));
1061
- return;
1062
- }
1063
- // Native Messages need no translation. Only authentication changes, so server-side threads,
1064
- // prompt caching, beta features and the client's exact tool contract remain first-party.
1065
- const credentials = await this.claudeAccounts.credentials();
1066
- if (credentials.length === 0) {
1067
- const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
1068
- res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1069
- finish("401", out.length, "no usable Claude account", true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "no-account" });
1070
- return;
1071
- }
1072
- dropForeignThinking(json);
1073
- body = Buffer.from(JSON.stringify(json));
1074
- chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
1075
- const using = chosen ?? credentials[0];
1076
- chosen = using;
1077
- penalised = { provider: route.provider, id: using.id };
1078
- target = {
1079
- protocol: "https:",
1080
- host: cfg.upstream,
1081
- port: this.deps.upstreamPort ?? 443,
1082
- agent: this.deps.upstreamAgent ?? this.agentFor(route.provider, "https:"),
1083
- extraHeaders: withClientBetas(using.headers, req.headers["anthropic-beta"]),
1084
- dropClientAuth: true,
1085
- dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
1086
- dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
1087
- };
1088
- const routeEffort = effortOf(json);
1089
- if (routeEffort)
1090
- record.effort = routeEffort;
1091
- tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
1220
+ else if (isApiHost) {
1221
+ target = { protocol: "https:", host: cfg.upstream, port: this.deps.upstreamPort ?? 443, agent: this.deps.upstreamAgent ?? this.upstreamAgent, extraHeaders: {} };
1222
+ tag = `PASS ${typeof model === "string" ? model : "-"}`;
1223
+ // Passthrough stays byte-exact unless another model's thinking would get the turn refused.
1224
+ if (json && dropForeignThinking(json) > 0)
1225
+ body = Buffer.from(JSON.stringify(json));
1092
1226
  }
1093
1227
  else {
1094
- // An Anthropic-shaped endpoint keeps no server-side thread either, so a `continue` is refused
1095
- // here as it is for the translated providers. sanitizeForCompatible used to strip the field
1096
- // and forward the rest, which is only the delta: DeepSeek (via Bailian) received two orphan
1097
- // tool_results with no task and answered that none had been given (measured 2026-09-24, CLI
1098
- // 2.1.280). Refused, the CLI resends the turn with the full history.
1099
- if (threadDecision(json) === "refuse") {
1100
- const out = JSON.stringify(THREAD_UNSUPPORTED);
1101
- res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
1102
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
1103
- return;
1104
- }
1105
- const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
1106
- const routeModel = route.model;
1107
- const modelEffortLevels = provider.models?.find((entry) => entry.id === routeModel)?.effortLevels;
1108
- compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
1109
- const sanitized = sanitizeForCompatible(json, compatCaps);
1110
- json = sanitized.json;
1111
- compatChanges = sanitized.changes;
1112
- // The upstream would otherwise read Claude Code's own system prompt and answer that it is
1113
- // Claude. After sanitizing, so the effort named is the one the provider actually receives.
1114
- applyIdentityToAnthropicBody(json, {
1115
- model: route.model,
1116
- effort: effortOf(json),
1117
- identity: provider.identity,
1118
- instructionsAppend: provider.instructionsAppend,
1119
- });
1120
- body = Buffer.from(JSON.stringify(json));
1121
- const u = new URL(provider.url);
1122
- const protocol = u.protocol === "https:" ? "https:" : "http:";
1123
- // Which credential answers this turn. The conversation keeps the one it is on while that one
1124
- // is healthy, because moving it moves the prompt cache with it. Every credential cooling or
1125
- // quarantined leaves `chosen` null, and the request goes out on the configured headers so the
1126
- // provider — not us — gets to say no.
1127
- const credentials = this.credentialsOf(route.provider, provider);
1128
- // The same key the prompt cache is keyed on. `metadata.user_id` alone is one value for every
1129
- // conversation a user has, so using it raw would drag all of them onto one credential at once
1130
- // — the opposite of what stickiness is for (see conversationKey and ARCHITECTURE §4).
1131
- chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
1132
- // Nothing usable means every credential is cooling, and the turn still has to go somewhere:
1133
- // the first one, so the request carries real credentials rather than none, and so the answer
1134
- // is charged to the credential that actually produced it.
1135
- const using = chosen ?? credentials[0];
1136
- chosen = using;
1137
- penalised = { provider: route.provider, id: using.id };
1138
- target = {
1139
- protocol,
1140
- host: u.hostname,
1141
- port: Number(u.port) || (protocol === "https:" ? 443 : 80),
1142
- agent: this.agentFor(route.provider, protocol),
1143
- // The credential wins over the session header: they are different names in practice, but a
1144
- // vendor that reused one would mean the request going out unauthenticated.
1145
- extraHeaders: provider.sessionHeader
1146
- ? { [provider.sessionHeader]: conversationKey(json), ...using.headers }
1147
- : using.headers,
1148
- dropClientAuth: true,
1149
- // Providers mount their Anthropic-compatible API under a path (DeepSeek /anthropic, OpenRouter /api,
1150
- // Qwen /apps/anthropic): the CLI's /v1/messages is appended to it. Dropping it sent requests to the
1151
- // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
1152
- basePath: u.pathname.replace(/\/+$/, ""),
1153
- };
1154
- const routeEffort = effortOf(json);
1155
- if (routeEffort)
1156
- record.effort = routeEffort;
1157
- tag = `${route.provider.toUpperCase()} ${route.tag} effort=${routeEffort ?? "-"}`;
1228
+ target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
1229
+ tag = `WEB ${reqHost}`;
1158
1230
  }
1231
+ break;
1159
1232
  }
1160
- else if (isApiHost) {
1161
- target = { protocol: "https:", host: cfg.upstream, port: this.deps.upstreamPort ?? 443, agent: this.deps.upstreamAgent ?? this.upstreamAgent, extraHeaders: {} };
1162
- tag = `PASS ${typeof model === "string" ? model : "-"}`;
1163
- // Passthrough stays byte-exact unless another model's thinking would get the turn refused.
1164
- if (json && dropForeignThinking(json) > 0)
1165
- body = Buffer.from(JSON.stringify(json));
1166
- }
1167
- else {
1168
- target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
1169
- tag = `WEB ${reqHost}`;
1170
- }
1171
- break;
1172
- }
1173
- if (json && typeof json.service_tier === "string")
1174
- record.requestedTier = json.service_tier;
1175
- // Two kinds of response editing: the CLI bootstrap (api host) and the claude.ai bootstrap (picker mode).
1176
- const isCliBootstrap = isApiHost && !route && path.startsWith(BOOTSTRAP_PATH);
1177
- const isPickerBootstrap = !isApiHost && !!cfg.picker?.enabled && method === "GET" && isBootstrapPath(path);
1178
- const isBootstrap = isCliBootstrap || isPickerBootstrap;
1179
- const headers = [];
1180
- const raw = req.rawHeaders;
1181
- for (let i = 0; i < raw.length; i += 2) {
1182
- const k = raw[i];
1183
- const lk = k.toLowerCase();
1184
- if (HOP_BY_HOP.has(lk))
1185
- continue;
1186
- // Bootstrap responses are edited: keep the client's accept-encoding as-is (some edges misbehave without it)
1187
- // and decompress whatever comes back before editing.
1188
- if (lk in target.extraHeaders || target.dropHeaders?.has(lk) || target.dropHeaderPrefixes?.some((prefix) => lk.startsWith(prefix)))
1189
- continue;
1190
- if (target.dropClientAuth && CLIENT_AUTH.has(lk))
1191
- continue;
1192
- // Anthropic beta flags opt into features most compatible providers have not implemented.
1193
- if (compatCaps && !forwardCompatibleHeader(lk, compatCaps)) {
1194
- compatChanges.push("anthropic-beta");
1195
- continue;
1196
- }
1197
- // Picker bootstrap: never let the renderer revalidate against a cached (unedited) copy.
1198
- if (isPickerBootstrap && (lk === "if-none-match" || lk === "if-modified-since"))
1199
- continue;
1200
- headers.push(k, raw[i + 1]);
1201
- }
1202
- for (const [k, v] of Object.entries(target.extraHeaders))
1203
- headers.push(k, v);
1204
- if (route && compatChanges.length > 0)
1205
- log.info(`COMPAT ${route.provider}: ${compatChanges.join(", ")}`);
1206
- headers.push("host", target.protocol === "https:" && target.port === 443 ? target.host : `${target.host}:${target.port}`);
1207
- // No content-length on body-less GET/HEAD: some edges reject it; Chromium never sends it.
1208
- if (body.length > 0 || (method !== "GET" && method !== "HEAD"))
1209
- headers.push("content-length", String(body.length));
1210
- if (isPickerBootstrap) {
1211
- const shown = [];
1212
- for (const [rawName, value] of Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]])) {
1213
- const safe = redactHeaders({ [rawName]: value })[rawName];
1214
- shown.push(safe === "[REDACTED]" ? `${rawName.toLowerCase()}=<${value.length}B>` : `${rawName.toLowerCase()}=${value}`);
1215
- }
1216
- log.info(`PICKER request ${method} ${path.slice(0, 80)} headers: ${shown.join(" | ")}`);
1217
- }
1218
- // Keep every credential this turn sends. A retry can use an opaque token that does not match a
1219
- // known token shape; retaining its actual value is what keeps a reflected refusal out of logs.
1220
- const errorSecrets = new Set(credentialHeaderValues(Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]])));
1221
- // Every retry is rebuilt from the first attempt's header list. Keep the union of identity header
1222
- // names already used so a current-session fingerprint cannot reappear on a third account.
1223
- let usedCredentialHeaders = { ...(chosen?.headers ?? {}) };
1224
- const lib = target.protocol === "https:" ? https : http;
1225
- /**
1226
- * The attempt in flight. A routed turn may make more than one: when a credential is refused and
1227
- * nothing has reached the client yet, the next credential takes over inside the same turn, so
1228
- * the client is answered on its first ask instead of having to retry. `send` below replaces
1229
- * this; everything that reaches for the upstream reaches for it through here.
1230
- */
1231
- let upReq;
1232
- /** Credentials already spent on this turn, so a retry cannot pick one of them again. */
1233
- const tried = new Set(penalised ? [penalised.id] : []);
1234
- /** The headers of the attempt in flight, so a stale-socket resend repeats exactly that attempt. */
1235
- let lastAttemptHeaders = headers;
1236
- let staleRetries = 0;
1237
- const send = (attemptHeaders) => {
1238
- lastAttemptHeaders = attemptHeaders;
1239
- upReq = lib.request({
1240
- protocol: target.protocol,
1241
- host: target.host,
1242
- port: target.port,
1243
- method,
1244
- path: target.basePath ? target.basePath + path : path,
1245
- headers: attemptHeaders,
1246
- agent: target.agent,
1247
- ...(target.protocol === "https:" ? { servername: target.host } : {}),
1248
- });
1249
- upReq.on("error", onUpstreamError);
1250
- upReq.on("response", onUpstreamResponse);
1251
- upReq.end(body);
1252
- };
1253
- /**
1254
- * The next credential to try inside this turn, or null to answer with what the provider said.
1255
- * Only for a routed request whose provider has one we have not already spent — a failure that
1256
- * is the request's own fault is not retried at all (see `classify`).
1257
- */
1258
- const nextCredential = (status, body = "") => {
1259
- if (!route || !penalised || res.headersSent)
1260
- return null;
1261
- if (!classify(status, undefined, body).retryable)
1262
- return null;
1263
- const owner = cfg.providers[route.provider];
1264
- if (!owner)
1265
- return null;
1266
- // The model's own shape again: its credential pool is the provider's, but an auth convention
1267
- // this model overrides has to be the one the retry actually sends.
1268
- const provider = providerFor(owner, route.model);
1269
- const native = provider.type === "anthropic" && provider.accountPool;
1270
- const available = native ? this.claudeAccounts.peekCredentials() : this.credentialsOf(route.provider, provider);
1271
- const rest = available.filter((c) => !tried.has(c.id));
1272
- if (rest.length === 0)
1273
- return null;
1274
- const next = this.pool.pick(route.provider, rest, conversationKey(json));
1275
- return next && native ? { ...next, headers: withClientBetas(next.headers, req.headers["anthropic-beta"]) } : next;
1276
- };
1277
- // Destroying the upstream makes it emit ECONNRESET (measured, Node 24.15), which is
1278
- // indistinguishable from the provider dropping us unless we remember that we did it. Without
1279
- // this flag every cancelled turn charged the credential a failure and moved the conversation
1280
- // off it, which costs the prompt cache — for a turn the user cancelled on purpose.
1281
- let clientAborted = false;
1282
- // Set when a 403 body was read ahead of the forward, so the forward knows to write that body
1283
- // instead of the stream it came from.
1284
- let headFilled = false;
1285
- // The 403 body exactly as it arrived. It is what the client is given: the judgement below
1286
- // reads a decoded copy, but a compressed body cannot survive a round trip through a UTF-8
1287
- // string — invalid sequences become U+FFFD and never come back. Returning that while
1288
- // `content-encoding` still stands is `ERR_CONTENT_DECODING_FAILED` in the app (2026-09-22).
1289
- let errRaw = Buffer.alloc(0);
1290
- // Set when the body was longer than the read cap, so its bytes are no longer a whole
1291
- // compressed stream and only the decoded head can be forwarded.
1292
- let errTruncated = false;
1293
- const abortUpstream = () => {
1294
- clientAborted = true;
1295
- if (upReq && !upReq.destroyed)
1296
- upReq.destroy();
1297
- };
1298
- req.on("aborted", abortUpstream);
1299
- res.on("close", () => {
1300
- if (!finished) {
1301
- abortUpstream();
1302
- finish("-", 0, "client closed");
1303
- }
1304
- });
1305
- const onUpstreamError = (e) => {
1306
- // A kept-alive socket the upstream had already closed: the reset arrives before any response,
1307
- // so the request never reached it. claude.ai did this 259 times in four days of picker mode
1308
- // (2026-10-03..06), each one a 502 on an app request such as `current_user_access`. Send an
1309
- // idempotent request again on a fresh attempt instead of failing it; a POST is never resent,
1310
- // since a reset cannot prove the upstream did not act on it.
1311
- const code = e.code;
1312
- if (upReq.reusedSocket && (code === "ECONNRESET" || code === "EPIPE") && IDEMPOTENT.has(method) && !clientAborted && !res.headersSent && staleRetries < 2) {
1313
- staleRetries++;
1314
- log.info(`STALE ${reqHost}: kept-alive socket was closed (${code}); resending ${method} ${path.slice(0, 60)}`);
1315
- send(lastAttemptHeaders);
1316
- return;
1317
- }
1318
- if (target.host === cfg.upstream)
1319
- this.deps.health.failure(e);
1320
- // Nothing reached the provider, so this says nothing about the credential — but it does say
1321
- // the route is unusable for a moment, and a pool with somewhere else to go should use it.
1322
- // Our own abort is not the provider's fault and must not be charged to it.
1323
- if (penalised && !clientAborted)
1324
- this.pool.penalise(penalised.provider, penalised.id, 0);
1325
- finish("-", 0, `upstream error ${e.code ?? ""} ${e.message}`);
1326
- if (!res.headersSent) {
1327
- res.writeHead(502, { "content-type": "application/json", connection: "close" });
1328
- res.end(JSON.stringify({ error: { type: "clauderipple_upstream", message: `${target.host}: ${e.message}` } }));
1233
+ if (json && typeof json.service_tier === "string")
1234
+ record.requestedTier = json.service_tier;
1235
+ // Two kinds of response editing: the CLI bootstrap (api host) and the claude.ai bootstrap (picker mode).
1236
+ const isCliBootstrap = isApiHost && !route && path.startsWith(BOOTSTRAP_PATH);
1237
+ const isPickerBootstrap = !isApiHost && !!cfg.picker?.enabled && method === "GET" && isBootstrapPath(path);
1238
+ const isBootstrap = isCliBootstrap || isPickerBootstrap;
1239
+ const headers = [];
1240
+ const raw = req.rawHeaders;
1241
+ for (let i = 0; i < raw.length; i += 2) {
1242
+ const k = raw[i];
1243
+ const lk = k.toLowerCase();
1244
+ if (HOP_BY_HOP.has(lk))
1245
+ continue;
1246
+ // Bootstrap responses are edited: keep the client's accept-encoding as-is (some edges misbehave without it)
1247
+ // and decompress whatever comes back before editing.
1248
+ if (lk in target.extraHeaders || target.dropHeaders?.has(lk) || target.dropHeaderPrefixes?.some((prefix) => lk.startsWith(prefix)))
1249
+ continue;
1250
+ if (target.dropClientAuth && CLIENT_AUTH.has(lk))
1251
+ continue;
1252
+ // Anthropic beta flags opt into features most compatible providers have not implemented.
1253
+ if (compatCaps && !forwardCompatibleHeader(lk, compatCaps)) {
1254
+ compatChanges.push("anthropic-beta");
1255
+ continue;
1256
+ }
1257
+ // Picker bootstrap: never let the renderer revalidate against a cached (unedited) copy.
1258
+ if (isPickerBootstrap && (lk === "if-none-match" || lk === "if-modified-since"))
1259
+ continue;
1260
+ headers.push(k, raw[i + 1]);
1329
1261
  }
1330
- else {
1331
- res.destroy();
1262
+ for (const [k, v] of Object.entries(target.extraHeaders))
1263
+ headers.push(k, v);
1264
+ if (route && compatChanges.length > 0)
1265
+ log.info(`COMPAT ${route.provider}: ${compatChanges.join(", ")}`);
1266
+ headers.push("host", target.protocol === "https:" && target.port === 443 ? target.host : `${target.host}:${target.port}`);
1267
+ // No content-length on body-less GET/HEAD: some edges reject it; Chromium never sends it.
1268
+ if (body.length > 0 || (method !== "GET" && method !== "HEAD"))
1269
+ headers.push("content-length", String(body.length));
1270
+ if (isPickerBootstrap) {
1271
+ const shown = [];
1272
+ for (const [rawName, value] of Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]])) {
1273
+ const safe = redactHeaders({ [rawName]: value })[rawName];
1274
+ shown.push(safe === "[REDACTED]" ? `${rawName.toLowerCase()}=<${value.length}B>` : `${rawName.toLowerCase()}=${value}`);
1275
+ }
1276
+ log.info(`PICKER request ${method} ${path.slice(0, 80)} headers: ${shown.join(" | ")}`);
1332
1277
  }
1333
- };
1334
- const onUpstreamResponse = (upRes) => {
1335
- if (target.host === cfg.upstream)
1336
- this.deps.health.success();
1337
- const status = upRes.statusCode ?? 0;
1338
- // A 403 is the one status whose meaning is in its body. A relay reporting a broken upstream
1339
- // must not be charged to the credential — that is what made one working key answer every turn
1340
- // with an authentication error for a minute (2026-09-21). Every other status is judged from
1341
- // the status alone, and its body is left for the forward below, untouched.
1342
- if (status === 403) {
1343
- bufferBody(upRes, ERROR_HEAD_MAX).then((whole) => onUpstreamJudged(upRes, status, whole)).catch((e) => {
1344
- log.warn(`upstream ${status}: could not read the body: ${e.message}`);
1345
- finish(String(status), 0, `upstream ${status}: body read failed`);
1346
- res.destroy();
1278
+ // Keep every credential this turn sends. A retry can use an opaque token that does not match a
1279
+ // known token shape; retaining its actual value is what keeps a reflected refusal out of logs.
1280
+ const errorSecrets = new Set(credentialHeaderValues(Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]])));
1281
+ // Every retry is rebuilt from the first attempt's header list. Keep the union of identity header
1282
+ // names already used so a current-session fingerprint cannot reappear on a third account.
1283
+ let usedCredentialHeaders = { ...(chosen?.headers ?? {}) };
1284
+ const lib = target.protocol === "https:" ? https : http;
1285
+ /**
1286
+ * The attempt in flight. A routed turn may make more than one: when a credential is refused and
1287
+ * nothing has reached the client yet, the next credential takes over inside the same turn, so
1288
+ * the client is answered on its first ask instead of having to retry. `send` below replaces
1289
+ * this; everything that reaches for the upstream reaches for it through here.
1290
+ */
1291
+ let upReq;
1292
+ /** Credentials already spent on this turn, so a retry cannot pick one of them again. */
1293
+ const tried = new Set(penalised ? [penalised.id] : []);
1294
+ /** The headers of the attempt in flight, so a stale-socket resend repeats exactly that attempt. */
1295
+ let lastAttemptHeaders = headers;
1296
+ let staleRetries = 0;
1297
+ const send = (attemptHeaders) => {
1298
+ lastAttemptHeaders = attemptHeaders;
1299
+ upReq = lib.request({
1300
+ protocol: target.protocol,
1301
+ host: target.host,
1302
+ port: target.port,
1303
+ method,
1304
+ path: target.basePath ? target.basePath + path : path,
1305
+ headers: attemptHeaders,
1306
+ agent: target.agent,
1307
+ ...(target.protocol === "https:" ? { servername: target.host } : {}),
1347
1308
  });
1348
- return;
1349
- }
1350
- onUpstreamJudged(upRes, status, "");
1351
- };
1352
- /**
1353
- * The whole of a small body, read before anything is decided or written.
1354
- *
1355
- * A 403's meaning is in its body and cannot be judged before it arrives, so the bytes are read
1356
- * and handed back as the head: `unshift` after a stream has ended throws, and reading a head and
1357
- * forwarding the rest mid-flow is where a body can be lost to a pause nothing resumes. A 403 is
1358
- * a refusal, so its body is small.
1359
- *
1360
- * What is returned is the body **decoded for reading**, because the judgement is about what the
1361
- * body says. The bytes themselves are kept in `errRaw` for the client: a compressed body is
1362
- * arbitrary binary, and text is a one-way door for it.
1363
- *
1364
- * Past `max` there is nothing worth identifying, so the rest is dropped — but the stream is
1365
- * never destroyed. Tearing the connection down over a body size would turn a refusal the user
1366
- * can read into a socket error, which is a worse bug than the one this handles; an unbounded
1367
- * relay answer instead reaches the forward above the cap and is reported there.
1368
- */
1369
- const bufferBody = (stream, max) => new Promise((resolveBody) => {
1370
- const chunks = [];
1371
- let size = 0;
1372
- stream.on("data", (c) => {
1373
- const room = max - size;
1374
- if (room <= 0) {
1375
- errTruncated = true;
1376
- stream.resume();
1377
- return;
1309
+ upReq.on("error", onUpstreamError);
1310
+ upReq.on("response", onUpstreamResponse);
1311
+ upReq.end(body);
1312
+ };
1313
+ /**
1314
+ * The next credential to try inside this turn, or null to answer with what the provider said.
1315
+ * Only for a routed request whose provider has one we have not already spent — a failure that
1316
+ * is the request's own fault is not retried at all (see `classify`).
1317
+ */
1318
+ const nextCredential = (status, body = "") => {
1319
+ if (!route || !penalised || res.headersSent)
1320
+ return null;
1321
+ if (!classify(status, undefined, body).retryable)
1322
+ return null;
1323
+ const owner = cfg.providers[route.provider];
1324
+ if (!owner)
1325
+ return null;
1326
+ // The model's own shape again: its credential pool is the provider's, but an auth convention
1327
+ // this model overrides has to be the one the retry actually sends.
1328
+ const provider = providerFor(owner, route.model);
1329
+ const native = provider.type === "anthropic" && provider.accountPool;
1330
+ const available = native ? this.claudeAccounts.peekCredentials() : this.credentialsOf(route.provider, provider);
1331
+ const rest = available.filter((c) => !tried.has(c.id));
1332
+ if (rest.length === 0)
1333
+ return null;
1334
+ const next = this.pool.pick(route.provider, rest, conversationKey(json));
1335
+ return next && native ? { ...next, headers: withClientBetas(next.headers, req.headers["anthropic-beta"]) } : next;
1336
+ };
1337
+ // Destroying the upstream makes it emit ECONNRESET (measured, Node 24.15), which is
1338
+ // indistinguishable from the provider dropping us unless we remember that we did it. Without
1339
+ // this flag every cancelled turn charged the credential a failure and moved the conversation
1340
+ // off it, which costs the prompt cache — for a turn the user cancelled on purpose.
1341
+ let clientAborted = false;
1342
+ // Set when a 403 body was read ahead of the forward, so the forward knows to write that body
1343
+ // instead of the stream it came from.
1344
+ let headFilled = false;
1345
+ // The 403 body exactly as it arrived. It is what the client is given: the judgement below
1346
+ // reads a decoded copy, but a compressed body cannot survive a round trip through a UTF-8
1347
+ // string — invalid sequences become U+FFFD and never come back. Returning that while
1348
+ // `content-encoding` still stands is `ERR_CONTENT_DECODING_FAILED` in the app (2026-09-22).
1349
+ let errRaw = Buffer.alloc(0);
1350
+ // Set when the body was longer than the read cap, so its bytes are no longer a whole
1351
+ // compressed stream and only the decoded head can be forwarded.
1352
+ let errTruncated = false;
1353
+ const abortUpstream = () => {
1354
+ clientAborted = true;
1355
+ if (upReq && !upReq.destroyed)
1356
+ upReq.destroy();
1357
+ };
1358
+ req.on("aborted", abortUpstream);
1359
+ res.on("close", () => {
1360
+ if (!finished) {
1361
+ abortUpstream();
1362
+ finish("-", 0, "client closed");
1378
1363
  }
1379
- if (c.length > room)
1380
- errTruncated = true;
1381
- chunks.push(c.subarray(0, room));
1382
- size += Math.min(c.length, room);
1383
1364
  });
1384
- const handOver = () => {
1385
- errRaw = Buffer.concat(chunks, size);
1386
- headFilled = true;
1387
- resolveBody(decodeBodyToText(errRaw, stream.headers["content-encoding"]));
1365
+ const onUpstreamError = (e) => {
1366
+ // A kept-alive socket the upstream had already closed: the reset arrives before any response,
1367
+ // so the request never reached it. claude.ai did this 259 times in four days of picker mode
1368
+ // (2026-10-03..06), each one a 502 on an app request such as `current_user_access`. Send an
1369
+ // idempotent request again on a fresh attempt instead of failing it; a POST is never resent,
1370
+ // since a reset cannot prove the upstream did not act on it.
1371
+ const code = e.code;
1372
+ if (upReq.reusedSocket && (code === "ECONNRESET" || code === "EPIPE") && IDEMPOTENT.has(method) && !clientAborted && !res.headersSent && staleRetries < 2) {
1373
+ staleRetries++;
1374
+ log.info(`STALE ${reqHost}: kept-alive socket was closed (${code}); resending ${method} ${path.slice(0, 60)}`);
1375
+ send(lastAttemptHeaders);
1376
+ return;
1377
+ }
1378
+ if (target.host === cfg.upstream)
1379
+ this.deps.health.failure(e);
1380
+ // Nothing reached the provider, so this says nothing about the credential — but it does say
1381
+ // the route is unusable for a moment, and a pool with somewhere else to go should use it.
1382
+ // Our own abort is not the provider's fault and must not be charged to it.
1383
+ if (penalised && !clientAborted)
1384
+ this.pool.penalise(penalised.provider, penalised.id, 0);
1385
+ finish("-", 0, `upstream error ${e.code ?? ""} ${e.message}`);
1386
+ if (!res.headersSent) {
1387
+ res.writeHead(502, { "content-type": "application/json", connection: "close" });
1388
+ res.end(JSON.stringify({ error: { type: "clauderipple_upstream", message: `${target.host}: ${e.message}` } }));
1389
+ }
1390
+ else {
1391
+ res.destroy();
1392
+ }
1388
1393
  };
1389
- stream.once("end", handOver);
1390
- // A dropped or failed stream still hands over what arrived; the caller's own upstream error
1391
- // path is already attached, and duplicating it here would race it for the same response.
1392
- stream.once("error", handOver);
1393
- });
1394
- const onUpstreamJudged = (upRes, status, errBody) => {
1395
- // Charge the answer to the credential that produced it. A rate limit parks this one until its
1396
- // stated reset so the next request takes another; an answer clears whatever it was carrying.
1397
- // A 400 is our own request and is charged to nobody (see `classify`).
1398
- if (penalised) {
1399
- if (status >= 400) {
1400
- this.pool.penalise(penalised.provider, penalised.id, status, retryAfterMs(upRes.headers), errBody);
1401
- if (status === 401 && cfg.providers[penalised.provider]?.type === "anthropic")
1402
- this.claudeAccounts.reject(penalised.id);
1394
+ const onUpstreamResponse = (upRes) => {
1395
+ if (target.host === cfg.upstream)
1396
+ this.deps.health.success();
1397
+ const status = upRes.statusCode ?? 0;
1398
+ // A 403 is the one status whose meaning is in its body. A relay reporting a broken upstream
1399
+ // must not be charged to the credential — that is what made one working key answer every turn
1400
+ // with an authentication error for a minute (2026-09-21). Every other status is judged from
1401
+ // the status alone, and its body is left for the forward below, untouched.
1402
+ if (status === 403) {
1403
+ bufferBody(upRes, ERROR_HEAD_MAX).then((whole) => onUpstreamJudged(upRes, status, whole)).catch((e) => {
1404
+ log.warn(`upstream ${status}: could not read the body: ${e.message}`);
1405
+ finish(String(status), 0, `upstream ${status}: body read failed`);
1406
+ res.destroy();
1407
+ });
1408
+ return;
1403
1409
  }
1404
- else
1405
- this.pool.succeed(penalised.provider, penalised.id);
1406
- }
1407
- // Nothing has been written to the client yet, so a refused credential can still be replaced
1408
- // and the client answered on its first ask. Only here: once the answer starts flowing the
1409
- // turn is committed, because replacing a half-sent stream splices two answers together.
1410
- const retry = status >= 400 ? nextCredential(status, errBody) : null;
1411
- if (retry) {
1412
- const previousId = retry.ownerId ? penalised.id.split(":", 1)[0] : penalised.id;
1413
- log.info(`RETRY ${route.provider}: ${previousId} answered ${status}, trying ${retry.ownerId ?? retry.id}`);
1414
- tried.add(retry.id);
1415
- usedCredentialHeaders = { ...usedCredentialHeaders, ...(chosen?.headers ?? {}), ...retry.headers };
1416
- chosen = retry;
1417
- penalised = { provider: route.provider, id: retry.id };
1418
- for (const secret of credentialHeaderValues(Object.entries(retry.headers)))
1419
- errorSecrets.add(secret);
1420
- upRes.resume();
1421
- upRes.destroy();
1422
- // Remove every identity header used by any earlier attempt. The baseline is the first request,
1423
- // so removing only the immediately previous account would resurrect first-attempt fingerprints.
1424
- send(withCredential(headers, retry.headers, usedCredentialHeaders));
1425
- return;
1426
- }
1427
- const native = isApiHost && (!route || cfg.providers[route.provider]?.type === "anthropic");
1428
- if (native && typeof upRes.headers["request-id"] === "string")
1429
- record.upstreamRequestId = upRes.headers["request-id"];
1430
- const outHeaders = [];
1431
- const r = upRes.rawHeaders;
1432
- for (let i = 0; i < r.length; i += 2) {
1433
- const lk = r[i].toLowerCase();
1434
- if (lk === "connection" || lk === "keep-alive" || lk === "transfer-encoding")
1435
- continue;
1436
- if (isBootstrap && (lk === "content-length" || lk === "content-encoding"))
1437
- continue;
1438
- if (isPickerBootstrap && (lk === "etag" || lk === "last-modified"))
1439
- continue;
1440
- // A truncated 403 is forwarded decoded (its bytes are no longer a whole compressed
1441
- // stream), so neither the upstream's encoding nor its length describes what is sent.
1442
- if (errTruncated && (lk === "content-length" || lk === "content-encoding"))
1443
- continue;
1444
- outHeaders.push(r[i], r[i + 1]);
1445
- }
1446
- let bytes = 0;
1447
- if (isBootstrap) {
1410
+ onUpstreamJudged(upRes, status, "");
1411
+ };
1412
+ /**
1413
+ * The whole of a small body, read before anything is decided or written.
1414
+ *
1415
+ * A 403's meaning is in its body and cannot be judged before it arrives, so the bytes are read
1416
+ * and handed back as the head: `unshift` after a stream has ended throws, and reading a head and
1417
+ * forwarding the rest mid-flow is where a body can be lost to a pause nothing resumes. A 403 is
1418
+ * a refusal, so its body is small.
1419
+ *
1420
+ * What is returned is the body **decoded for reading**, because the judgement is about what the
1421
+ * body says. The bytes themselves are kept in `errRaw` for the client: a compressed body is
1422
+ * arbitrary binary, and text is a one-way door for it.
1423
+ *
1424
+ * Past `max` there is nothing worth identifying, so the rest is dropped — but the stream is
1425
+ * never destroyed. Tearing the connection down over a body size would turn a refusal the user
1426
+ * can read into a socket error, which is a worse bug than the one this handles; an unbounded
1427
+ * relay answer instead reaches the forward above the cap and is reported there.
1428
+ */
1429
+ const bufferBody = (stream, max) => new Promise((resolveBody) => {
1448
1430
  const chunks = [];
1449
- upRes.on("data", (c) => chunks.push(c));
1450
- upRes.on("end", () => {
1451
- let out = Buffer.concat(chunks);
1452
- const enc = String(upRes.headers["content-encoding"] ?? "").toLowerCase();
1453
- try {
1454
- if (enc === "gzip" || enc === "x-gzip")
1455
- out = zlib.gunzipSync(out);
1456
- else if (enc === "deflate")
1457
- out = zlib.inflateSync(out);
1458
- else if (enc === "br")
1459
- out = zlib.brotliDecompressSync(out);
1460
- else if (enc === "zstd" && typeof zlib.zstdDecompressSync === "function")
1461
- out = zlib.zstdDecompressSync(out);
1431
+ let size = 0;
1432
+ stream.on("data", (c) => {
1433
+ const room = max - size;
1434
+ if (room <= 0) {
1435
+ errTruncated = true;
1436
+ stream.resume();
1437
+ return;
1462
1438
  }
1463
- catch (e) {
1464
- log.warn(`bootstrap decode (${enc}) failed: ${e.message}`);
1439
+ if (c.length > room)
1440
+ errTruncated = true;
1441
+ chunks.push(c.subarray(0, room));
1442
+ size += Math.min(c.length, room);
1443
+ });
1444
+ const handOver = () => {
1445
+ errRaw = Buffer.concat(chunks, size);
1446
+ headFilled = true;
1447
+ resolveBody(decodeBodyToText(errRaw, stream.headers["content-encoding"]));
1448
+ };
1449
+ stream.once("end", handOver);
1450
+ // A dropped or failed stream still hands over what arrived; the caller's own upstream error
1451
+ // path is already attached, and duplicating it here would race it for the same response.
1452
+ stream.once("error", handOver);
1453
+ });
1454
+ const onUpstreamJudged = (upRes, status, errBody) => {
1455
+ // Charge the answer to the credential that produced it. A rate limit parks this one until its
1456
+ // stated reset so the next request takes another; an answer clears whatever it was carrying.
1457
+ // A 400 is our own request and is charged to nobody (see `classify`).
1458
+ if (penalised) {
1459
+ if (status >= 400) {
1460
+ this.pool.penalise(penalised.provider, penalised.id, status, retryAfterMs(upRes.headers), errBody);
1461
+ if (status === 401 && cfg.providers[penalised.provider]?.type === "anthropic")
1462
+ this.claudeAccounts.reject(penalised.id);
1465
1463
  }
1466
- if (status === 200 && isCliBootstrap) {
1464
+ else
1465
+ this.pool.succeed(penalised.provider, penalised.id);
1466
+ }
1467
+ // Nothing has been written to the client yet, so a refused credential can still be replaced
1468
+ // and the client answered on its first ask. Only here: once the answer starts flowing the
1469
+ // turn is committed, because replacing a half-sent stream splices two answers together.
1470
+ const retry = status >= 400 ? nextCredential(status, errBody) : null;
1471
+ if (retry) {
1472
+ const previousId = retry.ownerId ? penalised.id.split(":", 1)[0] : penalised.id;
1473
+ log.info(`RETRY ${route.provider}: ${previousId} answered ${status}, trying ${retry.ownerId ?? retry.id}`);
1474
+ tried.add(retry.id);
1475
+ usedCredentialHeaders = { ...usedCredentialHeaders, ...(chosen?.headers ?? {}), ...retry.headers };
1476
+ chosen = retry;
1477
+ penalised = { provider: route.provider, id: retry.id };
1478
+ for (const secret of credentialHeaderValues(Object.entries(retry.headers)))
1479
+ errorSecrets.add(secret);
1480
+ upRes.resume();
1481
+ upRes.destroy();
1482
+ // Remove every identity header used by any earlier attempt. The baseline is the first request,
1483
+ // so removing only the immediately previous account would resurrect first-attempt fingerprints.
1484
+ send(withCredential(headers, retry.headers, usedCredentialHeaders));
1485
+ return;
1486
+ }
1487
+ const native = isApiHost && (!route || cfg.providers[route.provider]?.type === "anthropic");
1488
+ if (native && typeof upRes.headers["request-id"] === "string")
1489
+ record.upstreamRequestId = upRes.headers["request-id"];
1490
+ const outHeaders = [];
1491
+ const r = upRes.rawHeaders;
1492
+ for (let i = 0; i < r.length; i += 2) {
1493
+ const lk = r[i].toLowerCase();
1494
+ if (lk === "connection" || lk === "keep-alive" || lk === "transfer-encoding")
1495
+ continue;
1496
+ if (isBootstrap && (lk === "content-length" || lk === "content-encoding"))
1497
+ continue;
1498
+ if (isPickerBootstrap && (lk === "etag" || lk === "last-modified"))
1499
+ continue;
1500
+ // A truncated 403 is forwarded decoded (its bytes are no longer a whole compressed
1501
+ // stream), so neither the upstream's encoding nor its length describes what is sent.
1502
+ if (errTruncated && (lk === "content-length" || lk === "content-encoding"))
1503
+ continue;
1504
+ outHeaders.push(r[i], r[i + 1]);
1505
+ }
1506
+ let bytes = 0;
1507
+ if (isBootstrap) {
1508
+ const chunks = [];
1509
+ upRes.on("data", (c) => chunks.push(c));
1510
+ upRes.on("end", () => {
1511
+ let out = Buffer.concat(chunks);
1512
+ const enc = String(upRes.headers["content-encoding"] ?? "").toLowerCase();
1467
1513
  try {
1468
- out = injectBootstrap(out, cfg);
1514
+ if (enc === "gzip" || enc === "x-gzip")
1515
+ out = zlib.gunzipSync(out);
1516
+ else if (enc === "deflate")
1517
+ out = zlib.inflateSync(out);
1518
+ else if (enc === "br")
1519
+ out = zlib.brotliDecompressSync(out);
1520
+ else if (enc === "zstd" && typeof zlib.zstdDecompressSync === "function")
1521
+ out = zlib.zstdDecompressSync(out);
1469
1522
  }
1470
1523
  catch (e) {
1471
- log.warn(`bootstrap inject failed: ${e.message}`);
1524
+ log.warn(`bootstrap decode (${enc}) failed: ${e.message}`);
1472
1525
  }
1473
- }
1474
- else if (isPickerBootstrap && status !== 200 && status !== 304) {
1475
- log.warn(`PICKER bootstrap upstream ${status}; headers: ${JSON.stringify(redactHeaders(upRes.headers))}; body: ${redactErrorText(out.toString("utf8"), [...errorSecrets], 300)}`);
1476
- }
1477
- else if (status === 200 && isPickerBootstrap) {
1478
- try {
1479
- const j = JSON.parse(out.toString("utf8"));
1480
- const r = injectPickerModels(j, chatPickerModels(cfg), cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
1481
- if (r.surfaces.length > 0) {
1482
- this.lastPickerInjection = { at: new Date().toISOString(), ...r };
1483
- out = Buffer.from(JSON.stringify(j));
1484
- log.info(`PICKER injected ${r.injected} model(s); surfaces: ${r.surfaces.map((s) => `${s.id}[${s.models.length}]`).join(" ")}`);
1526
+ if (status === 200 && isCliBootstrap) {
1527
+ try {
1528
+ out = injectBootstrap(out, cfg);
1485
1529
  }
1486
- else {
1487
- log.info(`PICKER bootstrap had no model_selector_config (${path.slice(0, 60)})`);
1530
+ catch (e) {
1531
+ log.warn(`bootstrap inject failed: ${e.message}`);
1488
1532
  }
1489
1533
  }
1490
- catch (e) {
1491
- log.warn(`picker inject failed: ${e.message}`);
1534
+ else if (isPickerBootstrap && status !== 200 && status !== 304) {
1535
+ log.warn(`PICKER bootstrap upstream ${status}; headers: ${JSON.stringify(redactHeaders(upRes.headers))}; body: ${redactErrorText(out.toString("utf8"), [...errorSecrets], 300)}`);
1536
+ }
1537
+ else if (status === 200 && isPickerBootstrap) {
1538
+ try {
1539
+ const j = JSON.parse(out.toString("utf8"));
1540
+ const r = injectPickerModels(j, chatPickerModels(cfg), cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
1541
+ if (r.surfaces.length > 0) {
1542
+ this.lastPickerInjection = { at: new Date().toISOString(), ...r };
1543
+ out = Buffer.from(JSON.stringify(j));
1544
+ log.info(`PICKER injected ${r.injected} model(s); surfaces: ${r.surfaces.map((s) => `${s.id}[${s.models.length}]`).join(" ")}`);
1545
+ }
1546
+ else {
1547
+ log.info(`PICKER bootstrap had no model_selector_config (${path.slice(0, 60)})`);
1548
+ }
1549
+ }
1550
+ catch (e) {
1551
+ log.warn(`picker inject failed: ${e.message}`);
1552
+ }
1492
1553
  }
1554
+ outHeaders.push("content-length", String(out.length));
1555
+ res.writeHead(status, outHeaders);
1556
+ res.end(out);
1557
+ finish(String(status), out.length);
1558
+ });
1559
+ upRes.on("error", (e) => {
1560
+ finish(String(status), bytes, `upstream stream error ${e.message}`);
1561
+ res.destroy();
1562
+ });
1563
+ return;
1564
+ }
1565
+ res.writeHead(status, outHeaders);
1566
+ // This observer is deliberately side-band: it sees the exact chunks after they are written
1567
+ // to the client, retains only a 64 KiB line/object fragment, and never changes backpressure.
1568
+ const tap = new ResponseUsageTap(typeof upRes.headers["content-type"] === "string" ? upRes.headers["content-type"] : undefined, typeof upRes.headers["content-encoding"] === "string" ? upRes.headers["content-encoding"] : undefined);
1569
+ // An error body is the only thing that says why the provider refused; keep its head for the
1570
+ // log and the Logs page. A DeepSeek 401 went unexplained for a day because only the status
1571
+ // code was recorded (2026-09-15).
1572
+ const errorHead = [];
1573
+ let errorHeadBytes = 0;
1574
+ // The 403 body was read to judge it, so it is written here instead of read again from a
1575
+ // stream that has already ended. The bytes go back exactly as they arrived, so a compressed
1576
+ // body still matches the `content-encoding` and `content-length` the upstream stated; only a
1577
+ // truncated one is sent decoded, and both headers were dropped above for it.
1578
+ if (headFilled) {
1579
+ const body = errTruncated ? Buffer.from(errBody, "utf8") : errRaw;
1580
+ bytes = body.length;
1581
+ res.end(body);
1582
+ finish(String(status), bytes, `upstream ${status}: ${redactErrorText(errBody, [...errorSecrets], 300)}`);
1583
+ return;
1584
+ }
1585
+ upRes.on("data", (c) => {
1586
+ bytes += c.length;
1587
+ const writable = res.write(c);
1588
+ tap.feed(c);
1589
+ if (status >= 400 && errorHeadBytes < ERROR_HEAD_MAX) {
1590
+ const remaining = ERROR_HEAD_MAX - errorHeadBytes;
1591
+ errorHead.push(c.subarray(0, remaining));
1592
+ errorHeadBytes += Math.min(c.length, remaining);
1593
+ }
1594
+ if (!writable)
1595
+ upRes.pause();
1596
+ });
1597
+ res.on("drain", () => upRes.resume());
1598
+ upRes.on("end", () => {
1599
+ const observed = tap.finish();
1600
+ observedUsage = observed.usage;
1601
+ observedStopReason = observed.stopReason;
1602
+ if (!native && observed.messageId !== undefined)
1603
+ record.messageId = observed.messageId;
1604
+ if (observed.servedTier !== undefined)
1605
+ record.servedTier = observed.servedTier;
1606
+ res.end();
1607
+ const errorBody = status >= 400 ? decodeBodyToText(Buffer.concat(errorHead), upRes.headers["content-encoding"]) : "";
1608
+ if (route && path.startsWith("/v1/messages") && !path.startsWith("/v1/messages/count_tokens")) {
1609
+ this.providerHealth.observe(route.provider, status, providerExhaustion(status, errorBody, retryAfterMs(upRes.headers)));
1610
+ }
1611
+ const note = status >= 400 ? `upstream ${status}: ${errorSnippet(Buffer.concat(errorHead), upRes.headers["content-encoding"], [...errorSecrets], upRes.headers["content-type"])}` : undefined;
1612
+ // Anthropic forgets a conversation's server-side thread after a few idle minutes, and the CLI
1613
+ // answers the 404 by replaying the whole conversation, which hits the prompt cache. From CLI
1614
+ // 2.1.288 this is about half the turns after five idle minutes (2026-10-07: 105 of 217, none
1615
+ // of 69 before), so it is recorded as resent rather than failed.
1616
+ if (status === 404 && note && /thread_not_found|No thread state was found/.test(note)) {
1617
+ finish(String(status), bytes, note, false, { resent: true });
1618
+ return;
1493
1619
  }
1494
- outHeaders.push("content-length", String(out.length));
1495
- res.writeHead(status, outHeaders);
1496
- res.end(out);
1497
- finish(String(status), out.length);
1620
+ finish(String(status), bytes, note);
1498
1621
  });
1499
1622
  upRes.on("error", (e) => {
1500
1623
  finish(String(status), bytes, `upstream stream error ${e.message}`);
1501
1624
  res.destroy();
1502
1625
  });
1503
- return;
1504
- }
1505
- res.writeHead(status, outHeaders);
1506
- // This observer is deliberately side-band: it sees the exact chunks after they are written
1507
- // to the client, retains only a 64 KiB line/object fragment, and never changes backpressure.
1508
- const tap = new ResponseUsageTap(typeof upRes.headers["content-type"] === "string" ? upRes.headers["content-type"] : undefined, typeof upRes.headers["content-encoding"] === "string" ? upRes.headers["content-encoding"] : undefined);
1509
- // An error body is the only thing that says why the provider refused; keep its head for the
1510
- // log and the Logs page. A DeepSeek 401 went unexplained for a day because only the status
1511
- // code was recorded (2026-09-15).
1512
- const errorHead = [];
1513
- let errorHeadBytes = 0;
1514
- // The 403 body was read to judge it, so it is written here instead of read again from a
1515
- // stream that has already ended. The bytes go back exactly as they arrived, so a compressed
1516
- // body still matches the `content-encoding` and `content-length` the upstream stated; only a
1517
- // truncated one is sent decoded, and both headers were dropped above for it.
1518
- if (headFilled) {
1519
- const body = errTruncated ? Buffer.from(errBody, "utf8") : errRaw;
1520
- bytes = body.length;
1521
- res.end(body);
1522
- finish(String(status), bytes, `upstream ${status}: ${redactErrorText(errBody, [...errorSecrets], 300)}`);
1523
- return;
1524
- }
1525
- upRes.on("data", (c) => {
1526
- bytes += c.length;
1527
- const writable = res.write(c);
1528
- tap.feed(c);
1529
- if (status >= 400 && errorHeadBytes < ERROR_HEAD_MAX) {
1530
- const remaining = ERROR_HEAD_MAX - errorHeadBytes;
1531
- errorHead.push(c.subarray(0, remaining));
1532
- errorHeadBytes += Math.min(c.length, remaining);
1533
- }
1534
- if (!writable)
1535
- upRes.pause();
1536
- });
1537
- res.on("drain", () => upRes.resume());
1538
- upRes.on("end", () => {
1539
- const observed = tap.finish();
1540
- observedUsage = observed.usage;
1541
- observedStopReason = observed.stopReason;
1542
- if (!native && observed.messageId !== undefined)
1543
- record.messageId = observed.messageId;
1544
- if (observed.servedTier !== undefined)
1545
- record.servedTier = observed.servedTier;
1546
- res.end();
1547
- const errorBody = status >= 400 ? decodeBodyToText(Buffer.concat(errorHead), upRes.headers["content-encoding"]) : "";
1548
- if (route && path.startsWith("/v1/messages") && !path.startsWith("/v1/messages/count_tokens")) {
1549
- this.providerHealth.observe(route.provider, status, providerExhaustion(status, errorBody, retryAfterMs(upRes.headers)));
1550
- }
1551
- const note = status >= 400 ? `upstream ${status}: ${errorSnippet(Buffer.concat(errorHead), upRes.headers["content-encoding"], [...errorSecrets], upRes.headers["content-type"])}` : undefined;
1552
- // Anthropic forgets a conversation's server-side thread after a few idle minutes, and the CLI
1553
- // answers the 404 by replaying the whole conversation, which hits the prompt cache. From CLI
1554
- // 2.1.288 this is about half the turns after five idle minutes (2026-10-07: 105 of 217, none
1555
- // of 69 before), so it is recorded as resent rather than failed.
1556
- if (status === 404 && note && /thread_not_found|No thread state was found/.test(note)) {
1557
- finish(String(status), bytes, note, false, { resent: true });
1558
- return;
1559
- }
1560
- finish(String(status), bytes, note);
1561
- });
1562
- upRes.on("error", (e) => {
1563
- finish(String(status), bytes, `upstream stream error ${e.message}`);
1564
- res.destroy();
1565
- });
1626
+ };
1627
+ send(headers);
1566
1628
  };
1567
- send(headers);
1629
+ await run(fetchRoute);
1568
1630
  }
1569
1631
  /**
1570
1632
  * Answer a WebSearch side request from a provider's hosted search instead of letting it reach a
@@ -1772,6 +1834,34 @@ export class Proxy {
1772
1834
  return a;
1773
1835
  }
1774
1836
  }
1837
+ /**
1838
+ * Let the ordinary adapters prepare their usual errors without committing one to the client.
1839
+ * Successful responses stream directly, with real backpressure; no body is buffered. Once a
1840
+ * success header is sent this becomes a transparent response, including mid-stream failures.
1841
+ */
1842
+ function deferErrorResponse(response, discarded) {
1843
+ let error = false;
1844
+ const guarded = new globalThis.Proxy(response, {
1845
+ get(target, key) {
1846
+ if (key === "writeHead")
1847
+ return (...args) => {
1848
+ if (discarded())
1849
+ return guarded;
1850
+ error = typeof args[0] === "number" && args[0] >= 400 && !target.headersSent;
1851
+ return error ? guarded : Reflect.apply(target.writeHead, target, args);
1852
+ };
1853
+ if (key === "write" || key === "end" || key === "flushHeaders" || key === "destroy")
1854
+ return (...args) => {
1855
+ if (discarded() || error || (key === "destroy" && !target.headersSent))
1856
+ return guarded;
1857
+ return Reflect.apply(target[key], target, args);
1858
+ };
1859
+ const value = Reflect.get(target, key, target);
1860
+ return typeof value === "function" ? value.bind(target) : value;
1861
+ },
1862
+ });
1863
+ return guarded;
1864
+ }
1775
1865
  function readBody(req) {
1776
1866
  return new Promise((resolveP, reject) => {
1777
1867
  const chunks = [];