@nvae/llmswitch 0.7.0 → 0.9.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.
@@ -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/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);