@nvae/llmswitch 0.2.0 → 0.5.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.
@@ -1,17 +1,88 @@
1
1
  import { createServer, } from "node:http";
2
- import { buildProxyEnv } from "../utils/proxy.js";
3
- import { emptyProxy } from "../types.js";
4
- import { readBridgeState, readBridgeUpstreams } from "./state.js";
2
+ import { parseBridgeRuntimeLimits } from "./runtime.js";
3
+ import { requestWithNodeTransport, } from "./transport.js";
4
+ import { constantTimeTokenEqual, readBridgeState, readBridgeUpstreams, } from "./state.js";
5
5
  import { anthropicToChatRequest } from "./anthropic-translate-request.js";
6
6
  import { chatChunkToAnthropicEvents, chatCompletionToAnthropicMessage, createAnthropicStreamState, forceCompleteAnthropicStream, parseChatSseLine, } from "./anthropic-translate-response.js";
7
7
  import { collectCustomToolNames, responsesToChatRequest, responsesToCompletionsRequest, } from "./translate-request.js";
8
8
  import { chatChunkToResponsesEvents, chatCompletionToResponse, createStreamState, forceCompleteStream, parseChatSseLine as parseChatSseLineResponses, } from "./translate-response.js";
9
- function readBody(req) {
9
+ function headerValue(value) {
10
+ return Array.isArray(value) ? value[0] : value;
11
+ }
12
+ function controlToken(req) {
13
+ return headerValue(req.headers["x-llm-switch-control"]);
14
+ }
15
+ class RequestBodyTooLargeError extends Error {
16
+ maxBytes;
17
+ constructor(maxBytes) {
18
+ super(`Request body exceeded ${maxBytes} bytes`);
19
+ this.maxBytes = maxBytes;
20
+ this.name = "RequestBodyTooLargeError";
21
+ }
22
+ }
23
+ function bearerToken(req) {
24
+ const authorization = headerValue(req.headers.authorization);
25
+ const match = authorization?.match(/^Bearer\s+([^\s]+)$/i);
26
+ return match?.[1];
27
+ }
28
+ function authenticateDataRequest(req, upstream, tool) {
29
+ if (!upstream?.clientToken || upstream.migrationRequired)
30
+ return false;
31
+ const bearer = bearerToken(req);
32
+ if (tool === "codex") {
33
+ return constantTimeTokenEqual(upstream.clientToken, bearer);
34
+ }
35
+ const apiKey = headerValue(req.headers["x-api-key"]);
36
+ if (bearer && apiKey && !constantTimeTokenEqual(bearer, apiKey)) {
37
+ return false;
38
+ }
39
+ return constantTimeTokenEqual(upstream.clientToken, bearer || apiKey);
40
+ }
41
+ function authenticateModelsRequest(req, upstreams) {
42
+ return (authenticateDataRequest(req, upstreams.codex, "codex") ||
43
+ authenticateDataRequest(req, upstreams.claude, "claude"));
44
+ }
45
+ function readBody(req, maxBytes = parseBridgeRuntimeLimits().maxBodyBytes) {
10
46
  return new Promise((resolve, reject) => {
11
47
  const chunks = [];
12
- req.on("data", (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
13
- req.on("end", () => resolve(Buffer.concat(chunks)));
14
- req.on("error", reject);
48
+ let bytes = 0;
49
+ let settled = false;
50
+ const onData = (value) => {
51
+ if (settled)
52
+ return;
53
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
54
+ bytes += chunk.length;
55
+ if (bytes > maxBytes) {
56
+ settled = true;
57
+ cleanup();
58
+ req.resume();
59
+ reject(new RequestBodyTooLargeError(maxBytes));
60
+ return;
61
+ }
62
+ chunks.push(chunk);
63
+ };
64
+ const onEnd = () => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ cleanup();
69
+ resolve(Buffer.concat(chunks));
70
+ };
71
+ const onError = (error) => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ cleanup();
76
+ reject(error);
77
+ };
78
+ const cleanup = () => {
79
+ req.off("data", onData);
80
+ req.off("end", onEnd);
81
+ req.off("error", onError);
82
+ };
83
+ req.on("data", onData);
84
+ req.on("end", onEnd);
85
+ req.on("error", onError);
15
86
  });
16
87
  }
17
88
  function sendJson(res, status, body) {
@@ -33,56 +104,45 @@ function joinUrl(baseUrl, path) {
33
104
  }
34
105
  return `${base}${p}`;
35
106
  }
36
- function upstreamHeaders(upstream, incoming) {
107
+ function upstreamHeaders(upstream) {
37
108
  const headers = {
38
109
  Accept: "application/json",
39
110
  "Content-Type": "application/json",
40
111
  };
41
- const incomingAuth = incoming.headers.authorization;
42
- const incomingKey = incoming.headers["x-api-key"];
43
- if (incomingAuth) {
44
- headers.Authorization = Array.isArray(incomingAuth)
45
- ? incomingAuth[0]
46
- : incomingAuth;
47
- }
48
- else if (incomingKey) {
49
- const key = Array.isArray(incomingKey) ? incomingKey[0] : incomingKey;
50
- headers.Authorization = `Bearer ${key}`;
51
- }
52
- else if (upstream.apiKey) {
53
- headers.Authorization = `Bearer ${upstream.apiKey}`;
54
- }
112
+ let hasAuthorization = false;
55
113
  if (upstream.headers) {
56
- for (const [k, v] of Object.entries(upstream.headers)) {
57
- headers[k] = v;
114
+ for (const [name, value] of Object.entries(upstream.headers)) {
115
+ if (/^(connection|keep-alive|proxy-authenticate|proxy-authorization|te|trailer|transfer-encoding|upgrade)$/i.test(name)) {
116
+ continue;
117
+ }
118
+ headers[name] = value;
119
+ if (name.toLowerCase() === "authorization")
120
+ hasAuthorization = true;
58
121
  }
59
122
  }
123
+ if (upstream.apiKey && !hasAuthorization) {
124
+ headers.Authorization = `Bearer ${upstream.apiKey}`;
125
+ }
60
126
  return headers;
61
127
  }
62
- function withProxyEnv(upstream, fn) {
63
- if (emptyProxy(upstream.proxy))
64
- return fn();
65
- const next = buildProxyEnv(upstream.proxy);
66
- const backup = new Map();
67
- for (const [k, v] of Object.entries(next)) {
68
- backup.set(k, process.env[k]);
69
- process.env[k] = v;
70
- }
71
- return fn().finally(() => {
72
- for (const [k, v] of backup) {
73
- if (v === undefined)
74
- delete process.env[k];
75
- else
76
- process.env[k] = v;
77
- }
128
+ function requestUpstream(upstream, url, method, body, signal) {
129
+ const limits = parseBridgeRuntimeLimits();
130
+ return requestWithNodeTransport({
131
+ url,
132
+ method,
133
+ headers: upstreamHeaders(upstream),
134
+ body,
135
+ proxy: upstream.proxy,
136
+ signal,
137
+ connectTimeoutMs: limits.connectTimeoutMs,
138
+ idleTimeoutMs: limits.idleTimeoutMs,
139
+ totalTimeoutMs: limits.totalTimeoutMs,
140
+ maxResponseBytes: limits.maxResponseBytes,
78
141
  });
79
142
  }
80
- async function fetchModelsJson(upstream, req) {
143
+ async function fetchModelsJson(upstream) {
81
144
  const url = joinUrl(upstream.baseUrl, "/models");
82
- const response = await withProxyEnv(upstream, () => fetch(url, {
83
- method: "GET",
84
- headers: upstreamHeaders(upstream, req),
85
- }));
145
+ const response = await requestUpstream(upstream, url, "GET");
86
146
  if (!response.ok) {
87
147
  return { ok: false, status: response.status, data: [] };
88
148
  }
@@ -95,7 +155,7 @@ async function fetchModelsJson(upstream, req) {
95
155
  return { ok: false, status: 502, data: [] };
96
156
  }
97
157
  }
98
- async function proxyModelsMerged(req, res, upstreams) {
158
+ async function proxyModelsMerged(_req, res, upstreams) {
99
159
  const sides = [upstreams.codex, upstreams.claude].filter((u) => Boolean(u?.baseUrl));
100
160
  if (!sides.length) {
101
161
  sendJson(res, 503, {
@@ -103,7 +163,7 @@ async function proxyModelsMerged(req, res, upstreams) {
103
163
  });
104
164
  return;
105
165
  }
106
- const results = await Promise.all(sides.map((u) => fetchModelsJson(u, req).catch(() => ({
166
+ const results = await Promise.all(sides.map((u) => fetchModelsJson(u).catch(() => ({
107
167
  ok: false,
108
168
  status: 502,
109
169
  data: [],
@@ -148,7 +208,7 @@ async function handleResponses(req, res, upstream, bodyBuf) {
148
208
  }
149
209
  await forwardChatResponses(req, res, upstream, body, wantStream);
150
210
  }
151
- async function handleMessages(req, res, upstream, bodyBuf) {
211
+ async function handleMessages(_req, res, upstream, bodyBuf) {
152
212
  let body;
153
213
  try {
154
214
  body = JSON.parse(bodyBuf.toString("utf8"));
@@ -165,11 +225,7 @@ async function handleMessages(req, res, upstream, bodyBuf) {
165
225
  const url = joinUrl(upstream.baseUrl, "/chat/completions");
166
226
  let response;
167
227
  try {
168
- response = await withProxyEnv(upstream, () => fetch(url, {
169
- method: "POST",
170
- headers: upstreamHeaders(upstream, req),
171
- body: JSON.stringify(chatReq),
172
- }));
228
+ response = await requestUpstream(upstream, url, "POST", JSON.stringify(chatReq));
173
229
  }
174
230
  catch (err) {
175
231
  sendJson(res, 502, {
@@ -208,11 +264,7 @@ async function forwardChatResponses(req, res, upstream, body, wantStream) {
208
264
  const url = joinUrl(upstream.baseUrl, "/chat/completions");
209
265
  let response;
210
266
  try {
211
- response = await withProxyEnv(upstream, () => fetch(url, {
212
- method: "POST",
213
- headers: upstreamHeaders(upstream, req),
214
- body: JSON.stringify(chatReq),
215
- }));
267
+ response = await requestUpstream(upstream, url, "POST", JSON.stringify(chatReq));
216
268
  }
217
269
  catch (err) {
218
270
  sendJson(res, 502, {
@@ -236,22 +288,18 @@ async function forwardChatResponses(req, res, upstream, body, wantStream) {
236
288
  }
237
289
  if (!wantStream) {
238
290
  const json = (await response.json());
239
- sendJson(res, 200, chatCompletionToResponse(json, String(body.model || ""), customTools));
291
+ sendJson(res, 200, chatCompletionToResponse(json, String(body.model || ""), customTools, true));
240
292
  return;
241
293
  }
242
- await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools);
294
+ await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools, true);
243
295
  }
244
- async function forwardCompletions(req, res, upstream, body, wantStream) {
296
+ async function forwardCompletions(_req, res, upstream, body, wantStream) {
245
297
  const completionReq = responsesToCompletionsRequest(body);
246
298
  const customTools = collectCustomToolNames(body.tools);
247
299
  const url = joinUrl(upstream.baseUrl, "/completions");
248
300
  let response;
249
301
  try {
250
- response = await withProxyEnv(upstream, () => fetch(url, {
251
- method: "POST",
252
- headers: upstreamHeaders(upstream, req),
253
- body: JSON.stringify(completionReq),
254
- }));
302
+ response = await requestUpstream(upstream, url, "POST", JSON.stringify(completionReq));
255
303
  }
256
304
  catch (err) {
257
305
  sendJson(res, 502, {
@@ -271,19 +319,19 @@ async function forwardCompletions(req, res, upstream, body, wantStream) {
271
319
  }
272
320
  if (!wantStream) {
273
321
  const json = (await response.json());
274
- sendJson(res, 200, chatCompletionToResponse(json, String(body.model || ""), customTools));
322
+ sendJson(res, 200, chatCompletionToResponse(json, String(body.model || ""), customTools, true));
275
323
  return;
276
324
  }
277
- await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools);
325
+ await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools, true);
278
326
  }
279
- async function pipeChatStreamToResponses(upstream, res, model, customTools) {
327
+ async function pipeChatStreamToResponses(upstream, res, model, customTools, webSearchEnabled = false) {
280
328
  res.writeHead(200, {
281
329
  "Content-Type": "text/event-stream; charset=utf-8",
282
330
  "Cache-Control": "no-cache, no-transform",
283
331
  Connection: "keep-alive",
284
332
  "X-Accel-Buffering": "no",
285
333
  });
286
- const state = createStreamState(model, undefined, customTools);
334
+ const state = createStreamState(model, undefined, customTools, webSearchEnabled);
287
335
  const reader = upstream.body?.getReader();
288
336
  if (!reader) {
289
337
  for (const frame of forceCompleteStream(state))
@@ -396,40 +444,94 @@ async function pipeChatStreamToAnthropic(upstream, res, model) {
396
444
  res.end();
397
445
  }
398
446
  }
399
- export function createBridgeServer() {
447
+ export function createBridgeServer(options = {}) {
400
448
  return createServer(async (req, res) => {
401
449
  try {
402
- const upstreams = readBridgeUpstreams();
403
450
  const state = readBridgeState();
404
- const merged = {
405
- codex: upstreams.codex || state.upstreams.codex,
406
- claude: upstreams.claude || state.upstreams.claude,
407
- };
451
+ const expectedControlToken = options.controlToken ?? state.instance?.controlToken;
452
+ const expectedInstanceId = options.instanceId ?? state.instance?.id;
453
+ const upstreams = readBridgeUpstreams();
454
+ const merged = upstreams;
408
455
  const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
409
456
  const path = url.pathname.replace(/\/+$/, "") || "/";
410
457
  if (req.method === "GET" && (path === "/health" || path === "/v1/health")) {
458
+ const suppliedControl = controlToken(req);
459
+ if (!suppliedControl) {
460
+ sendJson(res, 200, { ok: true, service: "llm-switch-bridge" });
461
+ return;
462
+ }
463
+ if (!expectedControlToken ||
464
+ !constantTimeTokenEqual(expectedControlToken, suppliedControl)) {
465
+ sendJson(res, 401, {
466
+ ok: false,
467
+ error: { code: "invalid_control_token", message: "Unauthorized" },
468
+ });
469
+ return;
470
+ }
411
471
  sendJson(res, 200, {
412
472
  ok: true,
473
+ service: "llm-switch-bridge",
474
+ instanceId: expectedInstanceId,
413
475
  upstreams: {
414
476
  codex: merged.codex
415
477
  ? {
416
- baseUrl: merged.codex.baseUrl,
417
478
  mode: merged.codex.mode,
418
479
  profile: merged.codex.profileName || null,
480
+ migrationRequired: merged.codex.migrationRequired === true,
419
481
  }
420
482
  : null,
421
483
  claude: merged.claude
422
484
  ? {
423
- baseUrl: merged.claude.baseUrl,
424
485
  mode: merged.claude.mode,
425
486
  profile: merged.claude.profileName || null,
487
+ migrationRequired: merged.claude.migrationRequired === true,
426
488
  }
427
489
  : null,
428
490
  },
429
491
  });
430
492
  return;
431
493
  }
494
+ if (req.method === "POST" && path === "/_control/shutdown") {
495
+ const suppliedControl = controlToken(req);
496
+ if (!expectedControlToken ||
497
+ !constantTimeTokenEqual(expectedControlToken, suppliedControl)) {
498
+ sendJson(res, 401, {
499
+ ok: false,
500
+ error: { code: "invalid_control_token", message: "Unauthorized" },
501
+ });
502
+ return;
503
+ }
504
+ let instanceId = "";
505
+ try {
506
+ const body = JSON.parse((await readBody(req)).toString("utf8"));
507
+ instanceId = typeof body.instanceId === "string" ? body.instanceId : "";
508
+ }
509
+ catch {
510
+ sendJson(res, 400, { error: { message: "Invalid JSON body" } });
511
+ return;
512
+ }
513
+ if (!expectedInstanceId || instanceId !== expectedInstanceId) {
514
+ sendJson(res, 409, {
515
+ error: { code: "instance_mismatch", message: "Bridge instance mismatch" },
516
+ });
517
+ return;
518
+ }
519
+ sendJson(res, 202, { ok: true, instanceId });
520
+ queueMicrotask(() => {
521
+ void options.onShutdown?.(instanceId);
522
+ });
523
+ return;
524
+ }
432
525
  if (req.method === "GET" && (path === "/v1/models" || path === "/models")) {
526
+ if (!authenticateModelsRequest(req, merged)) {
527
+ sendJson(res, 401, {
528
+ error: {
529
+ code: "invalid_bridge_token",
530
+ message: "Bridge token 无效;升级后请重新执行 llms <tool> use <profile>",
531
+ },
532
+ });
533
+ return;
534
+ }
433
535
  await proxyModelsMerged(req, res, merged);
434
536
  return;
435
537
  }
@@ -443,6 +545,15 @@ export function createBridgeServer() {
443
545
  });
444
546
  return;
445
547
  }
548
+ if (!authenticateDataRequest(req, merged.codex, "codex")) {
549
+ sendJson(res, 401, {
550
+ error: {
551
+ code: "invalid_bridge_token",
552
+ message: "Bridge token 无效;请重新执行 llms codex use <profile>",
553
+ },
554
+ });
555
+ return;
556
+ }
446
557
  const body = await readBody(req);
447
558
  await handleResponses(req, res, merged.codex, body);
448
559
  return;
@@ -459,6 +570,16 @@ export function createBridgeServer() {
459
570
  });
460
571
  return;
461
572
  }
573
+ if (!authenticateDataRequest(req, merged.claude, "claude")) {
574
+ sendJson(res, 401, {
575
+ type: "error",
576
+ error: {
577
+ type: "authentication_error",
578
+ message: "Bridge token 无效;请重新执行 llms claude use <profile>",
579
+ },
580
+ });
581
+ return;
582
+ }
462
583
  const body = await readBody(req);
463
584
  await handleMessages(req, res, merged.claude, body);
464
585
  return;
@@ -470,6 +591,12 @@ export function createBridgeServer() {
470
591
  });
471
592
  }
472
593
  catch (err) {
594
+ if (err instanceof RequestBodyTooLargeError) {
595
+ sendJson(res, 413, {
596
+ error: { code: "request_too_large", message: err.message },
597
+ });
598
+ return;
599
+ }
473
600
  sendJson(res, 500, {
474
601
  error: {
475
602
  message: err instanceof Error ? err.message : String(err),
@@ -478,8 +605,8 @@ export function createBridgeServer() {
478
605
  }
479
606
  });
480
607
  }
481
- export function listenBridge(port, host) {
482
- const server = createBridgeServer();
608
+ export function listenBridge(port, host, options = {}) {
609
+ const server = createBridgeServer(options);
483
610
  return new Promise((resolve, reject) => {
484
611
  server.once("error", reject);
485
612
  server.listen(port, host, () => resolve(server));