@nvae/llmswitch 0.6.0 → 0.8.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 +220 -0
- package/dist/adapters/opencode.js +28 -12
- package/dist/bridge/anthropic-to-chat-response.js +332 -0
- package/dist/bridge/chat-to-anthropic-request.js +270 -0
- package/dist/bridge/chat-to-responses-request.js +216 -0
- package/dist/bridge/manager.js +45 -13
- package/dist/bridge/responses-to-chat-response.js +393 -0
- package/dist/bridge/server.js +109 -4
- package/dist/bridge/state.js +8 -2
- package/dist/bridge/types.js +4 -2
- package/dist/cli.js +2 -0
- package/dist/commands/bridge-cmd.js +3 -2
- package/dist/commands/gateway-cmd.js +1040 -0
- package/dist/gateway/health.js +45 -0
- package/dist/gateway/keys.js +433 -0
- package/dist/gateway/manager.js +278 -0
- package/dist/gateway/pipeline.js +328 -0
- package/dist/gateway/rate-limit.js +285 -0
- package/dist/gateway/router.js +163 -0
- package/dist/gateway/runtime.js +45 -0
- package/dist/gateway/server.js +1053 -0
- package/dist/gateway/state.js +135 -0
- package/dist/gateway/store.js +392 -0
- package/dist/gateway/tokens.js +423 -0
- package/dist/gateway/types.js +30 -0
- package/dist/gateway/usage.js +152 -0
- package/dist/utils/paths.js +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate OpenAI Responses API results (stream/non-stream) → Chat Completions.
|
|
3
|
+
*
|
|
4
|
+
* Reverse direction of `translate-response.ts`. Used by the gateway when a
|
|
5
|
+
* Responses upstream must be presented in Chat Completions shape (the
|
|
6
|
+
* gateway's hub format).
|
|
7
|
+
*/
|
|
8
|
+
function newId(prefix) {
|
|
9
|
+
return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
10
|
+
}
|
|
11
|
+
function asRecord(value) {
|
|
12
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function numberOr(value, fallback = 0) {
|
|
18
|
+
return typeof value === "number" ? value : fallback;
|
|
19
|
+
}
|
|
20
|
+
function mapUsage(usage) {
|
|
21
|
+
if (!usage)
|
|
22
|
+
return undefined;
|
|
23
|
+
const prompt = numberOr(usage.input_tokens, numberOr(usage.prompt_tokens));
|
|
24
|
+
const completion = numberOr(usage.output_tokens, numberOr(usage.completion_tokens));
|
|
25
|
+
const out = {
|
|
26
|
+
prompt_tokens: prompt,
|
|
27
|
+
completion_tokens: completion,
|
|
28
|
+
total_tokens: numberOr(usage.total_tokens, prompt + completion),
|
|
29
|
+
};
|
|
30
|
+
const details = asRecord(usage.output_tokens_details);
|
|
31
|
+
if (details && typeof details.reasoning_tokens === "number") {
|
|
32
|
+
out.completion_tokens_details = {
|
|
33
|
+
reasoning_tokens: details.reasoning_tokens,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const inputDetails = asRecord(usage.input_tokens_details);
|
|
37
|
+
if (inputDetails && typeof inputDetails.cached_tokens === "number") {
|
|
38
|
+
out.prompt_tokens_details = { cached_tokens: inputDetails.cached_tokens };
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** Responses status → Chat Completions finish_reason. */
|
|
43
|
+
export function responsesStatusToFinishReason(status, incompleteDetails) {
|
|
44
|
+
if (status === "incomplete") {
|
|
45
|
+
return incompleteDetails?.reason === "content_filter"
|
|
46
|
+
? "content_filter"
|
|
47
|
+
: "length";
|
|
48
|
+
}
|
|
49
|
+
return "stop";
|
|
50
|
+
}
|
|
51
|
+
function extractOutputText(content) {
|
|
52
|
+
if (typeof content === "string")
|
|
53
|
+
return content;
|
|
54
|
+
if (!Array.isArray(content))
|
|
55
|
+
return "";
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const raw of content) {
|
|
58
|
+
const part = asRecord(raw);
|
|
59
|
+
if (!part)
|
|
60
|
+
continue;
|
|
61
|
+
if (typeof part.text === "string")
|
|
62
|
+
parts.push(part.text);
|
|
63
|
+
}
|
|
64
|
+
return parts.join("");
|
|
65
|
+
}
|
|
66
|
+
function extractReasoning(item) {
|
|
67
|
+
const summary = Array.isArray(item.summary) ? item.summary : [];
|
|
68
|
+
const parts = [];
|
|
69
|
+
for (const raw of summary) {
|
|
70
|
+
const entry = asRecord(raw);
|
|
71
|
+
if (entry && typeof entry.text === "string")
|
|
72
|
+
parts.push(entry.text);
|
|
73
|
+
else if (typeof raw === "string")
|
|
74
|
+
parts.push(raw);
|
|
75
|
+
}
|
|
76
|
+
if (!parts.length && typeof item.text === "string")
|
|
77
|
+
parts.push(item.text);
|
|
78
|
+
return parts.join("\n");
|
|
79
|
+
}
|
|
80
|
+
/** Custom (freeform) tool input → Chat function arguments JSON string. */
|
|
81
|
+
function customInputToArguments(input) {
|
|
82
|
+
const raw = typeof input === "string" ? input : "";
|
|
83
|
+
try {
|
|
84
|
+
return JSON.stringify({ input: raw });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return "{}";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Non-streaming Responses object → Chat Completions object. */
|
|
91
|
+
export function responseToChatCompletion(response, fallbackModel = "") {
|
|
92
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
93
|
+
const textParts = [];
|
|
94
|
+
const reasoningParts = [];
|
|
95
|
+
const toolCalls = [];
|
|
96
|
+
for (const raw of output) {
|
|
97
|
+
const item = asRecord(raw);
|
|
98
|
+
if (!item)
|
|
99
|
+
continue;
|
|
100
|
+
const type = String(item.type || "");
|
|
101
|
+
if (type === "message") {
|
|
102
|
+
const text = extractOutputText(item.content);
|
|
103
|
+
if (text)
|
|
104
|
+
textParts.push(text);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (type === "reasoning") {
|
|
108
|
+
const text = extractReasoning(item);
|
|
109
|
+
if (text)
|
|
110
|
+
reasoningParts.push(text);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (type === "function_call") {
|
|
114
|
+
toolCalls.push({
|
|
115
|
+
index: toolCalls.length,
|
|
116
|
+
id: String(item.call_id || item.id || newId("call")),
|
|
117
|
+
type: "function",
|
|
118
|
+
function: {
|
|
119
|
+
name: String(item.name || "tool"),
|
|
120
|
+
arguments: typeof item.arguments === "string"
|
|
121
|
+
? item.arguments
|
|
122
|
+
: JSON.stringify(item.arguments ?? {}),
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (type === "custom_tool_call") {
|
|
128
|
+
toolCalls.push({
|
|
129
|
+
index: toolCalls.length,
|
|
130
|
+
id: String(item.call_id || item.id || newId("call")),
|
|
131
|
+
type: "function",
|
|
132
|
+
function: {
|
|
133
|
+
name: String(item.name || "tool"),
|
|
134
|
+
arguments: customInputToArguments(item.input),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const message = {
|
|
140
|
+
role: "assistant",
|
|
141
|
+
content: textParts.length ? textParts.join("") : null,
|
|
142
|
+
};
|
|
143
|
+
if (reasoningParts.length) {
|
|
144
|
+
message.reasoning_content = reasoningParts.join("\n");
|
|
145
|
+
}
|
|
146
|
+
if (toolCalls.length)
|
|
147
|
+
message.tool_calls = toolCalls;
|
|
148
|
+
const finishReason = toolCalls.length
|
|
149
|
+
? "tool_calls"
|
|
150
|
+
: responsesStatusToFinishReason(response.status, asRecord(response.incomplete_details));
|
|
151
|
+
const out = {
|
|
152
|
+
id: typeof response.id === "string" && response.id
|
|
153
|
+
? response.id.replace(/^resp_/, "chatcmpl-")
|
|
154
|
+
: newId("chatcmpl"),
|
|
155
|
+
object: "chat.completion",
|
|
156
|
+
created: numberOr(response.created_at, Math.floor(Date.now() / 1000)),
|
|
157
|
+
model: String(response.model || fallbackModel || ""),
|
|
158
|
+
choices: [
|
|
159
|
+
{ index: 0, message, finish_reason: finishReason, logprobs: null },
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
const usage = mapUsage(asRecord(response.usage));
|
|
163
|
+
if (usage)
|
|
164
|
+
out.usage = usage;
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
export function createResponsesToChatStreamState(model, options = {}) {
|
|
168
|
+
return {
|
|
169
|
+
id: newId("chatcmpl"),
|
|
170
|
+
model,
|
|
171
|
+
created: Math.floor(Date.now() / 1000),
|
|
172
|
+
items: new Map(),
|
|
173
|
+
nextToolIndex: 0,
|
|
174
|
+
roleEmitted: false,
|
|
175
|
+
finishReason: null,
|
|
176
|
+
usage: undefined,
|
|
177
|
+
completed: false,
|
|
178
|
+
includeUsage: options.includeUsage !== false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function chunk(state, delta, finishReason = null) {
|
|
182
|
+
return {
|
|
183
|
+
id: state.id,
|
|
184
|
+
object: "chat.completion.chunk",
|
|
185
|
+
created: state.created,
|
|
186
|
+
model: state.model,
|
|
187
|
+
choices: [{ index: 0, delta, finish_reason: finishReason, logprobs: null }],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function ensureRole(state, out) {
|
|
191
|
+
if (state.roleEmitted)
|
|
192
|
+
return;
|
|
193
|
+
state.roleEmitted = true;
|
|
194
|
+
out.push(chunk(state, { role: "assistant", content: "" }));
|
|
195
|
+
}
|
|
196
|
+
/** Parse one Responses SSE line; the JSON payload carries the event `type`. */
|
|
197
|
+
export function parseResponsesSseLine(line) {
|
|
198
|
+
const trimmed = line.trim();
|
|
199
|
+
if (!trimmed.startsWith("data:"))
|
|
200
|
+
return null;
|
|
201
|
+
const data = trimmed.slice(5).trim();
|
|
202
|
+
if (!data)
|
|
203
|
+
return null;
|
|
204
|
+
if (data === "[DONE]")
|
|
205
|
+
return "done";
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(data);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export class ResponsesStreamError extends Error {
|
|
214
|
+
errorType;
|
|
215
|
+
constructor(message, errorType = "api_error") {
|
|
216
|
+
super(message);
|
|
217
|
+
this.errorType = errorType;
|
|
218
|
+
this.name = "ResponsesStreamError";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function toolEntryFor(state, itemId) {
|
|
222
|
+
if (!itemId)
|
|
223
|
+
return undefined;
|
|
224
|
+
return state.items.get(itemId);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Convert one Responses SSE event into zero or more Chat Completions chunks.
|
|
228
|
+
* Throws `ResponsesStreamError` on upstream error/failed events.
|
|
229
|
+
*/
|
|
230
|
+
export function responsesEventToChatChunks(event, state) {
|
|
231
|
+
const out = [];
|
|
232
|
+
const type = String(event.type || "");
|
|
233
|
+
if (type === "response.created" || type === "response.in_progress") {
|
|
234
|
+
const response = asRecord(event.response);
|
|
235
|
+
if (response) {
|
|
236
|
+
if (typeof response.model === "string" && response.model) {
|
|
237
|
+
state.model = response.model;
|
|
238
|
+
}
|
|
239
|
+
if (typeof response.id === "string" && response.id) {
|
|
240
|
+
state.id = response.id.replace(/^resp_/, "chatcmpl-");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
ensureRole(state, out);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
if (type === "response.output_item.added") {
|
|
247
|
+
ensureRole(state, out);
|
|
248
|
+
const item = asRecord(event.item);
|
|
249
|
+
if (!item)
|
|
250
|
+
return out;
|
|
251
|
+
const itemType = String(item.type || "");
|
|
252
|
+
if (itemType !== "function_call" && itemType !== "custom_tool_call") {
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
const itemId = String(item.id || "");
|
|
256
|
+
const entry = {
|
|
257
|
+
toolIndex: state.nextToolIndex++,
|
|
258
|
+
callId: String(item.call_id || item.id || newId("call")),
|
|
259
|
+
name: String(item.name || "tool"),
|
|
260
|
+
custom: itemType === "custom_tool_call",
|
|
261
|
+
customInput: "",
|
|
262
|
+
emittedStart: true,
|
|
263
|
+
};
|
|
264
|
+
if (itemId)
|
|
265
|
+
state.items.set(itemId, entry);
|
|
266
|
+
out.push(chunk(state, {
|
|
267
|
+
tool_calls: [
|
|
268
|
+
{
|
|
269
|
+
index: entry.toolIndex,
|
|
270
|
+
id: entry.callId,
|
|
271
|
+
type: "function",
|
|
272
|
+
function: { name: entry.name, arguments: "" },
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
}));
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
if (type === "response.output_text.delta" ||
|
|
279
|
+
type === "response.refusal.delta") {
|
|
280
|
+
ensureRole(state, out);
|
|
281
|
+
const delta = event.delta;
|
|
282
|
+
if (typeof delta === "string" && delta) {
|
|
283
|
+
out.push(chunk(state, { content: delta }));
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
if (type === "response.reasoning_summary_text.delta" ||
|
|
288
|
+
type === "response.reasoning_text.delta") {
|
|
289
|
+
ensureRole(state, out);
|
|
290
|
+
const delta = event.delta;
|
|
291
|
+
if (typeof delta === "string" && delta) {
|
|
292
|
+
out.push(chunk(state, { reasoning_content: delta }));
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
if (type === "response.function_call_arguments.delta") {
|
|
297
|
+
const entry = toolEntryFor(state, String(event.item_id || ""));
|
|
298
|
+
const delta = event.delta;
|
|
299
|
+
if (entry && typeof delta === "string" && delta) {
|
|
300
|
+
out.push(chunk(state, {
|
|
301
|
+
tool_calls: [
|
|
302
|
+
{ index: entry.toolIndex, function: { arguments: delta } },
|
|
303
|
+
],
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
if (type === "response.custom_tool_call_input.delta") {
|
|
309
|
+
const entry = toolEntryFor(state, String(event.item_id || ""));
|
|
310
|
+
const delta = event.delta;
|
|
311
|
+
// Freeform input is not valid JSON; buffer and emit once complete.
|
|
312
|
+
if (entry && typeof delta === "string")
|
|
313
|
+
entry.customInput += delta;
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
if (type === "response.custom_tool_call_input.done") {
|
|
317
|
+
const entry = toolEntryFor(state, String(event.item_id || ""));
|
|
318
|
+
if (entry) {
|
|
319
|
+
const input = typeof event.input === "string" ? event.input : entry.customInput;
|
|
320
|
+
out.push(chunk(state, {
|
|
321
|
+
tool_calls: [
|
|
322
|
+
{
|
|
323
|
+
index: entry.toolIndex,
|
|
324
|
+
function: { arguments: customInputToArguments(input) },
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
}));
|
|
328
|
+
entry.customInput = "";
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
if (type === "response.output_item.done") {
|
|
333
|
+
const item = asRecord(event.item);
|
|
334
|
+
if (!item)
|
|
335
|
+
return out;
|
|
336
|
+
const itemId = String(item.id || "");
|
|
337
|
+
const entry = toolEntryFor(state, itemId);
|
|
338
|
+
// Non-streamed argument payloads only appear on the done event.
|
|
339
|
+
if (entry && entry.custom && entry.customInput) {
|
|
340
|
+
out.push(chunk(state, {
|
|
341
|
+
tool_calls: [
|
|
342
|
+
{
|
|
343
|
+
index: entry.toolIndex,
|
|
344
|
+
function: {
|
|
345
|
+
arguments: customInputToArguments(entry.customInput),
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
}));
|
|
350
|
+
entry.customInput = "";
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
if (type === "response.completed" || type === "response.incomplete") {
|
|
355
|
+
const response = asRecord(event.response);
|
|
356
|
+
if (response) {
|
|
357
|
+
state.usage = mapUsage(asRecord(response.usage));
|
|
358
|
+
state.finishReason = state.items.size
|
|
359
|
+
? "tool_calls"
|
|
360
|
+
: responsesStatusToFinishReason(response.status ?? (type === "response.incomplete" ? "incomplete" : "completed"), asRecord(response.incomplete_details));
|
|
361
|
+
}
|
|
362
|
+
return finishResponsesToChatStream(state);
|
|
363
|
+
}
|
|
364
|
+
if (type === "response.failed" || type === "error") {
|
|
365
|
+
const response = asRecord(event.response);
|
|
366
|
+
const error = asRecord(event.error) || asRecord(response?.error);
|
|
367
|
+
throw new ResponsesStreamError(String(error?.message || "上游 Responses 流返回错误"), String(error?.type || error?.code || "api_error"));
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function finishResponsesToChatStream(state) {
|
|
372
|
+
if (state.completed)
|
|
373
|
+
return [];
|
|
374
|
+
state.completed = true;
|
|
375
|
+
const out = [];
|
|
376
|
+
const finishReason = state.finishReason || (state.items.size ? "tool_calls" : "stop");
|
|
377
|
+
out.push(chunk(state, {}, finishReason));
|
|
378
|
+
if (state.includeUsage && state.usage) {
|
|
379
|
+
out.push({
|
|
380
|
+
id: state.id,
|
|
381
|
+
object: "chat.completion.chunk",
|
|
382
|
+
created: state.created,
|
|
383
|
+
model: state.model,
|
|
384
|
+
choices: [],
|
|
385
|
+
usage: state.usage,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
/** Emit terminal chunks when the upstream stream ends without a completion event. */
|
|
391
|
+
export function forceCompleteResponsesToChatStream(state) {
|
|
392
|
+
return finishResponsesToChatStream(state);
|
|
393
|
+
}
|
package/dist/bridge/server.js
CHANGED
|
@@ -29,7 +29,7 @@ function authenticateDataRequest(req, upstream, tool) {
|
|
|
29
29
|
if (!upstream?.clientToken || upstream.migrationRequired)
|
|
30
30
|
return false;
|
|
31
31
|
const bearer = bearerToken(req);
|
|
32
|
-
if (tool === "codex") {
|
|
32
|
+
if (tool === "codex" || tool === "opencode") {
|
|
33
33
|
return constantTimeTokenEqual(upstream.clientToken, bearer);
|
|
34
34
|
}
|
|
35
35
|
const apiKey = headerValue(req.headers["x-api-key"]);
|
|
@@ -40,7 +40,8 @@ function authenticateDataRequest(req, upstream, tool) {
|
|
|
40
40
|
}
|
|
41
41
|
function authenticateModelsRequest(req, upstreams) {
|
|
42
42
|
return (authenticateDataRequest(req, upstreams.codex, "codex") ||
|
|
43
|
-
authenticateDataRequest(req, upstreams.claude, "claude")
|
|
43
|
+
authenticateDataRequest(req, upstreams.claude, "claude") ||
|
|
44
|
+
authenticateDataRequest(req, upstreams.opencode, "opencode"));
|
|
44
45
|
}
|
|
45
46
|
function readBody(req, maxBytes = parseBridgeRuntimeLimits().maxBodyBytes) {
|
|
46
47
|
return new Promise((resolve, reject) => {
|
|
@@ -156,7 +157,7 @@ async function fetchModelsJson(upstream) {
|
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
async function proxyModelsMerged(_req, res, upstreams) {
|
|
159
|
-
const sides = [upstreams.codex, upstreams.claude].filter((u) => Boolean(u?.baseUrl));
|
|
160
|
+
const sides = [upstreams.codex, upstreams.claude, upstreams.opencode].filter((u) => Boolean(u?.baseUrl));
|
|
160
161
|
if (!sides.length) {
|
|
161
162
|
sendJson(res, 503, {
|
|
162
163
|
error: { message: "Bridge 未配置上游" },
|
|
@@ -293,6 +294,52 @@ async function forwardChatResponses(req, res, upstream, body, wantStream) {
|
|
|
293
294
|
}
|
|
294
295
|
await pipeChatStreamToResponses(response, res, String(body.model || ""), customTools, true);
|
|
295
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* OpenCode-facing passthrough: forward an OpenAI chat request verbatim to the
|
|
299
|
+
* upstream `/chat/completions` (with llm-switch transport applying the proxy)
|
|
300
|
+
* and relay the raw response, preserving streaming for SSE.
|
|
301
|
+
*/
|
|
302
|
+
async function forwardOpenCodeChat(_req, res, upstream, bodyBuf) {
|
|
303
|
+
let body;
|
|
304
|
+
try {
|
|
305
|
+
body = JSON.parse(bodyBuf.toString("utf8"));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
sendJson(res, 400, { error: { message: "Invalid JSON body" } });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const wantStream = Boolean(body.stream);
|
|
312
|
+
const url = joinUrl(upstream.baseUrl, "/chat/completions");
|
|
313
|
+
let response;
|
|
314
|
+
try {
|
|
315
|
+
response = await requestUpstream(upstream, url, "POST", bodyBuf.toString("utf8"));
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
sendJson(res, 502, {
|
|
319
|
+
error: {
|
|
320
|
+
message: `Upstream chat 请求失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
const text = await response.text();
|
|
327
|
+
res.writeHead(response.status, {
|
|
328
|
+
"Content-Type": response.headers.get("content-type") || "application/json",
|
|
329
|
+
});
|
|
330
|
+
res.end(text);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!wantStream) {
|
|
334
|
+
const text = await response.text();
|
|
335
|
+
res.writeHead(response.status, {
|
|
336
|
+
"Content-Type": response.headers.get("content-type") || "application/json",
|
|
337
|
+
});
|
|
338
|
+
res.end(text);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await pipeRawStream(response, res);
|
|
342
|
+
}
|
|
296
343
|
async function forwardCompletions(_req, res, upstream, body, wantStream) {
|
|
297
344
|
const completionReq = responsesToCompletionsRequest(body);
|
|
298
345
|
const customTools = collectCustomToolNames(body.tools);
|
|
@@ -444,6 +491,34 @@ async function pipeChatStreamToAnthropic(upstream, res, model) {
|
|
|
444
491
|
res.end();
|
|
445
492
|
}
|
|
446
493
|
}
|
|
494
|
+
/** Relay an upstream SSE stream verbatim (OpenCode chat passthrough). */
|
|
495
|
+
async function pipeRawStream(upstream, res) {
|
|
496
|
+
res.writeHead(200, {
|
|
497
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
498
|
+
"Cache-Control": "no-cache, no-transform",
|
|
499
|
+
Connection: "keep-alive",
|
|
500
|
+
"X-Accel-Buffering": "no",
|
|
501
|
+
});
|
|
502
|
+
const reader = upstream.body?.getReader();
|
|
503
|
+
if (!reader) {
|
|
504
|
+
res.end();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
while (true) {
|
|
509
|
+
const { done, value } = await reader.read();
|
|
510
|
+
if (done)
|
|
511
|
+
break;
|
|
512
|
+
res.write(value);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Connection dropped; best-effort close.
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
res.end();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
447
522
|
export function createBridgeServer(options = {}) {
|
|
448
523
|
return createServer(async (req, res) => {
|
|
449
524
|
try {
|
|
@@ -487,6 +562,13 @@ export function createBridgeServer(options = {}) {
|
|
|
487
562
|
migrationRequired: merged.claude.migrationRequired === true,
|
|
488
563
|
}
|
|
489
564
|
: null,
|
|
565
|
+
opencode: merged.opencode
|
|
566
|
+
? {
|
|
567
|
+
mode: merged.opencode.mode,
|
|
568
|
+
profile: merged.opencode.profileName || null,
|
|
569
|
+
migrationRequired: merged.opencode.migrationRequired === true,
|
|
570
|
+
}
|
|
571
|
+
: null,
|
|
490
572
|
},
|
|
491
573
|
});
|
|
492
574
|
return;
|
|
@@ -584,9 +666,32 @@ export function createBridgeServer(options = {}) {
|
|
|
584
666
|
await handleMessages(req, res, merged.claude, body);
|
|
585
667
|
return;
|
|
586
668
|
}
|
|
669
|
+
if (req.method === "POST" &&
|
|
670
|
+
(path === "/v1/chat/completions" || path === "/chat/completions")) {
|
|
671
|
+
if (!merged.opencode?.baseUrl) {
|
|
672
|
+
sendJson(res, 503, {
|
|
673
|
+
error: {
|
|
674
|
+
message: "Bridge 未配置 OpenCode 上游。请先 llms opencode use <openai-chat profile>",
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (!authenticateDataRequest(req, merged.opencode, "opencode")) {
|
|
680
|
+
sendJson(res, 401, {
|
|
681
|
+
error: {
|
|
682
|
+
code: "invalid_bridge_token",
|
|
683
|
+
message: "Bridge token 无效;请重新执行 llms opencode use <profile>",
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const body = await readBody(req);
|
|
689
|
+
await forwardOpenCodeChat(req, res, merged.opencode, body);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
587
692
|
sendJson(res, 404, {
|
|
588
693
|
error: {
|
|
589
|
-
message: `Bridge 支持 GET /v1/models、POST /v1/responses、POST /v1/messages(当前: ${req.method} ${path})`,
|
|
694
|
+
message: `Bridge 支持 GET /v1/models、POST /v1/responses、POST /v1/messages、POST /v1/chat/completions(当前: ${req.method} ${path})`,
|
|
590
695
|
},
|
|
591
696
|
});
|
|
592
697
|
}
|
package/dist/bridge/state.js
CHANGED
|
@@ -63,7 +63,10 @@ function isLegacyUpstream(raw) {
|
|
|
63
63
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
64
64
|
return false;
|
|
65
65
|
const row = raw;
|
|
66
|
-
return typeof row.baseUrl === "string" &&
|
|
66
|
+
return (typeof row.baseUrl === "string" &&
|
|
67
|
+
!("codex" in row) &&
|
|
68
|
+
!("claude" in row) &&
|
|
69
|
+
!("opencode" in row));
|
|
67
70
|
}
|
|
68
71
|
export function normalizeBridgeUpstreams(raw) {
|
|
69
72
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
@@ -71,11 +74,12 @@ export function normalizeBridgeUpstreams(raw) {
|
|
|
71
74
|
}
|
|
72
75
|
const row = raw;
|
|
73
76
|
if (isLegacyUpstream(raw)) {
|
|
74
|
-
return { codex: raw, claude: null };
|
|
77
|
+
return { codex: raw, claude: null, opencode: null };
|
|
75
78
|
}
|
|
76
79
|
return {
|
|
77
80
|
codex: row.codex ?? null,
|
|
78
81
|
claude: row.claude ?? null,
|
|
82
|
+
opencode: row.opencode ?? null,
|
|
79
83
|
};
|
|
80
84
|
}
|
|
81
85
|
/** Legacy upstreams cannot authenticate until reapplied. */
|
|
@@ -155,6 +159,7 @@ function readLegacyMigrationState() {
|
|
|
155
159
|
upstreams: {
|
|
156
160
|
codex: markUpstreamMigrationRequired(upstreams.codex),
|
|
157
161
|
claude: markUpstreamMigrationRequired(upstreams.claude),
|
|
162
|
+
opencode: markUpstreamMigrationRequired(upstreams.opencode),
|
|
158
163
|
},
|
|
159
164
|
pending: null,
|
|
160
165
|
});
|
|
@@ -224,6 +229,7 @@ function persistState(next) {
|
|
|
224
229
|
upstreams: {
|
|
225
230
|
codex: next.upstreams.codex,
|
|
226
231
|
claude: next.upstreams.claude,
|
|
232
|
+
opencode: next.upstreams.opencode,
|
|
227
233
|
},
|
|
228
234
|
pending: next.pending,
|
|
229
235
|
};
|
package/dist/bridge/types.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export const DEFAULT_BRIDGE_PORT = 17890;
|
|
2
2
|
export const DEFAULT_BRIDGE_HOST = "127.0.0.1";
|
|
3
3
|
export function emptyUpstreams() {
|
|
4
|
-
return { codex: null, claude: null };
|
|
4
|
+
return { codex: null, claude: null, opencode: null };
|
|
5
5
|
}
|
|
6
6
|
export function hasAnyUpstream(upstreams) {
|
|
7
|
-
return Boolean(upstreams.codex?.baseUrl ||
|
|
7
|
+
return Boolean(upstreams.codex?.baseUrl ||
|
|
8
|
+
upstreams.claude?.baseUrl ||
|
|
9
|
+
upstreams.opencode?.baseUrl);
|
|
8
10
|
}
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { TOOLS } from "./types.js";
|
|
|
3
3
|
import { registerToolCommand } from "./commands/tool.js";
|
|
4
4
|
import { registerLaunchCommand } from "./commands/launch-cmd.js";
|
|
5
5
|
import { registerBridgeCommand } from "./commands/bridge-cmd.js";
|
|
6
|
+
import { registerGatewayCommand } from "./commands/gateway-cmd.js";
|
|
6
7
|
import { registerSetupCommand } from "./commands/setup-cmd.js";
|
|
7
8
|
import { registerHomeCommand } from "./commands/home-cmd.js";
|
|
8
9
|
import { getAppConfigRoot } from "./utils/paths.js";
|
|
@@ -22,6 +23,7 @@ export function createProgram() {
|
|
|
22
23
|
});
|
|
23
24
|
registerLaunchCommand(program);
|
|
24
25
|
registerBridgeCommand(program);
|
|
26
|
+
registerGatewayCommand(program);
|
|
25
27
|
registerSetupCommand(program);
|
|
26
28
|
for (const tool of TOOLS) {
|
|
27
29
|
registerToolCommand(program, tool);
|
|
@@ -72,6 +72,7 @@ export function registerBridgeCommand(program) {
|
|
|
72
72
|
upstreams: {
|
|
73
73
|
codex: summarize(state.upstreams.codex),
|
|
74
74
|
claude: summarize(state.upstreams.claude),
|
|
75
|
+
opencode: summarize(state.upstreams.opencode),
|
|
75
76
|
},
|
|
76
77
|
};
|
|
77
78
|
if (opts.json) {
|
|
@@ -80,9 +81,9 @@ export function registerBridgeCommand(program) {
|
|
|
80
81
|
}
|
|
81
82
|
console.log(`状态:${alive ? "运行中" : "未运行"}`);
|
|
82
83
|
console.log(`根地址:${data.rootUrl}`);
|
|
83
|
-
console.log(`Codex base:${data.codexBaseUrl}`);
|
|
84
|
+
console.log(`Codex/OpenCode base:${data.codexBaseUrl}`);
|
|
84
85
|
console.log(`PID:${pid ?? "-"}`);
|
|
85
|
-
for (const tool of ["codex", "claude"]) {
|
|
86
|
+
for (const tool of ["codex", "claude", "opencode"]) {
|
|
86
87
|
const u = data.upstreams[tool];
|
|
87
88
|
if (u) {
|
|
88
89
|
console.log(`${tool} 上游:${u.baseUrl}(${u.mode}) profile=${u.profile ?? "-"}`);
|