@nvae/llmswitch 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/LICENSE +21 -0
- package/README.md +303 -0
- package/dist/adapters/claude.js +117 -0
- package/dist/adapters/codex.js +229 -0
- package/dist/adapters/index.js +33 -0
- package/dist/adapters/merge.js +21 -0
- package/dist/adapters/opencode.js +162 -0
- package/dist/bridge/anthropic-translate-request.js +226 -0
- package/dist/bridge/anthropic-translate-response.js +265 -0
- package/dist/bridge/manager.js +240 -0
- package/dist/bridge/server.js +487 -0
- package/dist/bridge/state.js +125 -0
- package/dist/bridge/translate-request.js +385 -0
- package/dist/bridge/translate-response.js +509 -0
- package/dist/bridge/types.js +8 -0
- package/dist/cli.js +48 -0
- package/dist/commands/bridge-cmd.js +113 -0
- package/dist/commands/launch-cmd.js +83 -0
- package/dist/commands/launch.js +175 -0
- package/dist/commands/prompts.js +595 -0
- package/dist/commands/tool.js +380 -0
- package/dist/formats/compatibility.js +33 -0
- package/dist/index.js +3 -0
- package/dist/presets/index.js +40 -0
- package/dist/store/profiles.js +202 -0
- package/dist/types.js +17 -0
- package/dist/utils/base-url.js +40 -0
- package/dist/utils/fetch-models.js +177 -0
- package/dist/utils/fs.js +40 -0
- package/dist/utils/paths.js +67 -0
- package/dist/utils/proxy.js +68 -0
- package/package.json +49 -0
|
@@ -0,0 +1,487 @@
|
|
|
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";
|
|
5
|
+
import { anthropicToChatRequest } from "./anthropic-translate-request.js";
|
|
6
|
+
import { chatChunkToAnthropicEvents, chatCompletionToAnthropicMessage, createAnthropicStreamState, forceCompleteAnthropicStream, parseChatSseLine, } from "./anthropic-translate-response.js";
|
|
7
|
+
import { collectCustomToolNames, responsesToChatRequest, responsesToCompletionsRequest, } from "./translate-request.js";
|
|
8
|
+
import { chatChunkToResponsesEvents, chatCompletionToResponse, createStreamState, forceCompleteStream, parseChatSseLine as parseChatSseLineResponses, } from "./translate-response.js";
|
|
9
|
+
function readBody(req) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
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);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function sendJson(res, status, body) {
|
|
18
|
+
const raw = JSON.stringify(body);
|
|
19
|
+
res.writeHead(status, {
|
|
20
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
21
|
+
"Content-Length": Buffer.byteLength(raw),
|
|
22
|
+
});
|
|
23
|
+
res.end(raw);
|
|
24
|
+
}
|
|
25
|
+
function joinUrl(baseUrl, path) {
|
|
26
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
27
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
28
|
+
if (/\/v1$/i.test(base) && p.startsWith("/v1/")) {
|
|
29
|
+
return `${base}${p.slice(3)}`;
|
|
30
|
+
}
|
|
31
|
+
if (!/\/v1$/i.test(base) && !p.startsWith("/v1/")) {
|
|
32
|
+
return `${base}/v1${p.startsWith("/") ? p : `/${p}`}`;
|
|
33
|
+
}
|
|
34
|
+
return `${base}${p}`;
|
|
35
|
+
}
|
|
36
|
+
function upstreamHeaders(upstream, incoming) {
|
|
37
|
+
const headers = {
|
|
38
|
+
Accept: "application/json",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
};
|
|
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
|
+
}
|
|
55
|
+
if (upstream.headers) {
|
|
56
|
+
for (const [k, v] of Object.entries(upstream.headers)) {
|
|
57
|
+
headers[k] = v;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return headers;
|
|
61
|
+
}
|
|
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
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function fetchModelsJson(upstream, req) {
|
|
81
|
+
const url = joinUrl(upstream.baseUrl, "/models");
|
|
82
|
+
const response = await withProxyEnv(upstream, () => fetch(url, {
|
|
83
|
+
method: "GET",
|
|
84
|
+
headers: upstreamHeaders(upstream, req),
|
|
85
|
+
}));
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
return { ok: false, status: response.status, data: [] };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const json = (await response.json());
|
|
91
|
+
const data = Array.isArray(json.data) ? json.data : [];
|
|
92
|
+
return { ok: true, status: 200, data: data };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { ok: false, status: 502, data: [] };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function proxyModelsMerged(req, res, upstreams) {
|
|
99
|
+
const sides = [upstreams.codex, upstreams.claude].filter((u) => Boolean(u?.baseUrl));
|
|
100
|
+
if (!sides.length) {
|
|
101
|
+
sendJson(res, 503, {
|
|
102
|
+
error: { message: "Bridge 未配置上游" },
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const results = await Promise.all(sides.map((u) => fetchModelsJson(u, req).catch(() => ({
|
|
107
|
+
ok: false,
|
|
108
|
+
status: 502,
|
|
109
|
+
data: [],
|
|
110
|
+
}))));
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const merged = [];
|
|
113
|
+
for (const result of results) {
|
|
114
|
+
if (!result.ok)
|
|
115
|
+
continue;
|
|
116
|
+
for (const item of result.data) {
|
|
117
|
+
const id = item && typeof item === "object" && "id" in item
|
|
118
|
+
? String(item.id)
|
|
119
|
+
: "";
|
|
120
|
+
if (!id || seen.has(id))
|
|
121
|
+
continue;
|
|
122
|
+
seen.add(id);
|
|
123
|
+
merged.push(item);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!merged.length && results.every((r) => !r.ok)) {
|
|
127
|
+
sendJson(res, 502, {
|
|
128
|
+
error: { message: "无法从任一上游拉取模型列表" },
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
sendJson(res, 200, { object: "list", data: merged });
|
|
133
|
+
}
|
|
134
|
+
async function handleResponses(req, res, upstream, bodyBuf) {
|
|
135
|
+
let body;
|
|
136
|
+
try {
|
|
137
|
+
body = JSON.parse(bodyBuf.toString("utf8"));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
sendJson(res, 400, { error: { message: "Invalid JSON body" } });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const mode = upstream.mode || "chat";
|
|
144
|
+
const wantStream = Boolean(body.stream);
|
|
145
|
+
if (mode === "completions") {
|
|
146
|
+
await forwardCompletions(req, res, upstream, body, wantStream);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
await forwardChatResponses(req, res, upstream, body, wantStream);
|
|
150
|
+
}
|
|
151
|
+
async function handleMessages(req, res, upstream, bodyBuf) {
|
|
152
|
+
let body;
|
|
153
|
+
try {
|
|
154
|
+
body = JSON.parse(bodyBuf.toString("utf8"));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
sendJson(res, 400, {
|
|
158
|
+
type: "error",
|
|
159
|
+
error: { type: "invalid_request_error", message: "Invalid JSON body" },
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const wantStream = Boolean(body.stream);
|
|
164
|
+
const chatReq = anthropicToChatRequest(body);
|
|
165
|
+
const url = joinUrl(upstream.baseUrl, "/chat/completions");
|
|
166
|
+
let response;
|
|
167
|
+
try {
|
|
168
|
+
response = await withProxyEnv(upstream, () => fetch(url, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: upstreamHeaders(upstream, req),
|
|
171
|
+
body: JSON.stringify(chatReq),
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
sendJson(res, 502, {
|
|
176
|
+
type: "error",
|
|
177
|
+
error: {
|
|
178
|
+
type: "api_error",
|
|
179
|
+
message: `Upstream chat 请求失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const text = await response.text();
|
|
186
|
+
try {
|
|
187
|
+
const json = JSON.parse(text);
|
|
188
|
+
sendJson(res, response.status, json);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
sendJson(res, response.status, {
|
|
192
|
+
type: "error",
|
|
193
|
+
error: { type: "api_error", message: text.slice(0, 500) },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!wantStream) {
|
|
199
|
+
const json = (await response.json());
|
|
200
|
+
sendJson(res, 200, chatCompletionToAnthropicMessage(json, String(body.model || "")));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await pipeChatStreamToAnthropic(response, res, String(body.model || ""));
|
|
204
|
+
}
|
|
205
|
+
async function forwardChatResponses(req, res, upstream, body, wantStream) {
|
|
206
|
+
const chatReq = responsesToChatRequest(body);
|
|
207
|
+
const customTools = collectCustomToolNames(body.tools);
|
|
208
|
+
const url = joinUrl(upstream.baseUrl, "/chat/completions");
|
|
209
|
+
let response;
|
|
210
|
+
try {
|
|
211
|
+
response = await withProxyEnv(upstream, () => fetch(url, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: upstreamHeaders(upstream, req),
|
|
214
|
+
body: JSON.stringify(chatReq),
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
sendJson(res, 502, {
|
|
219
|
+
error: {
|
|
220
|
+
message: `Upstream chat 请求失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
if (response.status === 404 || response.status === 405) {
|
|
227
|
+
await forwardCompletions(req, res, upstream, body, wantStream);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const text = await response.text();
|
|
231
|
+
res.writeHead(response.status, {
|
|
232
|
+
"Content-Type": response.headers.get("content-type") || "application/json",
|
|
233
|
+
});
|
|
234
|
+
res.end(text);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!wantStream) {
|
|
238
|
+
const json = (await response.json());
|
|
239
|
+
sendJson(res, 200, chatCompletionToResponse(json, String(body.model || ""), customTools));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools);
|
|
243
|
+
}
|
|
244
|
+
async function forwardCompletions(req, res, upstream, body, wantStream) {
|
|
245
|
+
const completionReq = responsesToCompletionsRequest(body);
|
|
246
|
+
const customTools = collectCustomToolNames(body.tools);
|
|
247
|
+
const url = joinUrl(upstream.baseUrl, "/completions");
|
|
248
|
+
let response;
|
|
249
|
+
try {
|
|
250
|
+
response = await withProxyEnv(upstream, () => fetch(url, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: upstreamHeaders(upstream, req),
|
|
253
|
+
body: JSON.stringify(completionReq),
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
sendJson(res, 502, {
|
|
258
|
+
error: {
|
|
259
|
+
message: `Upstream completions 请求失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const text = await response.text();
|
|
266
|
+
res.writeHead(response.status, {
|
|
267
|
+
"Content-Type": response.headers.get("content-type") || "application/json",
|
|
268
|
+
});
|
|
269
|
+
res.end(text);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!wantStream) {
|
|
273
|
+
const json = (await response.json());
|
|
274
|
+
sendJson(res, 200, chatCompletionToResponse(json, String(body.model || ""), customTools));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools);
|
|
278
|
+
}
|
|
279
|
+
async function pipeChatStreamToResponses(upstream, res, model, customTools) {
|
|
280
|
+
res.writeHead(200, {
|
|
281
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
282
|
+
"Cache-Control": "no-cache, no-transform",
|
|
283
|
+
Connection: "keep-alive",
|
|
284
|
+
"X-Accel-Buffering": "no",
|
|
285
|
+
});
|
|
286
|
+
const state = createStreamState(model, undefined, customTools);
|
|
287
|
+
const reader = upstream.body?.getReader();
|
|
288
|
+
if (!reader) {
|
|
289
|
+
for (const frame of forceCompleteStream(state))
|
|
290
|
+
res.write(frame);
|
|
291
|
+
res.end();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const decoder = new TextDecoder();
|
|
295
|
+
let buffer = "";
|
|
296
|
+
try {
|
|
297
|
+
while (true) {
|
|
298
|
+
const { done, value } = await reader.read();
|
|
299
|
+
if (done)
|
|
300
|
+
break;
|
|
301
|
+
buffer += decoder.decode(value, { stream: true });
|
|
302
|
+
const lines = buffer.split(/\r?\n/);
|
|
303
|
+
buffer = lines.pop() || "";
|
|
304
|
+
for (const line of lines) {
|
|
305
|
+
const parsed = parseChatSseLineResponses(line);
|
|
306
|
+
if (parsed === "done") {
|
|
307
|
+
for (const frame of forceCompleteStream(state))
|
|
308
|
+
res.write(frame);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!parsed)
|
|
312
|
+
continue;
|
|
313
|
+
for (const frame of chatChunkToResponsesEvents(parsed, state)) {
|
|
314
|
+
res.write(frame);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (buffer.trim()) {
|
|
319
|
+
const parsed = parseChatSseLineResponses(buffer);
|
|
320
|
+
if (parsed && parsed !== "done") {
|
|
321
|
+
for (const frame of chatChunkToResponsesEvents(parsed, state)) {
|
|
322
|
+
res.write(frame);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const frame of forceCompleteStream(state))
|
|
327
|
+
res.write(frame);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
331
|
+
res.write(`event: error\ndata: ${JSON.stringify({ type: "error", message })}\n\n`);
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
res.end();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function pipeChatStreamToAnthropic(upstream, res, model) {
|
|
338
|
+
res.writeHead(200, {
|
|
339
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
340
|
+
"Cache-Control": "no-cache, no-transform",
|
|
341
|
+
Connection: "keep-alive",
|
|
342
|
+
"X-Accel-Buffering": "no",
|
|
343
|
+
});
|
|
344
|
+
const state = createAnthropicStreamState(model);
|
|
345
|
+
const reader = upstream.body?.getReader();
|
|
346
|
+
if (!reader) {
|
|
347
|
+
for (const frame of forceCompleteAnthropicStream(state))
|
|
348
|
+
res.write(frame);
|
|
349
|
+
res.end();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const decoder = new TextDecoder();
|
|
353
|
+
let buffer = "";
|
|
354
|
+
try {
|
|
355
|
+
while (true) {
|
|
356
|
+
const { done, value } = await reader.read();
|
|
357
|
+
if (done)
|
|
358
|
+
break;
|
|
359
|
+
buffer += decoder.decode(value, { stream: true });
|
|
360
|
+
const lines = buffer.split(/\r?\n/);
|
|
361
|
+
buffer = lines.pop() || "";
|
|
362
|
+
for (const line of lines) {
|
|
363
|
+
const parsed = parseChatSseLine(line);
|
|
364
|
+
if (parsed === "done") {
|
|
365
|
+
for (const frame of forceCompleteAnthropicStream(state)) {
|
|
366
|
+
res.write(frame);
|
|
367
|
+
}
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!parsed)
|
|
371
|
+
continue;
|
|
372
|
+
for (const frame of chatChunkToAnthropicEvents(parsed, state)) {
|
|
373
|
+
res.write(frame);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (buffer.trim()) {
|
|
378
|
+
const parsed = parseChatSseLine(buffer);
|
|
379
|
+
if (parsed && parsed !== "done") {
|
|
380
|
+
for (const frame of chatChunkToAnthropicEvents(parsed, state)) {
|
|
381
|
+
res.write(frame);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const frame of forceCompleteAnthropicStream(state))
|
|
386
|
+
res.write(frame);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
390
|
+
res.write(`event: error\ndata: ${JSON.stringify({
|
|
391
|
+
type: "error",
|
|
392
|
+
error: { type: "api_error", message },
|
|
393
|
+
})}\n\n`);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
res.end();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
export function createBridgeServer() {
|
|
400
|
+
return createServer(async (req, res) => {
|
|
401
|
+
try {
|
|
402
|
+
const upstreams = readBridgeUpstreams();
|
|
403
|
+
const state = readBridgeState();
|
|
404
|
+
const merged = {
|
|
405
|
+
codex: upstreams.codex || state.upstreams.codex,
|
|
406
|
+
claude: upstreams.claude || state.upstreams.claude,
|
|
407
|
+
};
|
|
408
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
409
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
410
|
+
if (req.method === "GET" && (path === "/health" || path === "/v1/health")) {
|
|
411
|
+
sendJson(res, 200, {
|
|
412
|
+
ok: true,
|
|
413
|
+
upstreams: {
|
|
414
|
+
codex: merged.codex
|
|
415
|
+
? {
|
|
416
|
+
baseUrl: merged.codex.baseUrl,
|
|
417
|
+
mode: merged.codex.mode,
|
|
418
|
+
profile: merged.codex.profileName || null,
|
|
419
|
+
}
|
|
420
|
+
: null,
|
|
421
|
+
claude: merged.claude
|
|
422
|
+
? {
|
|
423
|
+
baseUrl: merged.claude.baseUrl,
|
|
424
|
+
mode: merged.claude.mode,
|
|
425
|
+
profile: merged.claude.profileName || null,
|
|
426
|
+
}
|
|
427
|
+
: null,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (req.method === "GET" && (path === "/v1/models" || path === "/models")) {
|
|
433
|
+
await proxyModelsMerged(req, res, merged);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (req.method === "POST" &&
|
|
437
|
+
(path === "/v1/responses" || path === "/responses")) {
|
|
438
|
+
if (!merged.codex?.baseUrl) {
|
|
439
|
+
sendJson(res, 503, {
|
|
440
|
+
error: {
|
|
441
|
+
message: "Bridge 未配置 Codex 上游。请先 llms codex use <openai-chat profile>",
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const body = await readBody(req);
|
|
447
|
+
await handleResponses(req, res, merged.codex, body);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (req.method === "POST" &&
|
|
451
|
+
(path === "/v1/messages" || path === "/messages")) {
|
|
452
|
+
if (!merged.claude?.baseUrl) {
|
|
453
|
+
sendJson(res, 503, {
|
|
454
|
+
type: "error",
|
|
455
|
+
error: {
|
|
456
|
+
type: "api_error",
|
|
457
|
+
message: "Bridge 未配置 Claude 上游。请先 llms claude use <openai-chat profile>",
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const body = await readBody(req);
|
|
463
|
+
await handleMessages(req, res, merged.claude, body);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
sendJson(res, 404, {
|
|
467
|
+
error: {
|
|
468
|
+
message: `Bridge 支持 GET /v1/models、POST /v1/responses、POST /v1/messages(当前: ${req.method} ${path})`,
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
sendJson(res, 500, {
|
|
474
|
+
error: {
|
|
475
|
+
message: err instanceof Error ? err.message : String(err),
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
export function listenBridge(port, host) {
|
|
482
|
+
const server = createBridgeServer();
|
|
483
|
+
return new Promise((resolve, reject) => {
|
|
484
|
+
server.once("error", reject);
|
|
485
|
+
server.listen(port, host, () => resolve(server));
|
|
486
|
+
});
|
|
487
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteFile, ensureDir } from "../utils/fs.js";
|
|
4
|
+
import { getAppConfigRoot } from "../utils/paths.js";
|
|
5
|
+
import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT, emptyUpstreams, } from "./types.js";
|
|
6
|
+
export function getBridgeDir() {
|
|
7
|
+
return join(getAppConfigRoot(), "bridge");
|
|
8
|
+
}
|
|
9
|
+
export function getBridgeStatePath() {
|
|
10
|
+
return join(getBridgeDir(), "state.json");
|
|
11
|
+
}
|
|
12
|
+
export function getBridgeUpstreamPath() {
|
|
13
|
+
return join(getBridgeDir(), "upstream.json");
|
|
14
|
+
}
|
|
15
|
+
export function getBridgePidPath() {
|
|
16
|
+
return join(getBridgeDir(), "bridge.pid");
|
|
17
|
+
}
|
|
18
|
+
function isLegacyUpstream(raw) {
|
|
19
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
20
|
+
return false;
|
|
21
|
+
const row = raw;
|
|
22
|
+
return typeof row.baseUrl === "string" && !("codex" in row) && !("claude" in row);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Normalize disk/runtime upstream payloads (legacy single object → per-tool map).
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeBridgeUpstreams(raw) {
|
|
28
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
29
|
+
return emptyUpstreams();
|
|
30
|
+
}
|
|
31
|
+
const row = raw;
|
|
32
|
+
if (isLegacyUpstream(raw)) {
|
|
33
|
+
return { codex: raw, claude: null };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
codex: row.codex ?? null,
|
|
37
|
+
claude: row.claude ?? null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function readBridgeUpstreams() {
|
|
41
|
+
const path = getBridgeUpstreamPath();
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return emptyUpstreams();
|
|
44
|
+
try {
|
|
45
|
+
return normalizeBridgeUpstreams(JSON.parse(readFileSync(path, "utf8")));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return emptyUpstreams();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function writeBridgeUpstreams(upstreams) {
|
|
52
|
+
ensureDir(getBridgeDir());
|
|
53
|
+
atomicWriteFile(getBridgeUpstreamPath(), JSON.stringify({
|
|
54
|
+
codex: upstreams.codex,
|
|
55
|
+
claude: upstreams.claude,
|
|
56
|
+
}, null, 2) + "\n");
|
|
57
|
+
}
|
|
58
|
+
export function readBridgeUpstream(tool = "codex") {
|
|
59
|
+
return readBridgeUpstreams()[tool];
|
|
60
|
+
}
|
|
61
|
+
export function writeBridgeUpstream(tool, upstream) {
|
|
62
|
+
const current = readBridgeUpstreams();
|
|
63
|
+
current[tool] = upstream;
|
|
64
|
+
writeBridgeUpstreams(current);
|
|
65
|
+
const state = readBridgeStateRaw();
|
|
66
|
+
writeBridgeState({
|
|
67
|
+
...state,
|
|
68
|
+
upstreams: current,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function readBridgeStateRaw() {
|
|
72
|
+
const path = getBridgeStatePath();
|
|
73
|
+
const defaults = {
|
|
74
|
+
port: Number(process.env.LLM_SWITCH_BRIDGE_PORT) || DEFAULT_BRIDGE_PORT,
|
|
75
|
+
host: process.env.LLM_SWITCH_BRIDGE_HOST || DEFAULT_BRIDGE_HOST,
|
|
76
|
+
pid: null,
|
|
77
|
+
upstreams: readBridgeUpstreams(),
|
|
78
|
+
};
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
return defaults;
|
|
81
|
+
try {
|
|
82
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
const upstreams = raw.upstreams
|
|
84
|
+
? normalizeBridgeUpstreams(raw.upstreams)
|
|
85
|
+
: raw.upstream
|
|
86
|
+
? normalizeBridgeUpstreams(raw.upstream)
|
|
87
|
+
: readBridgeUpstreams();
|
|
88
|
+
return {
|
|
89
|
+
port: raw.port || defaults.port,
|
|
90
|
+
host: raw.host || defaults.host,
|
|
91
|
+
pid: raw.pid ?? null,
|
|
92
|
+
upstreams,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return defaults;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function readBridgeState() {
|
|
100
|
+
return readBridgeStateRaw();
|
|
101
|
+
}
|
|
102
|
+
export function writeBridgeState(state) {
|
|
103
|
+
ensureDir(getBridgeDir());
|
|
104
|
+
const upstreams = normalizeBridgeUpstreams(state.upstreams);
|
|
105
|
+
atomicWriteFile(getBridgeStatePath(), JSON.stringify({
|
|
106
|
+
port: state.port,
|
|
107
|
+
host: state.host,
|
|
108
|
+
pid: state.pid,
|
|
109
|
+
upstreams,
|
|
110
|
+
}, null, 2) + "\n");
|
|
111
|
+
writeBridgeUpstreams(upstreams);
|
|
112
|
+
if (state.pid != null) {
|
|
113
|
+
atomicWriteFile(getBridgePidPath(), String(state.pid) + "\n");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Codex-facing base URL (includes /v1). */
|
|
117
|
+
export function bridgeBaseUrl(state) {
|
|
118
|
+
const s = state || readBridgeState();
|
|
119
|
+
return `http://${s.host}:${s.port}/v1`;
|
|
120
|
+
}
|
|
121
|
+
/** Claude-facing root URL (no /v1; client appends /v1/messages). */
|
|
122
|
+
export function bridgeRootUrl(state) {
|
|
123
|
+
const s = state || readBridgeState();
|
|
124
|
+
return `http://${s.host}:${s.port}`;
|
|
125
|
+
}
|