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.
- package/CHANGELOG.md +27 -0
- package/dist/app/package.json +1 -1
- package/dist/mod/hooks/register.tsx +9 -5
- package/dist/mod/hooks/workers.ts +19 -1
- package/dist/mod/tests/register.test.tsx +19 -3
- package/dist/router/src/proxy.js +801 -711
- package/dist/router/src/tether-win.js +24 -2
- package/dist/router/src/tether.js +66 -10
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/webfetch.js +56 -0
- package/dist/ui/app.js +1 -1
- package/dist/ui/i18n.js +12 -6
- package/docs/ARCHITECTURE.md +107 -10
- package/package.json +1 -1
package/dist/router/src/proxy.js
CHANGED
|
@@ -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
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
934
|
-
}
|
|
853
|
+
response.destroy();
|
|
854
|
+
});
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (!fetchTarget) {
|
|
858
|
+
complete(status, bytes, note, failed, extra);
|
|
935
859
|
return;
|
|
936
860
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
if (
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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 (
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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 (
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
const
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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 (
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1524
|
+
log.warn(`bootstrap decode (${enc}) failed: ${e.message}`);
|
|
1472
1525
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1487
|
-
log.
|
|
1530
|
+
catch (e) {
|
|
1531
|
+
log.warn(`bootstrap inject failed: ${e.message}`);
|
|
1488
1532
|
}
|
|
1489
1533
|
}
|
|
1490
|
-
|
|
1491
|
-
log.warn(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [];
|