@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,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate OpenAI Chat Completions requests → Anthropic Messages API.
|
|
3
|
+
*
|
|
4
|
+
* Reverse direction of `anthropic-translate-request.ts`. Used by the gateway
|
|
5
|
+
* when an OpenAI-format client is routed to an Anthropic upstream.
|
|
6
|
+
*/
|
|
7
|
+
/** Anthropic requires max_tokens; use this when the client omits it. */
|
|
8
|
+
export const DEFAULT_ANTHROPIC_MAX_TOKENS = 4096;
|
|
9
|
+
function asRecord(value) {
|
|
10
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function parseDataUrl(url) {
|
|
16
|
+
const match = url.match(/^data:([^;,]+);base64,([\s\S]*)$/);
|
|
17
|
+
if (!match)
|
|
18
|
+
return null;
|
|
19
|
+
return { mediaType: match[1] ?? "image/png", data: match[2] ?? "" };
|
|
20
|
+
}
|
|
21
|
+
function imageBlock(url) {
|
|
22
|
+
const inline = parseDataUrl(url);
|
|
23
|
+
if (inline) {
|
|
24
|
+
return {
|
|
25
|
+
type: "image",
|
|
26
|
+
source: {
|
|
27
|
+
type: "base64",
|
|
28
|
+
media_type: inline.mediaType,
|
|
29
|
+
data: inline.data,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (/^https?:\/\//i.test(url)) {
|
|
34
|
+
return { type: "image", source: { type: "url", url } };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** Chat content (string or multimodal parts) → Anthropic content blocks. */
|
|
39
|
+
export function chatContentToAnthropicBlocks(content) {
|
|
40
|
+
if (typeof content === "string") {
|
|
41
|
+
return content ? [{ type: "text", text: content }] : [];
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(content))
|
|
44
|
+
return [];
|
|
45
|
+
const blocks = [];
|
|
46
|
+
for (const raw of content) {
|
|
47
|
+
const part = asRecord(raw);
|
|
48
|
+
if (!part)
|
|
49
|
+
continue;
|
|
50
|
+
const type = String(part.type || "");
|
|
51
|
+
if ((type === "text" || type === "input_text" || type === "output_text") &&
|
|
52
|
+
typeof part.text === "string") {
|
|
53
|
+
blocks.push({ type: "text", text: part.text });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (type === "image_url") {
|
|
57
|
+
const nested = asRecord(part.image_url);
|
|
58
|
+
const url = typeof nested?.url === "string"
|
|
59
|
+
? nested.url
|
|
60
|
+
: typeof part.image_url === "string"
|
|
61
|
+
? part.image_url
|
|
62
|
+
: "";
|
|
63
|
+
const block = url ? imageBlock(url) : null;
|
|
64
|
+
if (block)
|
|
65
|
+
blocks.push(block);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Already an Anthropic-shaped block (image/document/thinking): keep as-is.
|
|
69
|
+
if (part.source || type === "thinking" || type === "redacted_thinking") {
|
|
70
|
+
blocks.push(part);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (typeof part.text === "string") {
|
|
74
|
+
blocks.push({ type: "text", text: part.text });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return blocks;
|
|
78
|
+
}
|
|
79
|
+
function stringifyToolArguments(raw) {
|
|
80
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
if (typeof raw !== "string" || !raw.trim())
|
|
84
|
+
return {};
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Partial or non-JSON arguments: forward as a single field.
|
|
93
|
+
}
|
|
94
|
+
return { input: raw };
|
|
95
|
+
}
|
|
96
|
+
function contentToText(content) {
|
|
97
|
+
if (typeof content === "string")
|
|
98
|
+
return content;
|
|
99
|
+
if (!Array.isArray(content))
|
|
100
|
+
return "";
|
|
101
|
+
return content
|
|
102
|
+
.map((raw) => {
|
|
103
|
+
const part = asRecord(raw);
|
|
104
|
+
return part && typeof part.text === "string" ? part.text : "";
|
|
105
|
+
})
|
|
106
|
+
.join("");
|
|
107
|
+
}
|
|
108
|
+
function pushMessage(messages, role, blocks) {
|
|
109
|
+
if (!blocks.length)
|
|
110
|
+
return;
|
|
111
|
+
const last = messages[messages.length - 1];
|
|
112
|
+
// Anthropic expects alternating roles; merge consecutive same-role turns.
|
|
113
|
+
if (last && last.role === role) {
|
|
114
|
+
last.content.push(...blocks);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
messages.push({ role, content: blocks });
|
|
118
|
+
}
|
|
119
|
+
export function chatMessagesToAnthropicMessages(chatMessages) {
|
|
120
|
+
const systemParts = [];
|
|
121
|
+
const messages = [];
|
|
122
|
+
for (const message of chatMessages) {
|
|
123
|
+
const role = String(message.role || "user");
|
|
124
|
+
if (role === "system" || role === "developer") {
|
|
125
|
+
const text = contentToText(message.content);
|
|
126
|
+
if (text)
|
|
127
|
+
systemParts.push(text);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (role === "tool" || role === "function") {
|
|
131
|
+
const toolUseId = message.tool_call_id || message.name || "";
|
|
132
|
+
if (!toolUseId)
|
|
133
|
+
continue;
|
|
134
|
+
pushMessage(messages, "user", [
|
|
135
|
+
{
|
|
136
|
+
type: "tool_result",
|
|
137
|
+
tool_use_id: toolUseId,
|
|
138
|
+
content: contentToText(message.content),
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (role === "assistant") {
|
|
144
|
+
const blocks = chatContentToAnthropicBlocks(message.content);
|
|
145
|
+
for (const call of message.tool_calls ?? []) {
|
|
146
|
+
blocks.push({
|
|
147
|
+
type: "tool_use",
|
|
148
|
+
id: call.id,
|
|
149
|
+
name: call.function?.name || "tool",
|
|
150
|
+
input: stringifyToolArguments(call.function?.arguments),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
pushMessage(messages, "assistant", blocks);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
pushMessage(messages, "user", chatContentToAnthropicBlocks(message.content));
|
|
157
|
+
}
|
|
158
|
+
return { system: systemParts.join("\n\n"), messages };
|
|
159
|
+
}
|
|
160
|
+
export function chatToolsToAnthropicTools(tools) {
|
|
161
|
+
if (!Array.isArray(tools) || !tools.length)
|
|
162
|
+
return undefined;
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const tool of tools) {
|
|
165
|
+
const fn = tool?.function;
|
|
166
|
+
const name = String(fn?.name || "").trim();
|
|
167
|
+
if (!name)
|
|
168
|
+
continue;
|
|
169
|
+
const entry = {
|
|
170
|
+
name,
|
|
171
|
+
input_schema: fn?.parameters ?? { type: "object", properties: {} },
|
|
172
|
+
};
|
|
173
|
+
if (typeof fn?.description === "string")
|
|
174
|
+
entry.description = fn.description;
|
|
175
|
+
out.push(entry);
|
|
176
|
+
}
|
|
177
|
+
return out.length ? out : undefined;
|
|
178
|
+
}
|
|
179
|
+
export function chatToolChoiceToAnthropic(toolChoice) {
|
|
180
|
+
if (toolChoice == null)
|
|
181
|
+
return undefined;
|
|
182
|
+
if (toolChoice === "auto")
|
|
183
|
+
return { type: "auto" };
|
|
184
|
+
if (toolChoice === "required")
|
|
185
|
+
return { type: "any" };
|
|
186
|
+
if (toolChoice === "none")
|
|
187
|
+
return { type: "none" };
|
|
188
|
+
const obj = asRecord(toolChoice);
|
|
189
|
+
if (!obj)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (obj.type === "function") {
|
|
192
|
+
const nested = asRecord(obj.function);
|
|
193
|
+
const name = String(nested?.name || obj.name || "");
|
|
194
|
+
return name ? { type: "tool", name } : { type: "auto" };
|
|
195
|
+
}
|
|
196
|
+
// Already Anthropic-shaped.
|
|
197
|
+
if (obj.type === "auto" || obj.type === "any" || obj.type === "tool") {
|
|
198
|
+
return obj;
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
function normalizeStop(stop) {
|
|
203
|
+
if (typeof stop === "string")
|
|
204
|
+
return stop ? [stop] : undefined;
|
|
205
|
+
if (Array.isArray(stop)) {
|
|
206
|
+
const list = stop.filter((v) => typeof v === "string");
|
|
207
|
+
return list.length ? list : undefined;
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Chat Completions request → Anthropic Messages request body.
|
|
213
|
+
* `max_tokens` is mandatory upstream, so a default is applied when missing.
|
|
214
|
+
*/
|
|
215
|
+
export function chatToAnthropicRequest(body) {
|
|
216
|
+
const chatMessages = Array.isArray(body.messages)
|
|
217
|
+
? body.messages
|
|
218
|
+
: [];
|
|
219
|
+
const { system, messages } = chatMessagesToAnthropicMessages(chatMessages);
|
|
220
|
+
const stream = Boolean(body.stream);
|
|
221
|
+
const maxTokens = typeof body.max_tokens === "number"
|
|
222
|
+
? body.max_tokens
|
|
223
|
+
: typeof body.max_completion_tokens === "number"
|
|
224
|
+
? body.max_completion_tokens
|
|
225
|
+
: DEFAULT_ANTHROPIC_MAX_TOKENS;
|
|
226
|
+
const out = {
|
|
227
|
+
model: String(body.model || ""),
|
|
228
|
+
max_tokens: maxTokens,
|
|
229
|
+
messages,
|
|
230
|
+
stream,
|
|
231
|
+
};
|
|
232
|
+
if (system)
|
|
233
|
+
out.system = system;
|
|
234
|
+
const tools = chatToolsToAnthropicTools(body.tools);
|
|
235
|
+
if (tools)
|
|
236
|
+
out.tools = tools;
|
|
237
|
+
if (body.tool_choice !== undefined) {
|
|
238
|
+
const choice = chatToolChoiceToAnthropic(body.tool_choice);
|
|
239
|
+
if (choice !== undefined)
|
|
240
|
+
out.tool_choice = choice;
|
|
241
|
+
}
|
|
242
|
+
if (typeof body.temperature === "number")
|
|
243
|
+
out.temperature = body.temperature;
|
|
244
|
+
if (typeof body.top_p === "number")
|
|
245
|
+
out.top_p = body.top_p;
|
|
246
|
+
const stopSequences = normalizeStop(body.stop);
|
|
247
|
+
if (stopSequences)
|
|
248
|
+
out.stop_sequences = stopSequences;
|
|
249
|
+
if (typeof body.metadata === "object" && body.metadata) {
|
|
250
|
+
out.metadata = body.metadata;
|
|
251
|
+
}
|
|
252
|
+
// Map OpenAI reasoning_effort onto Anthropic extended thinking budget.
|
|
253
|
+
const effort = body.reasoning_effort;
|
|
254
|
+
if (typeof effort === "string") {
|
|
255
|
+
const budget = effort === "low"
|
|
256
|
+
? 1024
|
|
257
|
+
: effort === "medium"
|
|
258
|
+
? 4096
|
|
259
|
+
: effort === "high"
|
|
260
|
+
? 8192
|
|
261
|
+
: 0;
|
|
262
|
+
if (budget > 0) {
|
|
263
|
+
out.thinking = { type: "enabled", budget_tokens: budget };
|
|
264
|
+
// Thinking requires headroom above the budget.
|
|
265
|
+
if (maxTokens <= budget)
|
|
266
|
+
out.max_tokens = budget + 1024;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate OpenAI Chat Completions requests → OpenAI Responses API.
|
|
3
|
+
*
|
|
4
|
+
* Reverse direction of `translate-request.ts`. Used by the gateway when a
|
|
5
|
+
* Chat-format client is routed to a Responses upstream.
|
|
6
|
+
*/
|
|
7
|
+
function asRecord(value) {
|
|
8
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
function contentToText(content) {
|
|
14
|
+
if (typeof content === "string")
|
|
15
|
+
return content;
|
|
16
|
+
if (!Array.isArray(content))
|
|
17
|
+
return "";
|
|
18
|
+
return content
|
|
19
|
+
.map((raw) => {
|
|
20
|
+
const part = asRecord(raw);
|
|
21
|
+
return part && typeof part.text === "string" ? part.text : "";
|
|
22
|
+
})
|
|
23
|
+
.join("");
|
|
24
|
+
}
|
|
25
|
+
/** Chat content parts → Responses input content parts. */
|
|
26
|
+
function chatContentToInputParts(content, textType) {
|
|
27
|
+
if (typeof content === "string") {
|
|
28
|
+
return content ? [{ type: textType, text: content }] : [];
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(content))
|
|
31
|
+
return [];
|
|
32
|
+
const parts = [];
|
|
33
|
+
for (const raw of content) {
|
|
34
|
+
const part = asRecord(raw);
|
|
35
|
+
if (!part)
|
|
36
|
+
continue;
|
|
37
|
+
const type = String(part.type || "");
|
|
38
|
+
if ((type === "text" || type === "input_text" || type === "output_text") &&
|
|
39
|
+
typeof part.text === "string") {
|
|
40
|
+
parts.push({ type: textType, text: part.text });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (type === "image_url") {
|
|
44
|
+
const nested = asRecord(part.image_url);
|
|
45
|
+
const url = typeof nested?.url === "string"
|
|
46
|
+
? nested.url
|
|
47
|
+
: typeof part.image_url === "string"
|
|
48
|
+
? part.image_url
|
|
49
|
+
: "";
|
|
50
|
+
if (url) {
|
|
51
|
+
const item = { type: "input_image", image_url: url };
|
|
52
|
+
if (typeof nested?.detail === "string")
|
|
53
|
+
item.detail = nested.detail;
|
|
54
|
+
parts.push(item);
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (type === "input_audio") {
|
|
59
|
+
const nested = asRecord(part.input_audio);
|
|
60
|
+
if (nested)
|
|
61
|
+
parts.push({ type: "input_audio", input_audio: nested });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (type === "file") {
|
|
65
|
+
const nested = asRecord(part.file);
|
|
66
|
+
if (nested)
|
|
67
|
+
parts.push({ type: "input_file", ...nested });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (typeof part.text === "string") {
|
|
71
|
+
parts.push({ type: textType, text: part.text });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return parts;
|
|
75
|
+
}
|
|
76
|
+
export function chatMessagesToResponsesInput(chatMessages) {
|
|
77
|
+
const instructionParts = [];
|
|
78
|
+
const input = [];
|
|
79
|
+
for (const message of chatMessages) {
|
|
80
|
+
const role = String(message.role || "user");
|
|
81
|
+
if (role === "system" || role === "developer") {
|
|
82
|
+
const text = contentToText(message.content);
|
|
83
|
+
if (text)
|
|
84
|
+
instructionParts.push(text);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (role === "tool" || role === "function") {
|
|
88
|
+
const callId = message.tool_call_id || message.name || "";
|
|
89
|
+
if (!callId)
|
|
90
|
+
continue;
|
|
91
|
+
input.push({
|
|
92
|
+
type: "function_call_output",
|
|
93
|
+
call_id: callId,
|
|
94
|
+
output: contentToText(message.content),
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (role === "assistant") {
|
|
99
|
+
const parts = chatContentToInputParts(message.content, "output_text");
|
|
100
|
+
if (parts.length) {
|
|
101
|
+
input.push({ type: "message", role: "assistant", content: parts });
|
|
102
|
+
}
|
|
103
|
+
for (const call of message.tool_calls ?? []) {
|
|
104
|
+
input.push({
|
|
105
|
+
type: "function_call",
|
|
106
|
+
call_id: call.id,
|
|
107
|
+
name: call.function?.name || "tool",
|
|
108
|
+
arguments: typeof call.function?.arguments === "string"
|
|
109
|
+
? call.function.arguments
|
|
110
|
+
: JSON.stringify(call.function?.arguments ?? {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const parts = chatContentToInputParts(message.content, "input_text");
|
|
116
|
+
if (parts.length) {
|
|
117
|
+
input.push({ type: "message", role: "user", content: parts });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { instructions: instructionParts.join("\n\n"), input };
|
|
121
|
+
}
|
|
122
|
+
export function chatToolsToResponsesTools(tools) {
|
|
123
|
+
if (!Array.isArray(tools) || !tools.length)
|
|
124
|
+
return undefined;
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const tool of tools) {
|
|
127
|
+
const fn = tool?.function;
|
|
128
|
+
const name = String(fn?.name || "").trim();
|
|
129
|
+
if (!name)
|
|
130
|
+
continue;
|
|
131
|
+
const entry = {
|
|
132
|
+
type: "function",
|
|
133
|
+
name,
|
|
134
|
+
parameters: fn?.parameters ?? { type: "object", properties: {} },
|
|
135
|
+
strict: false,
|
|
136
|
+
};
|
|
137
|
+
if (typeof fn?.description === "string")
|
|
138
|
+
entry.description = fn.description;
|
|
139
|
+
out.push(entry);
|
|
140
|
+
}
|
|
141
|
+
return out.length ? out : undefined;
|
|
142
|
+
}
|
|
143
|
+
export function chatToolChoiceToResponses(toolChoice) {
|
|
144
|
+
if (toolChoice == null)
|
|
145
|
+
return undefined;
|
|
146
|
+
if (toolChoice === "auto" ||
|
|
147
|
+
toolChoice === "none" ||
|
|
148
|
+
toolChoice === "required") {
|
|
149
|
+
return toolChoice;
|
|
150
|
+
}
|
|
151
|
+
const obj = asRecord(toolChoice);
|
|
152
|
+
if (!obj)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (obj.type === "function") {
|
|
155
|
+
const nested = asRecord(obj.function);
|
|
156
|
+
const name = String(nested?.name || obj.name || "");
|
|
157
|
+
return name ? { type: "function", name } : "auto";
|
|
158
|
+
}
|
|
159
|
+
return toolChoice;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Chat Completions request → Responses request body.
|
|
163
|
+
* `store` defaults to false: the gateway is stateless and must not create
|
|
164
|
+
* server-side conversation state on the upstream.
|
|
165
|
+
*/
|
|
166
|
+
export function chatToResponsesRequest(body) {
|
|
167
|
+
const chatMessages = Array.isArray(body.messages)
|
|
168
|
+
? body.messages
|
|
169
|
+
: [];
|
|
170
|
+
const { instructions, input } = chatMessagesToResponsesInput(chatMessages);
|
|
171
|
+
const stream = Boolean(body.stream);
|
|
172
|
+
const out = {
|
|
173
|
+
model: String(body.model || ""),
|
|
174
|
+
input,
|
|
175
|
+
stream,
|
|
176
|
+
store: false,
|
|
177
|
+
};
|
|
178
|
+
if (instructions)
|
|
179
|
+
out.instructions = instructions;
|
|
180
|
+
const tools = chatToolsToResponsesTools(body.tools);
|
|
181
|
+
if (tools)
|
|
182
|
+
out.tools = tools;
|
|
183
|
+
if (body.tool_choice !== undefined) {
|
|
184
|
+
const choice = chatToolChoiceToResponses(body.tool_choice);
|
|
185
|
+
if (choice !== undefined)
|
|
186
|
+
out.tool_choice = choice;
|
|
187
|
+
}
|
|
188
|
+
if (typeof body.parallel_tool_calls === "boolean") {
|
|
189
|
+
out.parallel_tool_calls = body.parallel_tool_calls;
|
|
190
|
+
}
|
|
191
|
+
if (typeof body.temperature === "number")
|
|
192
|
+
out.temperature = body.temperature;
|
|
193
|
+
if (typeof body.top_p === "number")
|
|
194
|
+
out.top_p = body.top_p;
|
|
195
|
+
const maxOut = typeof body.max_completion_tokens === "number"
|
|
196
|
+
? body.max_completion_tokens
|
|
197
|
+
: typeof body.max_tokens === "number"
|
|
198
|
+
? body.max_tokens
|
|
199
|
+
: undefined;
|
|
200
|
+
if (typeof maxOut === "number")
|
|
201
|
+
out.max_output_tokens = maxOut;
|
|
202
|
+
if (typeof body.reasoning_effort === "string") {
|
|
203
|
+
out.reasoning = { effort: body.reasoning_effort };
|
|
204
|
+
}
|
|
205
|
+
const responseFormat = asRecord(body.response_format);
|
|
206
|
+
if (responseFormat) {
|
|
207
|
+
out.text = { format: responseFormat };
|
|
208
|
+
}
|
|
209
|
+
if (typeof body.metadata === "object" && body.metadata) {
|
|
210
|
+
out.metadata = body.metadata;
|
|
211
|
+
}
|
|
212
|
+
if (typeof body.user === "string" && body.user) {
|
|
213
|
+
out.user = body.user;
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
package/dist/bridge/manager.js
CHANGED
|
@@ -28,8 +28,14 @@ export class BridgeControlError extends Error {
|
|
|
28
28
|
export function profileNeedsBridge(profile) {
|
|
29
29
|
return profile.apiFormat === "openai-chat";
|
|
30
30
|
}
|
|
31
|
+
function isBunRuntime() {
|
|
32
|
+
return typeof process.versions?.bun ===
|
|
33
|
+
"string";
|
|
34
|
+
}
|
|
31
35
|
export function bridgeToolForCliTool(tool) {
|
|
32
|
-
return tool === "codex" || tool === "claude"
|
|
36
|
+
return tool === "codex" || tool === "claude" || tool === "opencode"
|
|
37
|
+
? tool
|
|
38
|
+
: null;
|
|
33
39
|
}
|
|
34
40
|
export function upstreamFromProfile(profile, tool, clientToken = null) {
|
|
35
41
|
const mode = tool === "claude" ? "chat" : profile.bridgeMode || "chat";
|
|
@@ -186,17 +192,26 @@ export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAU
|
|
|
186
192
|
instance: null,
|
|
187
193
|
pid: null,
|
|
188
194
|
}));
|
|
189
|
-
const
|
|
190
|
-
const args = [
|
|
195
|
+
const runner = resolveBridgeDaemonRunner();
|
|
196
|
+
const args = [
|
|
197
|
+
runner.entry,
|
|
198
|
+
"bridge",
|
|
199
|
+
"serve",
|
|
200
|
+
"--host",
|
|
201
|
+
host,
|
|
202
|
+
"--port",
|
|
203
|
+
String(port),
|
|
204
|
+
];
|
|
191
205
|
if (allowRemote)
|
|
192
206
|
args.push("--allow-remote");
|
|
193
|
-
const child = spawn(
|
|
207
|
+
const child = spawn(runner.command, args, {
|
|
194
208
|
detached: true,
|
|
195
209
|
stdio: "ignore",
|
|
196
210
|
env: {
|
|
197
211
|
...process.env,
|
|
198
212
|
LLM_SWITCH_BRIDGE_PORT: String(port),
|
|
199
213
|
LLM_SWITCH_BRIDGE_HOST: host,
|
|
214
|
+
LLM_SWITCH_BRIDGE_RUNTIME: runner.command,
|
|
200
215
|
},
|
|
201
216
|
});
|
|
202
217
|
child.unref();
|
|
@@ -243,6 +258,9 @@ export async function forceStopBridge() {
|
|
|
243
258
|
await stopBridge();
|
|
244
259
|
}
|
|
245
260
|
export async function runBridgeForeground(host, port, allowRemote = false) {
|
|
261
|
+
if (process.env.LLM_SWITCH_BRIDGE_RUNTIME !== "node" && isBunRuntime()) {
|
|
262
|
+
console.error("注意:bridge 当前由 Bun 运行,Bun 会本地解析 DNS,可能导致 socks5h 代理出口地区错误。建议用「node dist/index.js bridge serve」运行。");
|
|
263
|
+
}
|
|
246
264
|
assertBridgeListenerAllowed(host, allowRemote);
|
|
247
265
|
const listener = resolveBridgeListener({ host, port, allowRemote });
|
|
248
266
|
const previous = readBridgeState();
|
|
@@ -321,15 +339,29 @@ export async function runBridgeForeground(host, port, allowRemote = false) {
|
|
|
321
339
|
process.removeListener("SIGINT", onSignal);
|
|
322
340
|
process.removeListener("SIGTERM", onSignal);
|
|
323
341
|
}
|
|
324
|
-
|
|
342
|
+
/**
|
|
343
|
+
* Resolve how the detached bridge daemon is launched. The bridge must run under
|
|
344
|
+
* Node.js: Bun's node:https stack resolves target DNS locally, which defeats
|
|
345
|
+
* socks5h remote-DNS and can produce wrong-region egress. Node delegates the
|
|
346
|
+
* hostname to the socks agent, giving correct remote-DNS behavior.
|
|
347
|
+
*/
|
|
348
|
+
function resolveBridgeDaemonRunner() {
|
|
349
|
+
const candidates = [
|
|
350
|
+
fileURLToPath(new URL("../index.js", import.meta.url)),
|
|
351
|
+
fileURLToPath(new URL("../../dist/index.js", import.meta.url)),
|
|
352
|
+
fileURLToPath(new URL("../index.ts", import.meta.url)),
|
|
353
|
+
];
|
|
354
|
+
for (const candidate of candidates) {
|
|
355
|
+
if (existsSync(candidate)) {
|
|
356
|
+
if (candidate.endsWith(".js")) {
|
|
357
|
+
return { command: "node", entry: candidate };
|
|
358
|
+
}
|
|
359
|
+
return { command: process.execPath, entry: candidate };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
325
362
|
const running = process.argv[1];
|
|
326
|
-
if (running && existsSync(running))
|
|
327
|
-
return running;
|
|
328
|
-
|
|
329
|
-
if (existsSync(compiled))
|
|
330
|
-
return compiled;
|
|
331
|
-
const source = fileURLToPath(new URL("../index.ts", import.meta.url));
|
|
332
|
-
if (existsSync(source))
|
|
333
|
-
return source;
|
|
363
|
+
if (running && existsSync(running)) {
|
|
364
|
+
return { command: process.execPath, entry: running };
|
|
365
|
+
}
|
|
334
366
|
throw new BridgeControlError("无法定位 llmswitch 入口文件");
|
|
335
367
|
}
|