clauderipple 0.2.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 +229 -0
- package/LICENSE +674 -0
- package/README.ko.md +328 -0
- package/README.md +372 -0
- package/bin/clauderipple.js +12 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/main.js +518 -0
- package/dist/cli/src/browser.js +21 -0
- package/dist/cli/src/bundle.js +51 -0
- package/dist/cli/src/certs.js +33 -0
- package/dist/cli/src/claude-auth.js +112 -0
- package/dist/cli/src/codex.js +172 -0
- package/dist/cli/src/gen-certs.js +7 -0
- package/dist/cli/src/hooks/agent-title.js +160 -0
- package/dist/cli/src/index.js +489 -0
- package/dist/cli/src/launchd.js +183 -0
- package/dist/cli/src/picker.js +166 -0
- package/dist/cli/src/probe.js +55 -0
- package/dist/cli/src/runtime.js +62 -0
- package/dist/cli/src/schtasks.js +134 -0
- package/dist/cli/src/settings.js +142 -0
- package/dist/cli/src/supervisor.js +100 -0
- package/dist/cli/src/tray.js +85 -0
- package/dist/router/src/admin.js +945 -0
- package/dist/router/src/bootstrap.js +80 -0
- package/dist/router/src/certs.js +65 -0
- package/dist/router/src/compat.js +172 -0
- package/dist/router/src/config.js +179 -0
- package/dist/router/src/health.js +45 -0
- package/dist/router/src/identity.js +51 -0
- package/dist/router/src/index.js +144 -0
- package/dist/router/src/ingress/models.js +29 -0
- package/dist/router/src/ingress/server.js +400 -0
- package/dist/router/src/ingress/translate.js +457 -0
- package/dist/router/src/log.js +81 -0
- package/dist/router/src/picker.js +74 -0
- package/dist/router/src/presets.js +267 -0
- package/dist/router/src/providers/anthropic-observed.js +88 -0
- package/dist/router/src/providers/anthropic-token-file.js +48 -0
- package/dist/router/src/providers/anthropic.js +203 -0
- package/dist/router/src/providers/chatgpt/auth.js +226 -0
- package/dist/router/src/providers/chatgpt/index.js +274 -0
- package/dist/router/src/providers/chatgpt/sse.js +28 -0
- package/dist/router/src/providers/chatgpt/translate.js +393 -0
- package/dist/router/src/providers/claude-oauth.js +252 -0
- package/dist/router/src/providers/openai/index.js +193 -0
- package/dist/router/src/providers/openai/translate.js +504 -0
- package/dist/router/src/proxy.js +724 -0
- package/dist/router/src/redact.js +43 -0
- package/dist/router/src/requestlog.js +346 -0
- package/dist/router/src/routing.js +113 -0
- package/dist/router/src/version.js +8 -0
- package/dist/router/src/x509.js +203 -0
- package/dist/ui/app.js +1228 -0
- package/dist/ui/i18n.js +95 -0
- package/dist/ui/index.html +104 -0
- package/dist/ui/presets-fallback.js +61 -0
- package/dist/ui/style.css +347 -0
- package/docs/ARCHITECTURE.md +441 -0
- package/package.json +66 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
// The proxy itself.
|
|
2
|
+
//
|
|
3
|
+
// client ──CONNECT host:port──▶ net server
|
|
4
|
+
// host == upstream → TLS terminated here (leaf cert), requests handled by an http.Server
|
|
5
|
+
// anything else → blind tunnel
|
|
6
|
+
//
|
|
7
|
+
// per request on the terminated connection:
|
|
8
|
+
// model resolves to a provider → body rewritten, sent to provider (http/https)
|
|
9
|
+
// otherwise → forwarded to https://<upstream> with headers preserved
|
|
10
|
+
//
|
|
11
|
+
// Reliability rules (see docs/ARCHITECTURE.md §5):
|
|
12
|
+
// - every request is tracked from arrival to completion and logged exactly once
|
|
13
|
+
// - client abort destroys the upstream request; upstream errors answer 502 if possible
|
|
14
|
+
// - connect-level upstream failures feed UpstreamHealth
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import https from "node:https";
|
|
18
|
+
import net from "node:net";
|
|
19
|
+
import tls from "node:tls";
|
|
20
|
+
import zlib from "node:zlib";
|
|
21
|
+
import { UpstreamHealth } from "./health.js";
|
|
22
|
+
import { BOOTSTRAP_PATH, injectBootstrap } from "./bootstrap.js";
|
|
23
|
+
import { THREAD_UNSUPPORTED, effortOf, resolve, rewriteBody, stripThreadFields, threadDecision } from "./routing.js";
|
|
24
|
+
import { forwardCompatibleHeader, resolveCompatibleCaps, sanitizeForCompatible } from "./compat.js";
|
|
25
|
+
import { applyIdentityToAnthropicBody } from "./identity.js";
|
|
26
|
+
import { PRESETS } from "./presets.js";
|
|
27
|
+
import { ChatGptAdapter } from "./providers/chatgpt/index.js";
|
|
28
|
+
import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
|
|
29
|
+
import { terminateHosts } from "./config.js";
|
|
30
|
+
import { injectPickerModels, isBootstrapPath } from "./picker.js";
|
|
31
|
+
import { ResponseUsageTap } from "./requestlog.js";
|
|
32
|
+
import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
|
|
33
|
+
const MAX_BODY = 64 * 1024 * 1024;
|
|
34
|
+
const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length"]);
|
|
35
|
+
// The caller's own Anthropic credentials. A routed request authenticates as the provider, so these
|
|
36
|
+
// are dropped rather than forwarded: the provider header replaces only the one it happens to share
|
|
37
|
+
// a name with, and the other would otherwise travel to an endpoint that is not Anthropic.
|
|
38
|
+
const CLIENT_AUTH = new Set(["authorization", "x-api-key"]);
|
|
39
|
+
/** How much of an upstream error body is kept for the log. */
|
|
40
|
+
const ERROR_HEAD_MAX = 4096;
|
|
41
|
+
/**
|
|
42
|
+
* A bounded, decoded upstream error excerpt with credentials completely masked. Providers may
|
|
43
|
+
* echo the key they rejected, including an opaque vendor-specific key format.
|
|
44
|
+
*/
|
|
45
|
+
export function errorSnippet(head, encoding, secrets = [], contentType) {
|
|
46
|
+
// An HTML error page is never an API answer: the provider URL points at a website (a bare
|
|
47
|
+
// vendor domain) or a login wall. Say that instead of quoting markup (measured 2026-09-13 with
|
|
48
|
+
// OpenRouter answering 200 HTML when the /api prefix was lost).
|
|
49
|
+
if (/text\/html/i.test(String(contentType ?? "")))
|
|
50
|
+
return `HTML page (${head.length}B) — the provider URL points at a website or a login page, not an API`;
|
|
51
|
+
let out = head;
|
|
52
|
+
const enc = String(encoding ?? "").toLowerCase();
|
|
53
|
+
try {
|
|
54
|
+
if (enc === "gzip" || enc === "x-gzip")
|
|
55
|
+
out = zlib.gunzipSync(head);
|
|
56
|
+
else if (enc === "deflate")
|
|
57
|
+
out = zlib.inflateSync(head);
|
|
58
|
+
else if (enc === "br")
|
|
59
|
+
out = zlib.brotliDecompressSync(head);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A truncated compressed body decodes to nothing; fall through to what is readable.
|
|
63
|
+
}
|
|
64
|
+
const masked = redactErrorText(out.toString("utf8"), secrets, 300);
|
|
65
|
+
return masked || "(empty body)";
|
|
66
|
+
}
|
|
67
|
+
export class Proxy {
|
|
68
|
+
stats = { inFlight: 0, messagesInFlight: 0, started: 0, completed: 0, failed: 0 };
|
|
69
|
+
inFlightSockets = new Set();
|
|
70
|
+
/** Set by drain(): new model calls are refused with a retryable 503 so the count can only fall. */
|
|
71
|
+
draining = false;
|
|
72
|
+
server;
|
|
73
|
+
httpServer;
|
|
74
|
+
upstreamAgent = new https.Agent({ keepAlive: true, maxSockets: 64 });
|
|
75
|
+
providerAgents = new Map();
|
|
76
|
+
chatgptAdapters = new Map();
|
|
77
|
+
openaiAdapters = new Map();
|
|
78
|
+
deps;
|
|
79
|
+
/** Latest rate-limit snapshot reported by any chatgpt provider (for the admin GUI). */
|
|
80
|
+
get chatgptRateLimits() {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const [name, a] of this.chatgptAdapters)
|
|
83
|
+
out[name] = a.adapter.lastRateLimits;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
chatgptAuthStatus() {
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [name, a] of this.chatgptAdapters)
|
|
89
|
+
out[name] = a.adapter.describeAuth();
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
chatgpt(name, cfg) {
|
|
93
|
+
const key = JSON.stringify(cfg);
|
|
94
|
+
const cur = this.chatgptAdapters.get(name);
|
|
95
|
+
if (cur && cur.key === key)
|
|
96
|
+
return cur.adapter;
|
|
97
|
+
const adapter = new ChatGptAdapter(name, cfg, this.deps.home, this.deps.log);
|
|
98
|
+
this.chatgptAdapters.set(name, { key, adapter });
|
|
99
|
+
return adapter;
|
|
100
|
+
}
|
|
101
|
+
openai(name, cfg) {
|
|
102
|
+
const key = JSON.stringify(cfg);
|
|
103
|
+
const cur = this.openaiAdapters.get(name);
|
|
104
|
+
if (cur && cur.key === key)
|
|
105
|
+
return cur.adapter;
|
|
106
|
+
const adapter = new OpenAiCompatibleAdapter(name, cfg, this.deps.log);
|
|
107
|
+
this.openaiAdapters.set(name, { key, adapter });
|
|
108
|
+
return adapter;
|
|
109
|
+
}
|
|
110
|
+
constructor(deps) {
|
|
111
|
+
this.deps = deps;
|
|
112
|
+
this.httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => {
|
|
113
|
+
void this.handle(req, res);
|
|
114
|
+
});
|
|
115
|
+
this.httpServer.keepAliveTimeout = 65_000;
|
|
116
|
+
this.httpServer.on("clientError", (err, socket) => {
|
|
117
|
+
if (err.code !== "ECONNRESET" && socket.writable) {
|
|
118
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
119
|
+
}
|
|
120
|
+
socket.destroy();
|
|
121
|
+
});
|
|
122
|
+
this.httpServer.on("upgrade", (req, socket, head) => this.upgrade(req, socket, head));
|
|
123
|
+
this.server = net.createServer((sock) => this.onConnect(sock));
|
|
124
|
+
}
|
|
125
|
+
/** Surfaces + model ids seen in the last injected bootstrap (admin GUI / debugging). */
|
|
126
|
+
lastPickerInjection = null;
|
|
127
|
+
listen() {
|
|
128
|
+
const { host, port } = this.deps.config().listen;
|
|
129
|
+
return new Promise((resolveP, reject) => {
|
|
130
|
+
this.server.once("error", reject);
|
|
131
|
+
this.server.listen(port, host, () => {
|
|
132
|
+
this.server.off("error", reject);
|
|
133
|
+
resolveP();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
close() {
|
|
138
|
+
this.server.close();
|
|
139
|
+
this.httpServer.close();
|
|
140
|
+
for (const s of this.inFlightSockets)
|
|
141
|
+
s.destroy();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Graceful shutdown: stop accepting new connections, let in-flight model calls finish
|
|
145
|
+
* (up to `maxMs`), then drop everything else. Long-poll worker streams are not waited
|
|
146
|
+
* for: the CLI reconnects those on its own, while a cut /v1/messages stream surfaces as
|
|
147
|
+
* "Connection lost mid-response" to the user (observed 2026-09-11, in-flight=5).
|
|
148
|
+
*/
|
|
149
|
+
async drain(maxMs, onProgress) {
|
|
150
|
+
// server.close() only stops new TCP connections; the CLI keeps sending new requests down
|
|
151
|
+
// its existing tunnels (measured 2026-09-13: in-flight went 2→1→2 and the budget ran out).
|
|
152
|
+
this.draining = true;
|
|
153
|
+
this.server.close();
|
|
154
|
+
const t0 = Date.now();
|
|
155
|
+
while (this.stats.messagesInFlight > 0 && Date.now() - t0 < maxMs) {
|
|
156
|
+
onProgress?.(this.stats.messagesInFlight);
|
|
157
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
158
|
+
}
|
|
159
|
+
this.close();
|
|
160
|
+
}
|
|
161
|
+
// ---- CONNECT handling -------------------------------------------------------------
|
|
162
|
+
onConnect(sock) {
|
|
163
|
+
const log = this.deps.log;
|
|
164
|
+
let head = Buffer.alloc(0);
|
|
165
|
+
sock.setNoDelay(true);
|
|
166
|
+
this.inFlightSockets.add(sock);
|
|
167
|
+
sock.once("close", () => this.inFlightSockets.delete(sock));
|
|
168
|
+
sock.on("error", (e) => log.warn(`client socket error ${e.message}`));
|
|
169
|
+
const onData = (chunk) => {
|
|
170
|
+
head = Buffer.concat([head, chunk]);
|
|
171
|
+
const end = head.indexOf("\r\n\r\n");
|
|
172
|
+
if (end < 0) {
|
|
173
|
+
if (head.length > 64 * 1024)
|
|
174
|
+
sock.destroy();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
sock.off("data", onData);
|
|
178
|
+
sock.pause(); // flowing mode with no listener would silently drop the client's next bytes (TLS ClientHello)
|
|
179
|
+
const rest = head.subarray(end + 4);
|
|
180
|
+
const line = head.subarray(0, end).toString("latin1").split("\r\n")[0] ?? "";
|
|
181
|
+
const [method, target] = line.split(" ");
|
|
182
|
+
if (method !== "CONNECT" || !target) {
|
|
183
|
+
sock.end("HTTP/1.1 405 Method Not Allowed\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const colon = target.lastIndexOf(":");
|
|
187
|
+
const host = colon > 0 ? target.slice(0, colon) : target;
|
|
188
|
+
const port = colon > 0 ? Number(target.slice(colon + 1)) : 443;
|
|
189
|
+
sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
190
|
+
if (rest.length > 0)
|
|
191
|
+
sock.unshift(rest);
|
|
192
|
+
if (terminateHosts(this.deps.config()).includes(host)) {
|
|
193
|
+
this.terminate(sock, host);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.tunnel(sock, host, port);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
sock.on("data", onData);
|
|
200
|
+
}
|
|
201
|
+
terminate(sock, host) {
|
|
202
|
+
let ctx;
|
|
203
|
+
try {
|
|
204
|
+
ctx = this.deps.certs.contextFor(host);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
this.deps.log.error(`no certificate for ${host}: ${e.message}; tunnelling instead`);
|
|
208
|
+
this.tunnel(sock, host, 443);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const tlsSock = new tls.TLSSocket(sock, {
|
|
212
|
+
isServer: true,
|
|
213
|
+
secureContext: ctx,
|
|
214
|
+
ALPNProtocols: ["http/1.1"],
|
|
215
|
+
// Chromium (picker mode) sends SNI; serve whichever terminated host it names.
|
|
216
|
+
SNICallback: (servername, cb) => {
|
|
217
|
+
try {
|
|
218
|
+
cb(null, terminateHosts(this.deps.config()).includes(servername) ? this.deps.certs.contextFor(servername) : ctx);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
cb(e);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
tlsSock.on("error", (e) => {
|
|
226
|
+
const msg = e.message;
|
|
227
|
+
if (!/ECONNRESET|ended by the other party/.test(msg))
|
|
228
|
+
this.deps.log.warn(`tls error ${msg}`);
|
|
229
|
+
});
|
|
230
|
+
this.httpServer.emit("connection", tlsSock);
|
|
231
|
+
sock.resume();
|
|
232
|
+
}
|
|
233
|
+
/** WebSocket / other upgrades on a terminated host: re-open TLS upstream and splice the sockets. */
|
|
234
|
+
upgrade(req, socket, head) {
|
|
235
|
+
const cfg = this.deps.config();
|
|
236
|
+
const host = (req.headers.host ?? cfg.upstream).split(":")[0];
|
|
237
|
+
const up = tls.connect({ host, port: 443, servername: host, ALPNProtocols: ["http/1.1"] });
|
|
238
|
+
const kill = () => {
|
|
239
|
+
socket.destroy();
|
|
240
|
+
up.destroy();
|
|
241
|
+
};
|
|
242
|
+
up.on("error", (e) => {
|
|
243
|
+
this.deps.log.warn(`upgrade upstream error ${host}: ${e.message}`);
|
|
244
|
+
kill();
|
|
245
|
+
});
|
|
246
|
+
socket.on("error", kill);
|
|
247
|
+
up.once("secureConnect", () => {
|
|
248
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
249
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2)
|
|
250
|
+
lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
251
|
+
up.write(lines.join("\r\n") + "\r\n\r\n");
|
|
252
|
+
if (head.length)
|
|
253
|
+
up.write(head);
|
|
254
|
+
socket.pipe(up);
|
|
255
|
+
up.pipe(socket);
|
|
256
|
+
this.deps.log.info(`UPGRADE ${host}${req.url}`);
|
|
257
|
+
});
|
|
258
|
+
up.on("close", () => socket.destroy());
|
|
259
|
+
socket.on("close", () => up.destroy());
|
|
260
|
+
}
|
|
261
|
+
tunnel(sock, host, port) {
|
|
262
|
+
const up = net.connect({ host, port });
|
|
263
|
+
const kill = () => {
|
|
264
|
+
sock.destroy();
|
|
265
|
+
up.destroy();
|
|
266
|
+
};
|
|
267
|
+
up.on("error", kill);
|
|
268
|
+
sock.on("error", kill);
|
|
269
|
+
up.once("connect", () => {
|
|
270
|
+
sock.pipe(up);
|
|
271
|
+
up.pipe(sock);
|
|
272
|
+
});
|
|
273
|
+
up.on("close", () => sock.destroy());
|
|
274
|
+
sock.on("close", () => up.destroy());
|
|
275
|
+
}
|
|
276
|
+
// ---- per-request handling ----------------------------------------------------------
|
|
277
|
+
async handle(req, res) {
|
|
278
|
+
const cfg = this.deps.config();
|
|
279
|
+
const log = this.deps.log;
|
|
280
|
+
const t0 = Date.now();
|
|
281
|
+
const method = req.method ?? "?";
|
|
282
|
+
const path = req.url ?? "/";
|
|
283
|
+
this.stats.started++;
|
|
284
|
+
this.stats.inFlight++;
|
|
285
|
+
const isMessages = path.startsWith("/v1/messages");
|
|
286
|
+
// The CLI sends `/v1/messages?beta=true`: compare the pathname, not the raw path.
|
|
287
|
+
const pathname = path.split("?")[0] ?? path;
|
|
288
|
+
const isLoggedRequest = pathname === "/v1/messages" || pathname === "/v1/messages/count_tokens";
|
|
289
|
+
if (isMessages)
|
|
290
|
+
this.stats.messagesInFlight++;
|
|
291
|
+
let tag = "PASS";
|
|
292
|
+
let finished = false;
|
|
293
|
+
let record = {
|
|
294
|
+
kind: pathname === "/v1/messages/count_tokens" ? "count_tokens" : pathname === "/v1/messages" ? "messages" : "other",
|
|
295
|
+
source: "-",
|
|
296
|
+
target: "-",
|
|
297
|
+
provider: "anthropic",
|
|
298
|
+
stream: false,
|
|
299
|
+
};
|
|
300
|
+
let observedUsage;
|
|
301
|
+
let observedStopReason;
|
|
302
|
+
// `failed` counts requests that did not get a proper response (vanished, upstream error,
|
|
303
|
+
// provider error). A note alone is not a failure: adapters attach usage notes on success.
|
|
304
|
+
const finish = (status, bytes, note, failed = note !== undefined, extra) => {
|
|
305
|
+
if (finished)
|
|
306
|
+
return;
|
|
307
|
+
finished = true;
|
|
308
|
+
this.stats.inFlight--;
|
|
309
|
+
if (isMessages)
|
|
310
|
+
this.stats.messagesInFlight--;
|
|
311
|
+
if (failed)
|
|
312
|
+
this.stats.failed++;
|
|
313
|
+
else
|
|
314
|
+
this.stats.completed++;
|
|
315
|
+
const ms = Date.now() - t0;
|
|
316
|
+
log.info(`${tag} ${method} ${path} -> ${status} ${bytes}B ${(ms / 1000).toFixed(1)}s${note ? " " + note : ""}`);
|
|
317
|
+
if (isLoggedRequest) {
|
|
318
|
+
const numericStatus = Number(status);
|
|
319
|
+
const usage = extra?.usage ?? observedUsage;
|
|
320
|
+
const stopReason = extra?.stopReason ?? observedStopReason;
|
|
321
|
+
const completed = {
|
|
322
|
+
...record,
|
|
323
|
+
id: crypto.randomUUID(),
|
|
324
|
+
at: new Date(t0).toISOString(),
|
|
325
|
+
ms,
|
|
326
|
+
status: Number.isFinite(numericStatus) ? numericStatus : status,
|
|
327
|
+
ok: Number.isFinite(numericStatus) && numericStatus >= 200 && numericStatus < 400,
|
|
328
|
+
};
|
|
329
|
+
if (usage)
|
|
330
|
+
completed.usage = usage;
|
|
331
|
+
if (stopReason)
|
|
332
|
+
completed.stopReason = stopReason;
|
|
333
|
+
if (note)
|
|
334
|
+
completed.note = note;
|
|
335
|
+
this.deps.requests.add(completed);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
if (this.draining && isMessages) {
|
|
339
|
+
// Refuse before reading the body: the client (Anthropic SDK) retries 5xx with backoff and
|
|
340
|
+
// honors retry-after; connection: close makes it reconnect, to the relaunched router.
|
|
341
|
+
tag = "DRAIN";
|
|
342
|
+
const msg = JSON.stringify({ type: "error", error: { type: "overloaded_error", message: "ClaudeRipple is restarting; retry" } });
|
|
343
|
+
res.writeHead(503, { "content-type": "application/json", "retry-after": "3", connection: "close", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
|
|
344
|
+
req.resume();
|
|
345
|
+
finish("503", msg.length, "refused during drain (client retries)", false);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let body;
|
|
349
|
+
try {
|
|
350
|
+
body = await readBody(req);
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
finish("-", 0, `body read failed: ${e.message}`);
|
|
354
|
+
if (!res.headersSent)
|
|
355
|
+
res.writeHead(400).end();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Which terminated host is this request for? Only the Anthropic API host is routed;
|
|
359
|
+
// anything else (claude.ai in picker mode) is passed through, with bootstrap injection.
|
|
360
|
+
const reqHost = (req.headers.host ?? cfg.upstream).split(":")[0];
|
|
361
|
+
const isApiHost = reqHost === cfg.upstream;
|
|
362
|
+
// Route decision: only Messages requests carry a model.
|
|
363
|
+
let json = null;
|
|
364
|
+
let model;
|
|
365
|
+
if (isApiHost && path.startsWith("/v1/messages")) {
|
|
366
|
+
try {
|
|
367
|
+
json = JSON.parse(body.toString("utf8"));
|
|
368
|
+
model = json.model;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
json = null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const route = json ? resolve(model, json, cfg) : null;
|
|
375
|
+
const source = typeof model === "string" ? model : "-";
|
|
376
|
+
record = {
|
|
377
|
+
...record,
|
|
378
|
+
source,
|
|
379
|
+
target: source,
|
|
380
|
+
stream: json?.stream === true,
|
|
381
|
+
};
|
|
382
|
+
const requestedEffort = effortOf(json ?? {});
|
|
383
|
+
if (requestedEffort)
|
|
384
|
+
record.effort = requestedEffort;
|
|
385
|
+
// Only a real, un-routed Claude Code Messages request may refresh this RAM-only source.
|
|
386
|
+
// Do not inspect it elsewhere: it must never enter logs, RequestLog, picker diagnostics, or admin data.
|
|
387
|
+
if (isApiHost && pathname === "/v1/messages" && !route)
|
|
388
|
+
this.deps.observedClaudeCodeAuth?.observe(req.rawHeaders);
|
|
389
|
+
let compatCaps;
|
|
390
|
+
let compatChanges = [];
|
|
391
|
+
let target;
|
|
392
|
+
if (route && json) {
|
|
393
|
+
const provider = cfg.providers[route.provider];
|
|
394
|
+
if (!provider) {
|
|
395
|
+
finish("500", 0, `unknown provider ${route.provider}`);
|
|
396
|
+
res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
rewriteBody(json, route, cfg.effortClamp);
|
|
400
|
+
if (provider.type === "chatgpt") {
|
|
401
|
+
const td = threadDecision(json);
|
|
402
|
+
if (td === "refuse") {
|
|
403
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
404
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
405
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (td === "strip")
|
|
409
|
+
stripThreadFields(json);
|
|
410
|
+
record = { ...record, target: route.model, provider: route.provider };
|
|
411
|
+
const routeEffort = effortOf(json);
|
|
412
|
+
if (routeEffort)
|
|
413
|
+
record.effort = routeEffort;
|
|
414
|
+
tag = `CHATGPT ${route.tag} effort=${routeEffort ?? "-"}`;
|
|
415
|
+
try {
|
|
416
|
+
const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
|
|
417
|
+
finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
|
|
418
|
+
}
|
|
419
|
+
catch (e) {
|
|
420
|
+
finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
|
|
421
|
+
if (!res.headersSent)
|
|
422
|
+
res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
|
|
423
|
+
else
|
|
424
|
+
res.destroy();
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (provider.type === "openai-compatible") {
|
|
429
|
+
const td = threadDecision(json);
|
|
430
|
+
if (td === "refuse") {
|
|
431
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
432
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
433
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (td === "strip")
|
|
437
|
+
stripThreadFields(json);
|
|
438
|
+
record = { ...record, target: route.model, provider: route.provider };
|
|
439
|
+
const routeEffort = effortOf(json);
|
|
440
|
+
if (routeEffort)
|
|
441
|
+
record.effort = routeEffort;
|
|
442
|
+
tag = `OPENAI ${route.tag} wire=${provider.wire ?? "chat"} effort=${routeEffort ?? "-"}`;
|
|
443
|
+
try {
|
|
444
|
+
const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
|
|
445
|
+
finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
finish("-", 0, `openai error ${e.code ?? ""} ${e.message}`);
|
|
449
|
+
if (!res.headersSent)
|
|
450
|
+
res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
|
|
451
|
+
else
|
|
452
|
+
res.destroy();
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
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
|
+
if (provider.type === "anthropic") {
|
|
460
|
+
finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
|
|
461
|
+
res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "invalid_request_error", message: "native Anthropic provider is available through OpenAI ingress only" } }));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
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
|
+
}
|
|
499
|
+
else if (isApiHost) {
|
|
500
|
+
target = { protocol: "https:", host: cfg.upstream, port: 443, agent: this.upstreamAgent, extraHeaders: {} };
|
|
501
|
+
tag = `PASS ${typeof model === "string" ? model : "-"}`;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
|
|
505
|
+
tag = `WEB ${reqHost}`;
|
|
506
|
+
}
|
|
507
|
+
// Two kinds of response editing: the CLI bootstrap (api host) and the claude.ai bootstrap (picker mode).
|
|
508
|
+
const isCliBootstrap = isApiHost && !route && path.startsWith(BOOTSTRAP_PATH);
|
|
509
|
+
const isPickerBootstrap = !isApiHost && !!cfg.picker?.enabled && method === "GET" && isBootstrapPath(path);
|
|
510
|
+
const isBootstrap = isCliBootstrap || isPickerBootstrap;
|
|
511
|
+
const headers = [];
|
|
512
|
+
const raw = req.rawHeaders;
|
|
513
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
514
|
+
const k = raw[i];
|
|
515
|
+
const lk = k.toLowerCase();
|
|
516
|
+
if (HOP_BY_HOP.has(lk))
|
|
517
|
+
continue;
|
|
518
|
+
// Bootstrap responses are edited: keep the client's accept-encoding as-is (some edges misbehave without it)
|
|
519
|
+
// and decompress whatever comes back before editing.
|
|
520
|
+
if (lk in target.extraHeaders)
|
|
521
|
+
continue;
|
|
522
|
+
if (target.dropClientAuth && CLIENT_AUTH.has(lk))
|
|
523
|
+
continue;
|
|
524
|
+
// Anthropic beta flags opt into features most compatible providers have not implemented.
|
|
525
|
+
if (compatCaps && !forwardCompatibleHeader(lk, compatCaps)) {
|
|
526
|
+
compatChanges.push("anthropic-beta");
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
// Picker bootstrap: never let the renderer revalidate against a cached (unedited) copy.
|
|
530
|
+
if (isPickerBootstrap && (lk === "if-none-match" || lk === "if-modified-since"))
|
|
531
|
+
continue;
|
|
532
|
+
headers.push(k, raw[i + 1]);
|
|
533
|
+
}
|
|
534
|
+
for (const [k, v] of Object.entries(target.extraHeaders))
|
|
535
|
+
headers.push(k, v);
|
|
536
|
+
if (route && compatChanges.length > 0)
|
|
537
|
+
log.info(`COMPAT ${route.provider}: ${compatChanges.join(", ")}`);
|
|
538
|
+
headers.push("host", target.protocol === "https:" && target.port === 443 ? target.host : `${target.host}:${target.port}`);
|
|
539
|
+
// No content-length on body-less GET/HEAD: some edges reject it; Chromium never sends it.
|
|
540
|
+
if (body.length > 0 || (method !== "GET" && method !== "HEAD"))
|
|
541
|
+
headers.push("content-length", String(body.length));
|
|
542
|
+
if (isPickerBootstrap) {
|
|
543
|
+
const shown = [];
|
|
544
|
+
for (const [rawName, value] of Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]])) {
|
|
545
|
+
const safe = redactHeaders({ [rawName]: value })[rawName];
|
|
546
|
+
shown.push(safe === "[REDACTED]" ? `${rawName.toLowerCase()}=<${value.length}B>` : `${rawName.toLowerCase()}=${value}`);
|
|
547
|
+
}
|
|
548
|
+
log.info(`PICKER request ${method} ${path.slice(0, 80)} headers: ${shown.join(" | ")}`);
|
|
549
|
+
}
|
|
550
|
+
// Keep only actual credentials named by the outbound headers. This covers arbitrary vendor
|
|
551
|
+
// key formats when a provider reflects the key in its error body.
|
|
552
|
+
const errorSecrets = credentialHeaderValues(Array.from({ length: headers.length / 2 }, (_, i) => [headers[i * 2], headers[i * 2 + 1]]));
|
|
553
|
+
const lib = target.protocol === "https:" ? https : http;
|
|
554
|
+
const upReq = lib.request({
|
|
555
|
+
protocol: target.protocol,
|
|
556
|
+
host: target.host,
|
|
557
|
+
port: target.port,
|
|
558
|
+
method,
|
|
559
|
+
path: target.basePath ? target.basePath + path : path,
|
|
560
|
+
headers,
|
|
561
|
+
agent: target.agent,
|
|
562
|
+
...(target.protocol === "https:" ? { servername: target.host } : {}),
|
|
563
|
+
});
|
|
564
|
+
const abortUpstream = () => {
|
|
565
|
+
if (!upReq.destroyed)
|
|
566
|
+
upReq.destroy();
|
|
567
|
+
};
|
|
568
|
+
req.on("aborted", abortUpstream);
|
|
569
|
+
res.on("close", () => {
|
|
570
|
+
if (!finished) {
|
|
571
|
+
abortUpstream();
|
|
572
|
+
finish("-", 0, "client closed");
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
upReq.on("error", (e) => {
|
|
576
|
+
if (target.host === cfg.upstream)
|
|
577
|
+
this.deps.health.failure(e);
|
|
578
|
+
finish("-", 0, `upstream error ${e.code ?? ""} ${e.message}`);
|
|
579
|
+
if (!res.headersSent) {
|
|
580
|
+
res.writeHead(502, { "content-type": "application/json", connection: "close" });
|
|
581
|
+
res.end(JSON.stringify({ error: { type: "clauderipple_upstream", message: `${target.host}: ${e.message}` } }));
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
res.destroy();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
upReq.on("response", (upRes) => {
|
|
588
|
+
if (target.host === cfg.upstream)
|
|
589
|
+
this.deps.health.success();
|
|
590
|
+
const status = upRes.statusCode ?? 0;
|
|
591
|
+
const outHeaders = [];
|
|
592
|
+
const r = upRes.rawHeaders;
|
|
593
|
+
for (let i = 0; i < r.length; i += 2) {
|
|
594
|
+
const lk = r[i].toLowerCase();
|
|
595
|
+
if (lk === "connection" || lk === "keep-alive" || lk === "transfer-encoding")
|
|
596
|
+
continue;
|
|
597
|
+
if (isBootstrap && (lk === "content-length" || lk === "content-encoding"))
|
|
598
|
+
continue;
|
|
599
|
+
if (isPickerBootstrap && (lk === "etag" || lk === "last-modified"))
|
|
600
|
+
continue;
|
|
601
|
+
outHeaders.push(r[i], r[i + 1]);
|
|
602
|
+
}
|
|
603
|
+
let bytes = 0;
|
|
604
|
+
if (isBootstrap) {
|
|
605
|
+
const chunks = [];
|
|
606
|
+
upRes.on("data", (c) => chunks.push(c));
|
|
607
|
+
upRes.on("end", () => {
|
|
608
|
+
let out = Buffer.concat(chunks);
|
|
609
|
+
const enc = String(upRes.headers["content-encoding"] ?? "").toLowerCase();
|
|
610
|
+
try {
|
|
611
|
+
if (enc === "gzip" || enc === "x-gzip")
|
|
612
|
+
out = zlib.gunzipSync(out);
|
|
613
|
+
else if (enc === "deflate")
|
|
614
|
+
out = zlib.inflateSync(out);
|
|
615
|
+
else if (enc === "br")
|
|
616
|
+
out = zlib.brotliDecompressSync(out);
|
|
617
|
+
else if (enc === "zstd" && typeof zlib.zstdDecompressSync === "function")
|
|
618
|
+
out = zlib.zstdDecompressSync(out);
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
log.warn(`bootstrap decode (${enc}) failed: ${e.message}`);
|
|
622
|
+
}
|
|
623
|
+
if (status === 200 && isCliBootstrap) {
|
|
624
|
+
try {
|
|
625
|
+
out = injectBootstrap(out, cfg);
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
log.warn(`bootstrap inject failed: ${e.message}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
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)}`);
|
|
633
|
+
}
|
|
634
|
+
else if (status === 200 && isPickerBootstrap) {
|
|
635
|
+
try {
|
|
636
|
+
const j = JSON.parse(out.toString("utf8"));
|
|
637
|
+
const r = injectPickerModels(j, cfg.cli.extraModels, cfg.cli.autoCompactWindow);
|
|
638
|
+
if (r.surfaces.length > 0) {
|
|
639
|
+
this.lastPickerInjection = { at: new Date().toISOString(), ...r };
|
|
640
|
+
out = Buffer.from(JSON.stringify(j));
|
|
641
|
+
log.info(`PICKER injected ${r.injected} model(s); surfaces: ${r.surfaces.map((s) => `${s.id}[${s.models.length}]`).join(" ")}`);
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
log.info(`PICKER bootstrap had no model_selector_config (${path.slice(0, 60)})`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
log.warn(`picker inject failed: ${e.message}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
outHeaders.push("content-length", String(out.length));
|
|
652
|
+
res.writeHead(status, outHeaders);
|
|
653
|
+
res.end(out);
|
|
654
|
+
finish(String(status), out.length);
|
|
655
|
+
});
|
|
656
|
+
upRes.on("error", (e) => {
|
|
657
|
+
finish(String(status), bytes, `upstream stream error ${e.message}`);
|
|
658
|
+
res.destroy();
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
res.writeHead(status, outHeaders);
|
|
663
|
+
// This observer is deliberately side-band: it sees the exact chunks after they are written
|
|
664
|
+
// to the client, retains only a 64 KiB line/object fragment, and never changes backpressure.
|
|
665
|
+
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);
|
|
666
|
+
// An error body is the only thing that says why the provider refused; keep its head for the
|
|
667
|
+
// log and the Logs page. A DeepSeek 401 went unexplained for a day because only the status
|
|
668
|
+
// code was recorded (2026-09-15).
|
|
669
|
+
const errorHead = [];
|
|
670
|
+
let errorHeadBytes = 0;
|
|
671
|
+
upRes.on("data", (c) => {
|
|
672
|
+
bytes += c.length;
|
|
673
|
+
const writable = res.write(c);
|
|
674
|
+
tap.feed(c);
|
|
675
|
+
if (status >= 400 && errorHeadBytes < ERROR_HEAD_MAX) {
|
|
676
|
+
const remaining = ERROR_HEAD_MAX - errorHeadBytes;
|
|
677
|
+
errorHead.push(c.subarray(0, remaining));
|
|
678
|
+
errorHeadBytes += Math.min(c.length, remaining);
|
|
679
|
+
}
|
|
680
|
+
if (!writable)
|
|
681
|
+
upRes.pause();
|
|
682
|
+
});
|
|
683
|
+
res.on("drain", () => upRes.resume());
|
|
684
|
+
upRes.on("end", () => {
|
|
685
|
+
const observed = tap.finish();
|
|
686
|
+
observedUsage = observed.usage;
|
|
687
|
+
observedStopReason = observed.stopReason;
|
|
688
|
+
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);
|
|
690
|
+
});
|
|
691
|
+
upRes.on("error", (e) => {
|
|
692
|
+
finish(String(status), bytes, `upstream stream error ${e.message}`);
|
|
693
|
+
res.destroy();
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
upReq.end(body);
|
|
697
|
+
}
|
|
698
|
+
agentFor(provider, protocol) {
|
|
699
|
+
const key = `${provider}|${protocol}`;
|
|
700
|
+
let a = this.providerAgents.get(key);
|
|
701
|
+
if (!a) {
|
|
702
|
+
a = protocol === "https:" ? new https.Agent({ keepAlive: true, maxSockets: 64 }) : new http.Agent({ keepAlive: true, maxSockets: 64 });
|
|
703
|
+
this.providerAgents.set(key, a);
|
|
704
|
+
}
|
|
705
|
+
return a;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
function readBody(req) {
|
|
709
|
+
return new Promise((resolveP, reject) => {
|
|
710
|
+
const chunks = [];
|
|
711
|
+
let n = 0;
|
|
712
|
+
req.on("data", (c) => {
|
|
713
|
+
n += c.length;
|
|
714
|
+
if (n > MAX_BODY) {
|
|
715
|
+
reject(new Error("body too large"));
|
|
716
|
+
req.destroy();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
chunks.push(c);
|
|
720
|
+
});
|
|
721
|
+
req.on("end", () => resolveP(Buffer.concat(chunks)));
|
|
722
|
+
req.on("error", reject);
|
|
723
|
+
});
|
|
724
|
+
}
|