@nvae/llmswitch 0.2.0 → 0.4.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/README.md +1 -1
- package/dist/adapters/claude.js +8 -5
- package/dist/adapters/codex.js +10 -4
- package/dist/bridge/manager.js +241 -146
- package/dist/bridge/runtime.js +199 -0
- package/dist/bridge/server.js +207 -80
- package/dist/bridge/state.js +345 -69
- package/dist/bridge/translate-response.js +205 -80
- package/dist/bridge/transport.js +439 -0
- package/dist/commands/bridge-cmd.js +13 -11
- package/dist/commands/prompts.js +47 -51
- package/dist/store/profiles.js +2 -2
- package/dist/types.js +20 -3
- package/dist/utils/fetch-models.js +45 -56
- package/dist/utils/proxy.js +14 -37
- package/package.json +5 -3
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
export const DEFAULT_BRIDGE_RUNTIME_LIMITS = Object.freeze({
|
|
3
|
+
maxBodyBytes: 16_777_216,
|
|
4
|
+
maxResponseBytes: 33_554_432,
|
|
5
|
+
maxSseFrameBytes: 2_097_152,
|
|
6
|
+
connectTimeoutMs: 30_000,
|
|
7
|
+
idleTimeoutMs: 90_000,
|
|
8
|
+
totalTimeoutMs: 600_000,
|
|
9
|
+
maxConcurrency: 16,
|
|
10
|
+
rateLimitPerMinute: 120,
|
|
11
|
+
});
|
|
12
|
+
const RUNTIME_LIMIT_SPECS = [
|
|
13
|
+
{
|
|
14
|
+
env: "LLM_SWITCH_MAX_BODY_BYTES",
|
|
15
|
+
key: "maxBodyBytes",
|
|
16
|
+
min: 1_024,
|
|
17
|
+
max: 67_108_864,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
env: "LLM_SWITCH_MAX_RESPONSE_BYTES",
|
|
21
|
+
key: "maxResponseBytes",
|
|
22
|
+
min: 1_024,
|
|
23
|
+
max: 134_217_728,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
env: "LLM_SWITCH_MAX_SSE_FRAME_BYTES",
|
|
27
|
+
key: "maxSseFrameBytes",
|
|
28
|
+
min: 1_024,
|
|
29
|
+
max: 16_777_216,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
env: "LLM_SWITCH_CONNECT_TIMEOUT_MS",
|
|
33
|
+
key: "connectTimeoutMs",
|
|
34
|
+
min: 1_000,
|
|
35
|
+
max: 120_000,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
env: "LLM_SWITCH_IDLE_TIMEOUT_MS",
|
|
39
|
+
key: "idleTimeoutMs",
|
|
40
|
+
min: 1_000,
|
|
41
|
+
max: 600_000,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
env: "LLM_SWITCH_TOTAL_TIMEOUT_MS",
|
|
45
|
+
key: "totalTimeoutMs",
|
|
46
|
+
min: 1_000,
|
|
47
|
+
max: 3_600_000,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
env: "LLM_SWITCH_MAX_CONCURRENCY",
|
|
51
|
+
key: "maxConcurrency",
|
|
52
|
+
min: 1,
|
|
53
|
+
max: 128,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
env: "LLM_SWITCH_RATE_LIMIT_PER_MINUTE",
|
|
57
|
+
key: "rateLimitPerMinute",
|
|
58
|
+
min: 1,
|
|
59
|
+
max: 10_000,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
export function parseBridgeRuntimeLimits(env = process.env) {
|
|
63
|
+
const parsed = { ...DEFAULT_BRIDGE_RUNTIME_LIMITS };
|
|
64
|
+
for (const spec of RUNTIME_LIMIT_SPECS) {
|
|
65
|
+
const raw = env[spec.env];
|
|
66
|
+
if (raw === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
if (!/^\d+$/.test(raw)) {
|
|
69
|
+
throw new Error(`${spec.env} 必须是 ${spec.min}..${spec.max} 范围内的整数`);
|
|
70
|
+
}
|
|
71
|
+
const value = Number(raw);
|
|
72
|
+
if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) {
|
|
73
|
+
throw new Error(`${spec.env} 必须是 ${spec.min}..${spec.max} 范围内的整数`);
|
|
74
|
+
}
|
|
75
|
+
parsed[spec.key] = value;
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
export function parseBridgePort(value) {
|
|
80
|
+
const raw = typeof value === "number" ? String(value) : value;
|
|
81
|
+
if (!/^\d+$/.test(raw)) {
|
|
82
|
+
throw new Error("Port 必须是 1..65535 范围内的整数");
|
|
83
|
+
}
|
|
84
|
+
const port = Number(raw);
|
|
85
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
86
|
+
throw new Error("Port 必须是 1..65535 范围内的整数");
|
|
87
|
+
}
|
|
88
|
+
return port;
|
|
89
|
+
}
|
|
90
|
+
export function normalizeHost(host) {
|
|
91
|
+
const trimmed = host.trim();
|
|
92
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
93
|
+
return trimmed.slice(1, -1);
|
|
94
|
+
}
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
function parseIpv4(value) {
|
|
98
|
+
const parts = value.split(".");
|
|
99
|
+
if (parts.length !== 4)
|
|
100
|
+
return null;
|
|
101
|
+
const bytes = [];
|
|
102
|
+
for (const part of parts) {
|
|
103
|
+
if (!/^\d{1,3}$/.test(part))
|
|
104
|
+
return null;
|
|
105
|
+
const byte = Number(part);
|
|
106
|
+
if (byte < 0 || byte > 255)
|
|
107
|
+
return null;
|
|
108
|
+
bytes.push(byte);
|
|
109
|
+
}
|
|
110
|
+
return bytes;
|
|
111
|
+
}
|
|
112
|
+
function parseIpv6Groups(value) {
|
|
113
|
+
let host = value.toLowerCase();
|
|
114
|
+
if (host.includes("%"))
|
|
115
|
+
return null;
|
|
116
|
+
const lastColon = host.lastIndexOf(":");
|
|
117
|
+
const tail = lastColon >= 0 ? host.slice(lastColon + 1) : host;
|
|
118
|
+
if (tail.includes(".")) {
|
|
119
|
+
const ipv4 = parseIpv4(tail);
|
|
120
|
+
if (!ipv4)
|
|
121
|
+
return null;
|
|
122
|
+
const high = ((ipv4[0] ?? 0) << 8) | (ipv4[1] ?? 0);
|
|
123
|
+
const low = ((ipv4[2] ?? 0) << 8) | (ipv4[3] ?? 0);
|
|
124
|
+
host = `${host.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
|
|
125
|
+
}
|
|
126
|
+
const halves = host.split("::");
|
|
127
|
+
if (halves.length > 2)
|
|
128
|
+
return null;
|
|
129
|
+
const parseHalf = (half) => {
|
|
130
|
+
if (!half)
|
|
131
|
+
return [];
|
|
132
|
+
const groups = [];
|
|
133
|
+
for (const part of half.split(":")) {
|
|
134
|
+
if (!/^[0-9a-f]{1,4}$/.test(part))
|
|
135
|
+
return null;
|
|
136
|
+
groups.push(Number.parseInt(part, 16));
|
|
137
|
+
}
|
|
138
|
+
return groups;
|
|
139
|
+
};
|
|
140
|
+
const left = parseHalf(halves[0] ?? "");
|
|
141
|
+
const right = parseHalf(halves[1] ?? "");
|
|
142
|
+
if (!left || !right)
|
|
143
|
+
return null;
|
|
144
|
+
if (halves.length === 1) {
|
|
145
|
+
return left.length === 8 ? left : null;
|
|
146
|
+
}
|
|
147
|
+
const missing = 8 - left.length - right.length;
|
|
148
|
+
if (missing < 1)
|
|
149
|
+
return null;
|
|
150
|
+
return [...left, ...Array.from({ length: missing }, () => 0), ...right];
|
|
151
|
+
}
|
|
152
|
+
export function isLoopbackHost(host) {
|
|
153
|
+
const normalized = normalizeHost(host).toLowerCase();
|
|
154
|
+
if (normalized === "localhost")
|
|
155
|
+
return true;
|
|
156
|
+
const ipv4 = parseIpv4(normalized);
|
|
157
|
+
if (ipv4)
|
|
158
|
+
return ipv4[0] === 127;
|
|
159
|
+
const groups = parseIpv6Groups(normalized);
|
|
160
|
+
if (!groups)
|
|
161
|
+
return false;
|
|
162
|
+
const isV6Loopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
|
|
163
|
+
if (isV6Loopback)
|
|
164
|
+
return true;
|
|
165
|
+
const isMappedIpv4 = groups.slice(0, 5).every((group) => group === 0) &&
|
|
166
|
+
groups[5] === 0xffff;
|
|
167
|
+
return isMappedIpv4 && ((groups[6] ?? 0) >> 8) === 127;
|
|
168
|
+
}
|
|
169
|
+
export function formatHostForUrl(host) {
|
|
170
|
+
const normalized = normalizeHost(host);
|
|
171
|
+
return isIP(normalized) === 6 || normalized.includes(":")
|
|
172
|
+
? `[${normalized}]`
|
|
173
|
+
: normalized;
|
|
174
|
+
}
|
|
175
|
+
export function assertBridgeListenerAllowed(host, allowRemote) {
|
|
176
|
+
if (!isLoopbackHost(host) && !allowRemote) {
|
|
177
|
+
throw new Error(`非回环 bridge 监听地址 ${host || "(empty)"} 必须显式传入 --allow-remote`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function advertiseHostForBind(host) {
|
|
181
|
+
const normalized = normalizeHost(host);
|
|
182
|
+
if (!normalized || normalized === "0.0.0.0")
|
|
183
|
+
return "127.0.0.1";
|
|
184
|
+
if (normalized === "::")
|
|
185
|
+
return "::1";
|
|
186
|
+
return normalized;
|
|
187
|
+
}
|
|
188
|
+
export function resolveBridgeListener(options) {
|
|
189
|
+
const bindHost = normalizeHost(options.host);
|
|
190
|
+
assertBridgeListenerAllowed(bindHost, options.allowRemote);
|
|
191
|
+
return {
|
|
192
|
+
bindHost,
|
|
193
|
+
advertiseHost: options.advertiseHost
|
|
194
|
+
? normalizeHost(options.advertiseHost)
|
|
195
|
+
: advertiseHostForBind(bindHost),
|
|
196
|
+
port: parseBridgePort(options.port),
|
|
197
|
+
allowRemote: options.allowRemote,
|
|
198
|
+
};
|
|
199
|
+
}
|
package/dist/bridge/server.js
CHANGED
|
@@ -1,17 +1,88 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
107
|
+
function upstreamHeaders(upstream) {
|
|
37
108
|
const headers = {
|
|
38
109
|
Accept: "application/json",
|
|
39
110
|
"Content-Type": "application/json",
|
|
40
111
|
};
|
|
41
|
-
|
|
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 [
|
|
57
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
143
|
+
async function fetchModelsJson(upstream) {
|
|
81
144
|
const url = joinUrl(upstream.baseUrl, "/models");
|
|
82
|
-
const response = await
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
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));
|