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