@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.
@@ -30,7 +30,7 @@ function mapUsage(usage) {
30
30
  function numberOr(value) {
31
31
  return typeof value === "number" ? value : undefined;
32
32
  }
33
- export function createStreamState(model, responseId, customTools) {
33
+ export function createStreamState(model, responseId, customTools, webSearchEnabled = false) {
34
34
  return {
35
35
  responseId: responseId || newId("resp"),
36
36
  model,
@@ -38,13 +38,60 @@ export function createStreamState(model, responseId, customTools) {
38
38
  textStarted: false,
39
39
  textContentIndex: 0,
40
40
  outputIndex: 0,
41
- fullText: "",
41
+ currentText: "",
42
+ completedItems: [],
43
+ webSearchEnabled,
44
+ webSearchBuffer: "",
45
+ insideWebSearch: false,
42
46
  customTools: new Set(customTools ?? []),
43
47
  toolCalls: new Map(),
44
48
  created: false,
45
49
  completed: false,
46
50
  };
47
51
  }
52
+ const WEB_SEARCH_OPEN = "<web_search>";
53
+ const WEB_SEARCH_CLOSE = "</web_search>";
54
+ function pushTextSegment(segments, content) {
55
+ if (!content)
56
+ return;
57
+ const previous = segments[segments.length - 1];
58
+ if (previous?.type === "text")
59
+ previous.content += content;
60
+ else
61
+ segments.push({ type: "text", content });
62
+ }
63
+ function splitWebSearchContent(content, enabled) {
64
+ if (!enabled || !content) {
65
+ return content ? [{ type: "text", content }] : [];
66
+ }
67
+ const segments = [];
68
+ let cursor = 0;
69
+ while (cursor < content.length) {
70
+ const open = content.indexOf(WEB_SEARCH_OPEN, cursor);
71
+ if (open < 0) {
72
+ pushTextSegment(segments, content.slice(cursor));
73
+ break;
74
+ }
75
+ const close = content.indexOf(WEB_SEARCH_CLOSE, open + WEB_SEARCH_OPEN.length);
76
+ if (close < 0) {
77
+ pushTextSegment(segments, content.slice(cursor));
78
+ break;
79
+ }
80
+ pushTextSegment(segments, content.slice(cursor, open));
81
+ segments.push({
82
+ type: "web_search",
83
+ content: content.slice(open + WEB_SEARCH_OPEN.length, close),
84
+ });
85
+ cursor = close + WEB_SEARCH_CLOSE.length;
86
+ }
87
+ return segments;
88
+ }
89
+ function webSearchAction(content) {
90
+ const match = content.match(/^\s*Search results for\s+["“]([^"”]+)["”]\s*:/);
91
+ return match
92
+ ? { type: "search", query: match[1] }
93
+ : { type: "search" };
94
+ }
48
95
  /**
49
96
  * Chat function-calling often wraps freeform payloads as `{"input":"..."}`.
50
97
  * Codex custom tools need the raw string in `input`.
@@ -102,6 +149,7 @@ function ensureTextItem(state, out) {
102
149
  return;
103
150
  state.textStarted = true;
104
151
  state.textItemId = newId("msg");
152
+ state.currentText = "";
105
153
  const outputIndex = state.outputIndex;
106
154
  out.push(sseEvent("response.output_item.added", {
107
155
  output_index: outputIndex,
@@ -120,6 +168,103 @@ function ensureTextItem(state, out) {
120
168
  part: { type: "output_text", text: "", annotations: [] },
121
169
  }));
122
170
  }
171
+ function emitTextDelta(state, out, content) {
172
+ if (!content)
173
+ return;
174
+ ensureTextItem(state, out);
175
+ state.currentText += content;
176
+ out.push(sseEvent("response.output_text.delta", {
177
+ item_id: state.textItemId,
178
+ output_index: state.outputIndex,
179
+ content_index: state.textContentIndex,
180
+ delta: content,
181
+ }));
182
+ }
183
+ function pendingOpenTagLength(value) {
184
+ const max = Math.min(value.length, WEB_SEARCH_OPEN.length - 1);
185
+ for (let length = max; length > 0; length -= 1) {
186
+ if (WEB_SEARCH_OPEN.startsWith(value.slice(-length)))
187
+ return length;
188
+ }
189
+ return 0;
190
+ }
191
+ function emitWebSearchItem(state, out, content) {
192
+ if (state.textStarted)
193
+ closeTextItem(state, out);
194
+ const outputIndex = state.outputIndex;
195
+ const itemId = newId("ws");
196
+ const action = webSearchAction(content);
197
+ out.push(sseEvent("response.output_item.added", {
198
+ output_index: outputIndex,
199
+ item: {
200
+ id: itemId,
201
+ type: "web_search_call",
202
+ status: "in_progress",
203
+ action,
204
+ },
205
+ }));
206
+ const item = {
207
+ id: itemId,
208
+ type: "web_search_call",
209
+ status: "completed",
210
+ action,
211
+ };
212
+ out.push(sseEvent("response.output_item.done", {
213
+ output_index: outputIndex,
214
+ item,
215
+ }));
216
+ state.completedItems.push({ outputIndex, item });
217
+ state.outputIndex += 1;
218
+ if (content) {
219
+ emitTextDelta(state, out, content);
220
+ closeTextItem(state, out);
221
+ }
222
+ }
223
+ function consumeTextContent(state, out, content) {
224
+ if (!content)
225
+ return;
226
+ if (!state.webSearchEnabled) {
227
+ emitTextDelta(state, out, content);
228
+ return;
229
+ }
230
+ state.webSearchBuffer += content;
231
+ while (state.webSearchBuffer) {
232
+ if (state.insideWebSearch) {
233
+ const close = state.webSearchBuffer.indexOf(WEB_SEARCH_CLOSE);
234
+ if (close < 0)
235
+ return;
236
+ const searchContent = state.webSearchBuffer.slice(0, close);
237
+ state.webSearchBuffer = state.webSearchBuffer.slice(close + WEB_SEARCH_CLOSE.length);
238
+ state.insideWebSearch = false;
239
+ emitWebSearchItem(state, out, searchContent);
240
+ continue;
241
+ }
242
+ const open = state.webSearchBuffer.indexOf(WEB_SEARCH_OPEN);
243
+ if (open >= 0) {
244
+ emitTextDelta(state, out, state.webSearchBuffer.slice(0, open));
245
+ if (state.textStarted)
246
+ closeTextItem(state, out);
247
+ state.webSearchBuffer = state.webSearchBuffer.slice(open + WEB_SEARCH_OPEN.length);
248
+ state.insideWebSearch = true;
249
+ continue;
250
+ }
251
+ const pending = pendingOpenTagLength(state.webSearchBuffer);
252
+ const safeLength = state.webSearchBuffer.length - pending;
253
+ emitTextDelta(state, out, state.webSearchBuffer.slice(0, safeLength));
254
+ state.webSearchBuffer = state.webSearchBuffer.slice(safeLength);
255
+ return;
256
+ }
257
+ }
258
+ function flushPendingWebSearchText(state, out) {
259
+ if (!state.webSearchBuffer && !state.insideWebSearch)
260
+ return;
261
+ const content = state.insideWebSearch
262
+ ? WEB_SEARCH_OPEN + state.webSearchBuffer
263
+ : state.webSearchBuffer;
264
+ state.webSearchBuffer = "";
265
+ state.insideWebSearch = false;
266
+ emitTextDelta(state, out, content);
267
+ }
123
268
  /**
124
269
  * Convert one Chat Completions SSE JSON chunk into zero or more Responses SSE frames.
125
270
  */
@@ -135,27 +280,15 @@ export function chatChunkToResponsesEvents(chunk, state) {
135
280
  const delta = (choice.delta || choice.message || {});
136
281
  const finish = choice.finish_reason;
137
282
  if (typeof delta.content === "string" && delta.content.length > 0) {
138
- ensureTextItem(state, out);
139
- state.fullText += delta.content;
140
- out.push(sseEvent("response.output_text.delta", {
141
- item_id: state.textItemId,
142
- output_index: state.outputIndex,
143
- content_index: state.textContentIndex,
144
- delta: delta.content,
145
- }));
283
+ consumeTextContent(state, out, delta.content);
146
284
  }
147
285
  // Completions-style: choices[].text
148
286
  if (typeof choice.text === "string" && choice.text.length > 0) {
149
- ensureTextItem(state, out);
150
- state.fullText += choice.text;
151
- out.push(sseEvent("response.output_text.delta", {
152
- item_id: state.textItemId,
153
- output_index: state.outputIndex,
154
- content_index: state.textContentIndex,
155
- delta: choice.text,
156
- }));
287
+ consumeTextContent(state, out, choice.text);
157
288
  }
158
289
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
290
+ if (toolCalls.length > 0)
291
+ flushPendingWebSearchText(state, out);
159
292
  for (const tcRaw of toolCalls) {
160
293
  const tc = tcRaw;
161
294
  const idx = typeof tc.index === "number" ? tc.index : 0;
@@ -277,37 +410,41 @@ function closeTextItem(state, out) {
277
410
  if (!state.textStarted || !state.textItemId)
278
411
  return;
279
412
  const outputIndex = state.outputIndex;
413
+ const item = {
414
+ id: state.textItemId,
415
+ type: "message",
416
+ status: "completed",
417
+ role: "assistant",
418
+ content: [
419
+ { type: "output_text", text: state.currentText, annotations: [] },
420
+ ],
421
+ };
280
422
  out.push(sseEvent("response.output_text.done", {
281
423
  item_id: state.textItemId,
282
424
  output_index: outputIndex,
283
425
  content_index: state.textContentIndex,
284
- text: state.fullText,
426
+ text: state.currentText,
285
427
  }));
286
428
  out.push(sseEvent("response.content_part.done", {
287
429
  item_id: state.textItemId,
288
430
  output_index: outputIndex,
289
431
  content_index: state.textContentIndex,
290
- part: { type: "output_text", text: state.fullText, annotations: [] },
432
+ part: { type: "output_text", text: state.currentText, annotations: [] },
291
433
  }));
292
434
  out.push(sseEvent("response.output_item.done", {
293
435
  output_index: outputIndex,
294
- item: {
295
- id: state.textItemId,
296
- type: "message",
297
- status: "completed",
298
- role: "assistant",
299
- content: [
300
- { type: "output_text", text: state.fullText, annotations: [] },
301
- ],
302
- },
436
+ item,
303
437
  }));
438
+ state.completedItems.push({ outputIndex, item });
304
439
  state.outputIndex += 1;
305
440
  state.textStarted = false;
306
441
  state.textItemId = null;
442
+ state.currentText = "";
307
443
  }
308
444
  function finalizeStream(state, out, finishReason) {
309
445
  if (state.completed)
310
446
  return;
447
+ flushPendingWebSearchText(state, out);
311
448
  if (state.textStarted && state.textItemId) {
312
449
  closeTextItem(state, out);
313
450
  }
@@ -319,6 +456,14 @@ function finalizeStream(state, out, finishReason) {
319
456
  const outputIndex = entry.outputIndex >= 0 ? entry.outputIndex : state.outputIndex;
320
457
  if (entry.custom) {
321
458
  const input = unwrapCustomToolInput(entry.arguments);
459
+ const item = {
460
+ id: entry.itemId,
461
+ type: "custom_tool_call",
462
+ status: "completed",
463
+ call_id: entry.callId,
464
+ name: entry.name || "tool",
465
+ input,
466
+ };
322
467
  out.push(sseEvent("response.custom_tool_call_input.delta", {
323
468
  item_id: entry.itemId,
324
469
  output_index: outputIndex,
@@ -333,17 +478,19 @@ function finalizeStream(state, out, finishReason) {
333
478
  }));
334
479
  out.push(sseEvent("response.output_item.done", {
335
480
  output_index: outputIndex,
336
- item: {
337
- id: entry.itemId,
338
- type: "custom_tool_call",
339
- status: "completed",
340
- call_id: entry.callId,
341
- name: entry.name || "tool",
342
- input,
343
- },
481
+ item,
344
482
  }));
483
+ state.completedItems.push({ outputIndex, item });
345
484
  }
346
485
  else {
486
+ const item = {
487
+ id: entry.itemId,
488
+ type: "function_call",
489
+ status: "completed",
490
+ call_id: entry.callId,
491
+ name: entry.name || "tool",
492
+ arguments: entry.arguments,
493
+ };
347
494
  out.push(sseEvent("response.function_call_arguments.done", {
348
495
  item_id: entry.itemId,
349
496
  output_index: outputIndex,
@@ -351,15 +498,9 @@ function finalizeStream(state, out, finishReason) {
351
498
  }));
352
499
  out.push(sseEvent("response.output_item.done", {
353
500
  output_index: outputIndex,
354
- item: {
355
- id: entry.itemId,
356
- type: "function_call",
357
- status: "completed",
358
- call_id: entry.callId,
359
- name: entry.name || "tool",
360
- arguments: entry.arguments,
361
- },
501
+ item,
362
502
  }));
503
+ state.completedItems.push({ outputIndex, item });
363
504
  }
364
505
  if (entry.outputIndex < 0) {
365
506
  entry.outputIndex = outputIndex;
@@ -369,38 +510,10 @@ function finalizeStream(state, out, finishReason) {
369
510
  const status = finishReason === "length" || finishReason === "content_filter"
370
511
  ? "incomplete"
371
512
  : "completed";
372
- const output = [];
373
- if (state.fullText) {
374
- output.push({
375
- id: newId("msg"),
376
- type: "message",
377
- status: "completed",
378
- role: "assistant",
379
- content: [{ type: "output_text", text: state.fullText, annotations: [] }],
380
- });
381
- }
382
- for (const entry of state.toolCalls.values()) {
383
- if (entry.custom) {
384
- output.push({
385
- id: entry.itemId,
386
- type: "custom_tool_call",
387
- status: "completed",
388
- call_id: entry.callId,
389
- name: entry.name || "tool",
390
- input: unwrapCustomToolInput(entry.arguments),
391
- });
392
- }
393
- else {
394
- output.push({
395
- id: entry.itemId,
396
- type: "function_call",
397
- status: "completed",
398
- call_id: entry.callId,
399
- name: entry.name || "tool",
400
- arguments: entry.arguments,
401
- });
402
- }
403
- }
513
+ const output = state.completedItems
514
+ .slice()
515
+ .sort((a, b) => a.outputIndex - b.outputIndex)
516
+ .map(({ item }) => item);
404
517
  const response = {
405
518
  ...baseResponse(state, status),
406
519
  output,
@@ -420,7 +533,7 @@ export function forceCompleteStream(state) {
420
533
  /**
421
534
  * Non-streaming Chat Completions JSON → Responses JSON.
422
535
  */
423
- export function chatCompletionToResponse(chat, modelFallback, customTools) {
536
+ export function chatCompletionToResponse(chat, modelFallback, customTools, webSearchEnabled = false) {
424
537
  const id = newId("resp");
425
538
  const model = String(chat.model || modelFallback || "");
426
539
  const customSet = new Set(customTools ?? []);
@@ -436,13 +549,25 @@ export function chatCompletionToResponse(chat, modelFallback, customTools) {
436
549
  const content = typeof message.content === "string"
437
550
  ? message.content
438
551
  : textFromCompletions;
439
- if (content) {
552
+ for (const segment of splitWebSearchContent(content, webSearchEnabled)) {
553
+ if (segment.type === "web_search") {
554
+ output.push({
555
+ id: newId("ws"),
556
+ type: "web_search_call",
557
+ status: "completed",
558
+ action: webSearchAction(segment.content),
559
+ });
560
+ if (!segment.content)
561
+ continue;
562
+ }
440
563
  output.push({
441
564
  id: newId("msg"),
442
565
  type: "message",
443
566
  status: "completed",
444
567
  role: "assistant",
445
- content: [{ type: "output_text", text: content, annotations: [] }],
568
+ content: [
569
+ { type: "output_text", text: segment.content, annotations: [] },
570
+ ],
446
571
  });
447
572
  }
448
573
  const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];