@galaxy-yearn/codex-deepseek-gateway 0.1.7 → 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/src/server.js CHANGED
@@ -1,21 +1,30 @@
1
1
  import http from 'node:http';
2
+ import { appendFileSync, renameSync, rmSync, statSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
2
4
  import { fileURLToPath } from 'node:url';
3
5
  import { loadConfig } from './config.js';
4
6
  import { generateId, safeJsonParse, toText } from './common.js';
5
7
  import { listModels, mergeModelLists, normalizeModelList } from './model-map.js';
6
8
  import {
7
9
  assistantMessageFromResponseOutput,
10
+ bridgedCommentaryToolCallsFromMessage,
11
+ chatToolNamesFromTools,
8
12
  convertChatCompletionToResponses,
13
+ expandParallelToolCallsInCompletion,
9
14
  extractToolCallIdsFromMessages,
10
15
  normalizeResponsesRequest,
16
+ resolveEmittedToolCallNamesInCompletion,
11
17
  ResponsesStreamMapper,
12
18
  serializeResponsesSseEvent,
19
+ stripBridgedCommentaryFromCompletion,
13
20
  toChatCompletionsRequest,
14
21
  toProviderChatCompletionsRequest,
22
+ unavailableWebSearchToolShims,
15
23
  } from './protocol.js';
16
24
  import { SessionStore } from './session-store.js';
17
25
  import { callChatCompletions, callModels, readJsonResponse, relayChatCompletionsResponse } from './upstream.js';
18
26
  import {
27
+ annotateMessagePartWithWebCitations,
19
28
  applyWebSearchOutputCompatibility,
20
29
  buildWebSearchCallItem,
21
30
  containsWebSearchTool,
@@ -34,6 +43,7 @@ import {
34
43
  } from './web-search-emulator.js';
35
44
 
36
45
  function sendJson(res, statusCode, payload, headers = {}) {
46
+ if (res.destroyed || res.writableEnded) return;
37
47
  res.writeHead(statusCode, {
38
48
  'content-type': 'application/json; charset=utf-8',
39
49
  ...headers,
@@ -67,25 +77,20 @@ function conversationIdFromRequest(request, normalized) {
67
77
  }
68
78
 
69
79
  function historyMessagesFromSession(session) {
70
- if (!session?.history?.length) return [];
71
- const messages = [];
72
- for (const turn of session.history) {
73
- if (Array.isArray(turn.historyMessages)) {
74
- messages.push(...turn.historyMessages);
75
- continue;
76
- }
77
- if (Array.isArray(turn.inputMessages)) {
78
- messages.push(...turn.inputMessages);
79
- } else if (Array.isArray(turn.chatRequest?.messages)) {
80
- messages.push(...turn.chatRequest.messages);
81
- }
82
- if (turn.assistantMessage) {
83
- messages.push(turn.assistantMessage);
84
- }
85
- }
80
+ return Array.isArray(session?.messages) ? session.messages : [];
81
+ }
82
+
83
+ function sessionMessages(chatMessages, assistantMessage) {
84
+ const messages = Array.isArray(chatMessages) ? chatMessages.slice() : [];
85
+ if (assistantMessage) messages.push(assistantMessage);
86
86
  return messages;
87
87
  }
88
88
 
89
+ function shouldPersistSession(normalized, request, conversationId) {
90
+ if (conversationId) return true;
91
+ return (normalized.store ?? request.store) !== false;
92
+ }
93
+
89
94
  function prependMissingAssistantToolMessages(messages, sessions) {
90
95
  const existingToolCallIds = new Set();
91
96
  const missingToolOutputIds = [];
@@ -114,6 +119,22 @@ function prependMissingAssistantToolMessages(messages, sessions) {
114
119
  return prefix.length ? prefix.concat(messages) : messages;
115
120
  }
116
121
 
122
+ function restoreAssistantReasoningContent(messages, sessions) {
123
+ return messages.map((message) => {
124
+ if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls) || !message.tool_calls.length) {
125
+ return message;
126
+ }
127
+ if (typeof message.reasoning_content === 'string' && message.reasoning_content) return message;
128
+ for (const toolCall of message.tool_calls) {
129
+ const stored = sessions.getAssistantMessageForToolCall(toolCall?.id);
130
+ if (typeof stored?.reasoning_content === 'string' && stored.reasoning_content) {
131
+ return { ...message, reasoning_content: stored.reasoning_content };
132
+ }
133
+ }
134
+ return message;
135
+ });
136
+ }
137
+
117
138
  function summarizeMessage(message, index) {
118
139
  return {
119
140
  index,
@@ -126,16 +147,66 @@ function summarizeMessage(message, index) {
126
147
  };
127
148
  }
128
149
 
129
- function logDebugPayload(config, request) {
150
+ function summarizeTool(tool, index) {
151
+ if (!tool || typeof tool !== 'object') {
152
+ return { index, type: typeof tool };
153
+ }
154
+ const fn = tool.type === 'function' && tool.function && typeof tool.function === 'object'
155
+ ? tool.function
156
+ : null;
157
+ return {
158
+ index,
159
+ type: tool.type,
160
+ name: fn?.name || tool.name || tool.tool_name || tool.server_label,
161
+ namespace: fn?.namespace || tool.namespace,
162
+ child_tools: Array.isArray(tool.tools) ? tool.tools.length : undefined,
163
+ has_parameters: Boolean(fn?.parameters || tool.parameters || tool.input_schema),
164
+ };
165
+ }
166
+
167
+ function summarizeTools(tools) {
168
+ return Array.isArray(tools) ? tools.map(summarizeTool) : [];
169
+ }
170
+
171
+ const DEBUG_PAYLOAD_LOG_MAX_BYTES = 5 * 1024 * 1024;
172
+
173
+ function rotateDebugPayloadLog(logPath, maxBytes) {
174
+ try {
175
+ if (statSync(logPath).size < maxBytes) return;
176
+ rmSync(`${logPath}.1`, { force: true });
177
+ renameSync(logPath, `${logPath}.1`);
178
+ } catch {
179
+ }
180
+ }
181
+
182
+ function writeDebugPayloadLine(config, line) {
183
+ process.stderr.write(line);
184
+ if (!config.debugPayloadLogPath) return;
185
+ try {
186
+ const logPath = resolve(config.debugPayloadLogPath);
187
+ rotateDebugPayloadLog(logPath, config.debugPayloadLogMaxBytes || DEBUG_PAYLOAD_LOG_MAX_BYTES);
188
+ appendFileSync(logPath, line, 'utf8');
189
+ } catch (error) {
190
+ process.stderr.write(`[codex-deepseek-gateway] failed to write debug payload log: ${error.message || error}\n`);
191
+ }
192
+ }
193
+
194
+ function logDebugPayload(config, request, context = {}) {
130
195
  if (!config.debugPayload) return;
131
196
  const summary = {
197
+ stage: context.stage || 'upstream',
132
198
  model: request.model,
133
199
  stream: request.stream,
134
200
  thinking: request.thinking,
135
201
  reasoning_effort: request.reasoning_effort,
202
+ codex_tools: summarizeTools(context.rawRequest?.tools),
203
+ normalized_tools: summarizeTools(context.normalized?.tools),
204
+ chat_tools: summarizeTools(context.chatRequest?.tools),
205
+ upstream_tools: summarizeTools(request.tools),
206
+ tool_choice: request.tool_choice,
136
207
  messages: Array.isArray(request.messages) ? request.messages.map(summarizeMessage) : [],
137
208
  };
138
- process.stderr.write(`[codex-deepseek-gateway] upstream request ${JSON.stringify(summary)}\n`);
209
+ writeDebugPayloadLine(config, `[codex-deepseek-gateway] upstream request ${JSON.stringify(summary)}\n`);
139
210
  }
140
211
 
141
212
  function resolveReasoningStreamMode(config, upstreamRequest) {
@@ -168,29 +239,26 @@ function webToolTimeoutMs(config = {}) {
168
239
  return Math.max(tavilyTimeout, firecrawlTimeout);
169
240
  }
170
241
 
171
- function addUsage(left, right) {
172
- if (!left) return right || null;
173
- if (!right) return left;
242
+ function combineUsage(completions) {
243
+ const usages = completions.map((completion) => completion?.usage).filter(Boolean);
244
+ if (!usages.length) return null;
245
+ const last = usages[usages.length - 1];
246
+ const sum = (pick) => usages.reduce((total, usage) => total + (Number(pick(usage)) || 0), 0);
247
+ const promptTokens = Number(last.prompt_tokens) || 0;
248
+ const completionTokens = sum((usage) => usage.completion_tokens);
174
249
  return {
175
- prompt_tokens: (left.prompt_tokens || 0) + (right.prompt_tokens || 0),
176
- completion_tokens: (left.completion_tokens || 0) + (right.completion_tokens || 0),
177
- total_tokens: (left.total_tokens || 0) + (right.total_tokens || 0),
178
- reasoning_tokens: (left.reasoning_tokens || 0) + (right.reasoning_tokens || 0),
250
+ prompt_tokens: promptTokens,
251
+ completion_tokens: completionTokens,
252
+ total_tokens: promptTokens + completionTokens,
179
253
  prompt_tokens_details: {
180
- cached_tokens: (left.prompt_tokens_details?.cached_tokens || 0) + (right.prompt_tokens_details?.cached_tokens || 0),
254
+ cached_tokens: last.prompt_tokens_details?.cached_tokens ?? last.prompt_cache_hit_tokens ?? 0,
181
255
  },
182
256
  completion_tokens_details: {
183
- reasoning_tokens:
184
- (left.completion_tokens_details?.reasoning_tokens || 0) +
185
- (right.completion_tokens_details?.reasoning_tokens || 0),
257
+ reasoning_tokens: sum((usage) => usage.completion_tokens_details?.reasoning_tokens ?? usage.reasoning_tokens),
186
258
  },
187
259
  };
188
260
  }
189
261
 
190
- function combineUsage(completions) {
191
- return completions.reduce((usage, completion) => addUsage(usage, completion?.usage), null);
192
- }
193
-
194
262
  function cloneCompletionWithUsage(completion, usage) {
195
263
  if (!usage) return completion;
196
264
  return {
@@ -202,7 +270,7 @@ function cloneCompletionWithUsage(completion, usage) {
202
270
  function toolCallsToFinalAnswerRequest(request) {
203
271
  return {
204
272
  ...request,
205
- tool_choice: 'none',
273
+ tool_choice: undefined,
206
274
  tools: undefined,
207
275
  messages: removeWebSearchInstructions(request.messages).concat({
208
276
  role: 'user',
@@ -245,6 +313,12 @@ function hasWebSearchContext(searches, openedPages) {
245
313
  return searches.length > 0 || openedPages.length > 0;
246
314
  }
247
315
 
316
+ function gatewayIncompleteMessageContent(reason) {
317
+ return reason === 'pseudo_tool_call_text_after_web_limit'
318
+ ? 'Gateway incomplete: after web tools were disabled, the model wrote a tool call as text instead of producing a final answer.'
319
+ : 'Gateway incomplete: the model did not produce visible assistant content after web tool results and a final-answer request.';
320
+ }
321
+
248
322
  function completionWithGatewayIncompleteMessage(completion, reason = 'no_visible_assistant_content') {
249
323
  const next = completion == null ? {} : JSON.parse(JSON.stringify(completion));
250
324
  if (!Array.isArray(next.choices) || !next.choices.length) {
@@ -252,9 +326,7 @@ function completionWithGatewayIncompleteMessage(completion, reason = 'no_visible
252
326
  }
253
327
  const choice = next.choices[0];
254
328
  const previousMessage = choice.message || {};
255
- const content = reason === 'pseudo_tool_call_text_after_web_limit'
256
- ? 'Gateway incomplete: after web tools were disabled, the model wrote a tool call as text instead of producing a final answer.'
257
- : 'Gateway incomplete: the model did not produce visible assistant content after web tool results and a final-answer request.';
329
+ const content = gatewayIncompleteMessageContent(reason);
258
330
  choice.message = {
259
331
  role: 'assistant',
260
332
  content,
@@ -276,24 +348,62 @@ function completionWithoutToolCalls(completion) {
276
348
  return next;
277
349
  }
278
350
 
279
- function markPayloadIncomplete(payload, reason) {
280
- payload.status = 'incomplete';
281
- payload.incomplete_details = { reason };
282
- return payload;
283
- }
284
-
285
- async function callUpstreamJson({ upstreamRequest, config }) {
351
+ async function callUpstreamJson({ upstreamRequest, config, signal }) {
286
352
  const response = await callChatCompletions({
287
353
  baseUrl: config.upstreamBaseUrl,
288
354
  apiKey: config.upstreamApiKey,
289
355
  request: upstreamRequest,
290
356
  timeoutMs: config.upstreamTimeoutMs,
357
+ signal,
291
358
  });
292
359
  const data = await readJsonResponse(response);
293
- return { response, data };
360
+ return {
361
+ response,
362
+ data: resolveEmittedToolCallNamesInCompletion(
363
+ expandParallelToolCallsInCompletion(data),
364
+ chatToolNamesFromTools(upstreamRequest.tools),
365
+ ),
366
+ };
367
+ }
368
+
369
+ function clientAbortControllerFor(res) {
370
+ const controller = new AbortController();
371
+ res.on('close', () => {
372
+ if (!res.writableEnded) controller.abort();
373
+ });
374
+ return controller;
375
+ }
376
+
377
+ function mergeAbortSignals(signals) {
378
+ const active = signals.filter(Boolean);
379
+ if (!active.length) return undefined;
380
+ if (active.length === 1) return active[0];
381
+ return AbortSignal.any(active);
382
+ }
383
+
384
+ function commentaryToolMessages(commentaryCalls) {
385
+ return (Array.isArray(commentaryCalls) ? commentaryCalls : []).map((toolCall) => ({
386
+ role: 'tool',
387
+ tool_call_id: toolCall.id || generateId('call'),
388
+ content: 'Delivered to the user.',
389
+ }));
390
+ }
391
+
392
+ function restoreCommentaryToolCalls(completion, commentaryCalls) {
393
+ if (!Array.isArray(commentaryCalls) || !commentaryCalls.length) return completion;
394
+ const choice = Array.isArray(completion?.choices) ? completion.choices[0] : null;
395
+ if (!choice?.message) return completion;
396
+ const existing = Array.isArray(choice.message.tool_calls) ? choice.message.tool_calls : [];
397
+ return {
398
+ ...completion,
399
+ choices: [
400
+ { ...choice, message: { ...choice.message, tool_calls: [...commentaryCalls, ...existing] } },
401
+ ...completion.choices.slice(1),
402
+ ],
403
+ };
294
404
  }
295
405
 
296
- async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchStart, onSearchDone }) {
406
+ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, config, clientSignal, onSearchStart, onSearchDone }) {
297
407
  const effectiveChatRequest = { ...chatRequest, normalized };
298
408
  const state = prepareWebSearchRequest({ normalized, chatRequest: effectiveChatRequest, config });
299
409
  const searchConfig = state.config || config;
@@ -310,9 +420,16 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
310
420
  currentChatRequest = toolCallsToFinalAnswerRequest(baseRequest);
311
421
  const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
312
422
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
423
+ logDebugPayload(config, upstreamRequest, {
424
+ stage: 'web_search_final_answer',
425
+ rawRequest,
426
+ normalized,
427
+ chatRequest: currentChatRequest,
428
+ });
313
429
  const { response, data } = await callUpstreamJson({
314
430
  upstreamRequest: disableStreaming(upstreamRequest),
315
431
  config,
432
+ signal: clientSignal,
316
433
  });
317
434
  finalResponse = response;
318
435
  if (!response.ok) return { ok: false, status: response.status, data };
@@ -329,17 +446,30 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
329
446
  for (let round = 0; round <= maxRounds + 1; round += 1) {
330
447
  const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
331
448
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
449
+ logDebugPayload(config, upstreamRequest, {
450
+ stage: `web_search_round_${round}`,
451
+ rawRequest,
452
+ normalized,
453
+ chatRequest: currentChatRequest,
454
+ });
332
455
  const { response, data } = await callUpstreamJson({
333
456
  upstreamRequest: disableStreaming(upstreamRequest),
334
457
  config,
458
+ signal: clientSignal,
335
459
  });
336
460
  finalResponse = response;
337
461
  if (!response.ok) return { ok: false, status: response.status, data };
338
462
  completions.push(data);
339
463
  finalCompletion = data;
340
-
341
- if (state.enabled && hasKnownExternalToolCalls(data, currentChatRequest.tools)) {
342
- finalCompletion = knownExternalToolCallsCompletion(data, currentChatRequest.tools);
464
+ const roundChatMessage = Array.isArray(data?.choices) ? data.choices[0]?.message : null;
465
+ const commentaryCalls = bridgedCommentaryToolCallsFromMessage(roundChatMessage, currentChatRequest.tools);
466
+ const routingData = stripBridgedCommentaryFromCompletion(data, currentChatRequest.tools);
467
+
468
+ if (state.enabled && hasKnownExternalToolCalls(routingData, currentChatRequest.tools)) {
469
+ finalCompletion = restoreCommentaryToolCalls(
470
+ knownExternalToolCallsCompletion(routingData, currentChatRequest.tools),
471
+ commentaryCalls,
472
+ );
343
473
  completions[completions.length - 1] = finalCompletion;
344
474
  break;
345
475
  }
@@ -348,12 +478,13 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
348
478
  break;
349
479
  }
350
480
 
351
- const wantsInternalWeb = shouldContinueWebSearchLoop(data);
352
- const hasToolCalls = hasAnyToolCalls(data);
481
+ const wantsInternalWeb = shouldContinueWebSearchLoop(routingData);
482
+ const wantsInternalRound = wantsInternalWeb || commentaryCalls.length > 0;
483
+ const hasToolCalls = hasAnyToolCalls(routingData);
353
484
 
354
- if (!wantsInternalWeb || round >= maxRounds) {
355
- if (hasToolCalls && hasUnknownExternalToolCalls(data, currentChatRequest.tools)) {
356
- const unsupportedMessages = unhandledToolMessagesFromCompletion(data, undefined, {
485
+ if (!wantsInternalRound || round >= maxRounds) {
486
+ if (hasToolCalls && hasUnknownExternalToolCalls(routingData, currentChatRequest.tools)) {
487
+ const unsupportedMessages = unhandledToolMessagesFromCompletion(routingData, undefined, {
357
488
  onlyUnknownExternal: true,
358
489
  tools: currentChatRequest.tools,
359
490
  });
@@ -365,7 +496,7 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
365
496
  break;
366
497
  }
367
498
 
368
- if (wantsInternalWeb || (!hasToolCalls && hasWebSearchContext(searches, openedPages) && !hasVisibleAssistantContent(data))) {
499
+ if (wantsInternalRound || (!hasToolCalls && hasWebSearchContext(searches, openedPages) && !hasVisibleAssistantContent(routingData))) {
369
500
  const finalAnswerError = await requestFinalAnswer(currentChatRequest);
370
501
  if (finalAnswerError) return finalAnswerError;
371
502
  break;
@@ -380,7 +511,7 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
380
511
  toolResult = await executeWebSearchCalls({
381
512
  completion: data,
382
513
  config: searchConfig,
383
- signal: controller.signal,
514
+ signal: mergeAbortSignals([controller.signal, clientSignal]),
384
515
  onSearchStart,
385
516
  onSearchDone,
386
517
  });
@@ -391,7 +522,7 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
391
522
  openedPages.push(...(toolResult.openedPages || []));
392
523
  currentChatRequest = {
393
524
  ...currentChatRequest,
394
- messages: currentChatRequest.messages.concat(toolResult.messages),
525
+ messages: currentChatRequest.messages.concat(toolResult.messages, commentaryToolMessages(commentaryCalls)),
395
526
  tool_choice:
396
527
  currentChatRequest.tool_choice?.function?.name === INTERNAL_WEB_SEARCH_TOOL
397
528
  ? 'auto'
@@ -410,170 +541,10 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
410
541
  };
411
542
  }
412
543
 
413
- const REPLAY_REASONING_DELTA_CHARS = 1200;
414
-
415
- function splitReplayText(text, maxChars = REPLAY_REASONING_DELTA_CHARS) {
416
- const value = String(text ?? '');
417
- if (!value) return [];
418
- const chunks = [];
419
- for (let index = 0; index < value.length; index += maxChars) {
420
- chunks.push(value.slice(index, index + maxChars));
421
- }
422
- return chunks;
423
- }
424
-
425
- function replayReasoningItemEvents({ item, outputIndex, mapper }) {
426
- const events = [];
427
- const summaries = Array.isArray(item.summary) ? item.summary : [];
428
- const content = Array.isArray(item.content) ? item.content : [];
429
- const addedItem = {
430
- ...item,
431
- status: 'in_progress',
432
- summary: [],
433
- content: content.map((part) => (part?.type === 'reasoning_text' ? { ...part, text: '' } : part)),
434
- };
435
-
436
- events.push({
437
- type: 'response.output_item.added',
438
- sequence_number: mapper.nextSequence(),
439
- output_index: outputIndex,
440
- item: addedItem,
441
- });
442
-
443
- for (const [summaryIndex, part] of summaries.entries()) {
444
- if (part?.type !== 'summary_text') continue;
445
- const text = String(part.text ?? '');
446
- events.push({
447
- type: 'response.reasoning_summary_part.added',
448
- sequence_number: mapper.nextSequence(),
449
- output_index: outputIndex,
450
- item_id: item.id,
451
- summary_index: summaryIndex,
452
- part: { ...part, text: '' },
453
- });
454
- for (const delta of splitReplayText(text)) {
455
- events.push({
456
- type: 'response.reasoning_summary_text.delta',
457
- sequence_number: mapper.nextSequence(),
458
- output_index: outputIndex,
459
- item_id: item.id,
460
- summary_index: summaryIndex,
461
- delta,
462
- });
463
- }
464
- events.push({
465
- type: 'response.reasoning_summary_text.done',
466
- sequence_number: mapper.nextSequence(),
467
- output_index: outputIndex,
468
- item_id: item.id,
469
- summary_index: summaryIndex,
470
- text,
471
- });
472
- events.push({
473
- type: 'response.reasoning_summary_part.done',
474
- sequence_number: mapper.nextSequence(),
475
- output_index: outputIndex,
476
- item_id: item.id,
477
- summary_index: summaryIndex,
478
- part,
479
- });
480
- }
481
-
482
- events.push({
483
- type: 'response.output_item.done',
484
- sequence_number: mapper.nextSequence(),
485
- output_index: outputIndex,
486
- item,
487
- });
488
- return events;
489
- }
490
-
491
- function outputEventsFromPayload(payload, mapper, { skipTypes = new Set() } = {}) {
492
- const events = [];
493
- const output = Array.isArray(payload.output) ? payload.output : [];
494
- for (const [outputIndex, item] of output.entries()) {
495
- if (skipTypes.has(item?.type)) continue;
496
- mapper.output[outputIndex] = item;
497
- if (item?.type === 'reasoning') {
498
- events.push(...replayReasoningItemEvents({ item, outputIndex, mapper }));
499
- continue;
500
- }
501
- events.push({
502
- type: 'response.output_item.added',
503
- sequence_number: mapper.nextSequence(),
504
- output_index: outputIndex,
505
- item,
506
- });
507
- if (item.type === 'message' && Array.isArray(item.content)) {
508
- for (const [contentIndex, part] of item.content.entries()) {
509
- events.push({
510
- type: 'response.content_part.added',
511
- sequence_number: mapper.nextSequence(),
512
- output_index: outputIndex,
513
- content_index: contentIndex,
514
- item_id: item.id,
515
- part: part.type === 'output_text' ? { ...part, text: '', annotations: [] } : part,
516
- });
517
- if (part.type === 'output_text' && part.text) {
518
- events.push({
519
- type: 'response.output_text.delta',
520
- sequence_number: mapper.nextSequence(),
521
- output_index: outputIndex,
522
- content_index: contentIndex,
523
- item_id: item.id,
524
- delta: part.text,
525
- logprobs: [],
526
- });
527
- for (const [annotationIndex, annotation] of (Array.isArray(part.annotations) ? part.annotations : []).entries()) {
528
- events.push({
529
- type: 'response.output_text.annotation.added',
530
- sequence_number: mapper.nextSequence(),
531
- output_index: outputIndex,
532
- content_index: contentIndex,
533
- item_id: item.id,
534
- annotation_index: annotationIndex,
535
- annotation,
536
- });
537
- }
538
- events.push({
539
- type: 'response.output_text.done',
540
- sequence_number: mapper.nextSequence(),
541
- output_index: outputIndex,
542
- content_index: contentIndex,
543
- item_id: item.id,
544
- text: part.text,
545
- logprobs: [],
546
- });
547
- }
548
- events.push({
549
- type: 'response.content_part.done',
550
- sequence_number: mapper.nextSequence(),
551
- output_index: outputIndex,
552
- content_index: contentIndex,
553
- item_id: item.id,
554
- part,
555
- });
556
- }
557
- }
558
- events.push({
559
- type: 'response.output_item.done',
560
- sequence_number: mapper.nextSequence(),
561
- output_index: outputIndex,
562
- item,
563
- });
564
- }
565
- return events;
566
- }
567
-
568
- function completedEventFromPayload(payload, mapper) {
569
- return {
570
- type: payload.status === 'incomplete' ? 'response.incomplete' : 'response.completed',
571
- sequence_number: mapper.nextSequence(),
572
- response: payload,
573
- };
574
- }
544
+ const STREAM_HEARTBEAT_MS = 10000;
575
545
 
576
546
  function writeSseEvent(res, event) {
547
+ if (res.writableEnded || res.destroyed) return;
577
548
  res.write(serializeResponsesSseEvent(event));
578
549
  }
579
550
 
@@ -609,7 +580,242 @@ function webSearchSseWriter({ res, mapper, outputIndexBySearch, includeSources =
609
580
  };
610
581
  }
611
582
 
612
- export function createProxyServer({ config = loadConfig(), sessions = new SessionStore() } = {}) {
583
+ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest, config, res, responseId, previousResponseId, clientSignal }) {
584
+ const effectiveChatRequest = { ...chatRequest, normalized };
585
+ const state = prepareWebSearchRequest({ normalized, chatRequest: effectiveChatRequest, config });
586
+ if (!state.enabled) return { handled: false };
587
+
588
+ const searchConfig = state.config || config;
589
+ const maxRounds = maxWebSearchRounds(searchConfig);
590
+ let currentChatRequest = state.chatRequest;
591
+
592
+ const buildUpstreamRequest = (stage) => {
593
+ const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
594
+ if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
595
+ logDebugPayload(config, upstreamRequest, { stage, rawRequest, normalized, chatRequest: currentChatRequest });
596
+ return upstreamRequest;
597
+ };
598
+
599
+ const firstUpstreamRequest = buildUpstreamRequest('web_search_stream_round_0');
600
+ let upstreamResponse = await callChatCompletions({
601
+ baseUrl: config.upstreamBaseUrl,
602
+ apiKey: config.upstreamApiKey,
603
+ request: firstUpstreamRequest,
604
+ timeoutMs: config.upstreamTimeoutMs,
605
+ signal: clientSignal,
606
+ });
607
+ if (!upstreamResponse.ok) {
608
+ sendJson(res, upstreamResponse.status, await readJsonResponse(upstreamResponse));
609
+ return { handled: true };
610
+ }
611
+
612
+ const mapper = new ResponsesStreamMapper({
613
+ responseId,
614
+ model: firstUpstreamRequest.model,
615
+ createdAt: Math.floor(Date.now() / 1000),
616
+ previousResponseId,
617
+ normalized,
618
+ config,
619
+ ...resolveReasoningStreamMode(config, firstUpstreamRequest),
620
+ holdToolItemEvents: true,
621
+ knownToolNames: chatToolNamesFromTools(firstUpstreamRequest.tools),
622
+ });
623
+ const searchWriter = webSearchSseWriter({
624
+ res,
625
+ mapper,
626
+ outputIndexBySearch: new Map(),
627
+ includeSources: shouldIncludeSearchSources(normalized),
628
+ });
629
+ const streamSearchItems = new Set();
630
+ const roundUsages = [];
631
+ const searches = [];
632
+ const openedPages = [];
633
+ let finalAnswerForced = false;
634
+ let roundEnd = null;
635
+
636
+ res.writeHead(200, {
637
+ 'content-type': 'text/event-stream; charset=utf-8',
638
+ 'cache-control': 'no-cache, no-transform',
639
+ connection: 'keep-alive',
640
+ 'x-accel-buffering': 'no',
641
+ });
642
+ writeSseEvent(res, mapper.createdEvent());
643
+ writeSseEvent(res, mapper.inProgressEvent());
644
+ const heartbeat = setInterval(() => {
645
+ writeSseEvent(res, mapper.inProgressEvent());
646
+ }, STREAM_HEARTBEAT_MS);
647
+
648
+ const endStream = () => {
649
+ writeSseDone(res);
650
+ res.end();
651
+ };
652
+ const failStream = (message) => {
653
+ for (const event of mapper.streamFailed(message)) writeSseEvent(res, event);
654
+ endStream();
655
+ return { handled: true };
656
+ };
657
+ const finishTurn = () => {
658
+ if (mapper.messageItem?.content?.length) {
659
+ annotateMessagePartWithWebCitations(mapper.messageItem.content[0], searches, openedPages);
660
+ }
661
+ const combinedUsage = combineUsage(roundUsages.concat([{ usage: mapper.pendingUsage }]));
662
+ for (const event of mapper.finalize(mapper.pendingFinishReason || 'stop', combinedUsage)) {
663
+ writeSseEvent(res, event);
664
+ }
665
+ endStream();
666
+ return { handled: true, chatRequest: currentChatRequest, assistantMessage: mapper.roundAssistantMessage() };
667
+ };
668
+ const relayRound = async () => {
669
+ roundEnd = null;
670
+ mapper.markRoundStart();
671
+ await relayChatCompletionsResponse({
672
+ upstreamResponse,
673
+ res,
674
+ passThrough: false,
675
+ writeHeaders: false,
676
+ endResponse: false,
677
+ onStreamChunk(event) {
678
+ if (event?.done) {
679
+ roundEnd = { eof: Boolean(event.eof) };
680
+ return;
681
+ }
682
+ for (const responseEvent of mapper.mapChatEvent(event)) {
683
+ writeSseEvent(res, responseEvent);
684
+ }
685
+ },
686
+ });
687
+ };
688
+
689
+ try {
690
+ await relayRound();
691
+ for (let round = 0; ; round += 1) {
692
+ if (clientSignal?.aborted) {
693
+ return { handled: true };
694
+ }
695
+ if (!mapper.pendingFinishReason && roundEnd?.eof) {
696
+ return failStream('upstream stream ended before completion');
697
+ }
698
+ mapper.expandParallelToolItems();
699
+ const roundMessage = mapper.roundAssistantMessage();
700
+ const commentaryCalls = bridgedCommentaryToolCallsFromMessage(roundMessage, currentChatRequest.tools);
701
+ for (const event of mapper.convertBridgedCommentaryItems()) writeSseEvent(res, event);
702
+ const completionLike = {
703
+ choices: [{ index: 0, message: roundMessage, finish_reason: mapper.pendingFinishReason || 'stop' }],
704
+ };
705
+ const routingCompletion = stripBridgedCommentaryFromCompletion(completionLike, currentChatRequest.tools);
706
+
707
+ if (finalAnswerForced) {
708
+ mapper.removeToolItems();
709
+ const reason = finalAnswerIncompleteReason(routingCompletion);
710
+ if (reason) {
711
+ const events = mapper.replaceBufferedAssistantText(gatewayIncompleteMessageContent(reason));
712
+ for (const event of events || []) writeSseEvent(res, event);
713
+ }
714
+ return finishTurn();
715
+ }
716
+
717
+ if (hasKnownExternalToolCalls(routingCompletion, currentChatRequest.tools)) {
718
+ const kept = knownExternalToolCallsCompletion(routingCompletion, currentChatRequest.tools);
719
+ const keptIds = new Set(
720
+ (kept.choices?.[0]?.message?.tool_calls || []).map((toolCall) => toolCall.id).filter(Boolean),
721
+ );
722
+ mapper.removeToolItems((item) => !keptIds.has(item.call_id));
723
+ return finishTurn();
724
+ }
725
+
726
+ const wantsInternalWeb = shouldContinueWebSearchLoop(routingCompletion);
727
+ const wantsInternalRound = wantsInternalWeb || commentaryCalls.length > 0;
728
+ const hasToolCallsRound = hasAnyToolCalls(routingCompletion);
729
+
730
+ if (!wantsInternalRound || round >= maxRounds) {
731
+ if (hasToolCallsRound && hasUnknownExternalToolCalls(routingCompletion, currentChatRequest.tools)) {
732
+ const unsupportedMessages = unhandledToolMessagesFromCompletion(routingCompletion, undefined, {
733
+ onlyUnknownExternal: true,
734
+ tools: currentChatRequest.tools,
735
+ });
736
+ currentChatRequest = toolCallsToFinalAnswerRequest({
737
+ ...currentChatRequest,
738
+ messages: currentChatRequest.messages.concat(unsupportedMessages),
739
+ });
740
+ } else if (wantsInternalRound || (!hasToolCallsRound && hasWebSearchContext(searches, openedPages) && !hasVisibleAssistantContent(routingCompletion))) {
741
+ currentChatRequest = toolCallsToFinalAnswerRequest(currentChatRequest);
742
+ } else {
743
+ return finishTurn();
744
+ }
745
+ finalAnswerForced = true;
746
+ roundUsages.push({ usage: mapper.pendingUsage });
747
+ mapper.removeToolItems();
748
+ for (const event of mapper.beginNextRound()) writeSseEvent(res, event);
749
+ mapper.holdVisibleTextUntilDone();
750
+ } else {
751
+ roundUsages.push({ usage: mapper.pendingUsage });
752
+ for (const event of mapper.beginNextRound()) writeSseEvent(res, event);
753
+ const controller = new AbortController();
754
+ const timeout = setTimeout(() => controller.abort(), webToolTimeoutMs(searchConfig));
755
+ let toolResult;
756
+ try {
757
+ toolResult = await executeWebSearchCalls({
758
+ completion: completionLike,
759
+ config: searchConfig,
760
+ signal: mergeAbortSignals([controller.signal, clientSignal]),
761
+ onSearchStart(search) {
762
+ if (search.action && search.auto) return;
763
+ streamSearchItems.add(search);
764
+ searchWriter.start(search);
765
+ },
766
+ onSearchDone(search) {
767
+ if (!streamSearchItems.has(search)) return;
768
+ searchWriter.done(search);
769
+ },
770
+ });
771
+ } finally {
772
+ clearTimeout(timeout);
773
+ }
774
+ searches.push(...toolResult.searches);
775
+ openedPages.push(...(toolResult.openedPages || []));
776
+ currentChatRequest = {
777
+ ...currentChatRequest,
778
+ messages: currentChatRequest.messages.concat(toolResult.messages, commentaryToolMessages(commentaryCalls)),
779
+ tool_choice:
780
+ currentChatRequest.tool_choice?.function?.name === INTERNAL_WEB_SEARCH_TOOL
781
+ ? 'auto'
782
+ : currentChatRequest.tool_choice,
783
+ };
784
+ }
785
+
786
+ if (clientSignal?.aborted) {
787
+ return { handled: true };
788
+ }
789
+ const upstreamRequest = buildUpstreamRequest(`web_search_stream_round_${round + 1}`);
790
+ upstreamResponse = await callChatCompletions({
791
+ baseUrl: config.upstreamBaseUrl,
792
+ apiKey: config.upstreamApiKey,
793
+ request: upstreamRequest,
794
+ timeoutMs: config.upstreamTimeoutMs,
795
+ signal: clientSignal,
796
+ });
797
+ if (!upstreamResponse.ok) {
798
+ const data = await readJsonResponse(upstreamResponse);
799
+ return failStream(data?.error?.message || `upstream error ${upstreamResponse.status}`);
800
+ }
801
+ await relayRound();
802
+ }
803
+ } catch (error) {
804
+ if (clientSignal?.aborted) {
805
+ return { handled: true };
806
+ }
807
+ throw error;
808
+ } finally {
809
+ clearInterval(heartbeat);
810
+ }
811
+ }
812
+
813
+ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
814
+ sessions ||= new SessionStore({
815
+ persistPath: config.sessionStoreEnabled === false ? '' : config.sessionStorePath,
816
+ maxSessions: config.sessionStoreMaxSessions,
817
+ maxBytes: config.sessionStoreMaxBytes,
818
+ });
613
819
  let modelCache = null;
614
820
 
615
821
  async function getModelList() {
@@ -647,18 +853,26 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
647
853
 
648
854
  const request = parsed.value;
649
855
  const normalized = normalizeResponsesRequest(request);
650
- const currentInputMessages = normalized.messages.slice();
651
856
  const previousResponseId = normalized.previous_response_id || request.previous_response_id || null;
652
857
  const conversationId = conversationIdFromRequest(request, normalized);
653
858
  const priorSession = previousResponseId ? sessions.get(previousResponseId) : conversationId ? sessions.getConversation(conversationId) : null;
654
- if (priorSession?.history?.length) {
655
- const priorMessages = historyMessagesFromSession(priorSession);
859
+ const priorMessages = historyMessagesFromSession(priorSession);
860
+ if (priorMessages.length) {
656
861
  normalized.messages = priorMessages.concat(normalized.messages);
657
862
  }
658
863
  normalized.messages = prependMissingAssistantToolMessages(normalized.messages, sessions);
864
+ normalized.messages = restoreAssistantReasoningContent(normalized.messages, sessions);
659
865
 
660
866
  const chatRequest = toChatCompletionsRequest(normalized);
661
867
  const useWebSearchEmulator = hasTavilyWebSearch(config, normalized);
868
+ if (!useWebSearchEmulator) {
869
+ const existingToolNames = new Set(chatToolNamesFromTools(chatRequest.tools));
870
+ const webShims = unavailableWebSearchToolShims(normalized.tools)
871
+ .filter((tool) => !existingToolNames.has(tool.function.name));
872
+ if (webShims.length) chatRequest.tools = [...(chatRequest.tools ?? []), ...webShims];
873
+ }
874
+ const clientAbort = clientAbortControllerFor(res);
875
+ const clientSignal = clientAbort.signal;
662
876
  let upstreamRequest = toProviderChatCompletionsRequest(chatRequest, config);
663
877
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || normalized.model;
664
878
 
@@ -672,119 +886,74 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
672
886
  }
673
887
 
674
888
  const responseId = generateId('resp');
675
- const nextSession = { id: responseId, history: [] };
676
-
677
- if (useWebSearchEmulator) {
678
- const streamWebSearch = Boolean(request.stream);
679
- let streamMapper = null;
680
- let streamSearchWriter = null;
681
- const streamSearchItems = new Set();
682
- if (streamWebSearch) {
683
- const createdAt = Math.floor(Date.now() / 1000);
684
- streamMapper = new ResponsesStreamMapper({
685
- responseId,
686
- model: upstreamRequest.model,
687
- createdAt,
688
- previousResponseId,
689
- normalized,
690
- emitReasoningSummary: true,
691
- emitReasoningText: false,
692
- });
693
- streamSearchWriter = webSearchSseWriter({
694
- res,
695
- mapper: streamMapper,
696
- outputIndexBySearch: new Map(),
697
- includeSources: shouldIncludeSearchSources(normalized),
698
- });
699
- res.writeHead(200, {
700
- 'content-type': 'text/event-stream; charset=utf-8',
701
- 'cache-control': 'no-cache, no-transform',
702
- connection: 'keep-alive',
703
- 'x-accel-buffering': 'no',
704
- });
705
- writeSseEvent(res, streamMapper.createdEvent());
706
- writeSseEvent(res, streamMapper.inProgressEvent());
889
+ const nextSession = { id: responseId, messages: [] };
890
+ const persistSession = shouldPersistSession(normalized, request, conversationId);
891
+ const persistTurn = (chatMessages, assistantMessage) => {
892
+ if (persistSession) {
893
+ nextSession.messages = sessionMessages(chatMessages, assistantMessage);
894
+ sessions.set(responseId, nextSession);
895
+ sessions.setConversation(conversationId, responseId);
896
+ } else {
897
+ sessions.rememberAssistantMessage(assistantMessage);
707
898
  }
899
+ };
708
900
 
901
+ if (useWebSearchEmulator && request.stream) {
902
+ const result = await runStreamingWebSearchTurn({
903
+ rawRequest: request,
904
+ normalized,
905
+ chatRequest,
906
+ config,
907
+ res,
908
+ responseId,
909
+ previousResponseId,
910
+ clientSignal,
911
+ });
912
+ if (result.handled) {
913
+ if (result.assistantMessage) persistTurn(result.chatRequest.messages, result.assistantMessage);
914
+ return;
915
+ }
916
+ } else if (useWebSearchEmulator) {
709
917
  const loop = await runWebSearchChatLoop({
918
+ rawRequest: request,
710
919
  normalized,
711
920
  chatRequest,
712
921
  config,
713
- onSearchStart: streamWebSearch
714
- ? (search) => {
715
- if (search.action && search.auto) return;
716
- streamSearchItems.add(search);
717
- streamSearchWriter.start(search);
718
- }
719
- : undefined,
720
- onSearchDone: streamWebSearch
721
- ? (search) => {
722
- if (!streamSearchItems.has(search)) return;
723
- streamSearchWriter.done(search);
724
- }
725
- : undefined,
922
+ clientSignal,
726
923
  });
727
924
  if (!loop.ok) {
728
- if (streamWebSearch) {
729
- writeSseEvent(res, {
730
- type: 'response.failed',
731
- sequence_number: streamMapper.nextSequence(),
732
- response: streamMapper.response('failed'),
733
- });
734
- writeSseDone(res);
735
- res.end();
736
- return;
737
- }
738
925
  sendJson(res, loop.status || 500, loop.data);
739
926
  return;
740
927
  }
741
928
  upstreamRequest = toProviderChatCompletionsRequest(loop.chatRequest, config);
742
929
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || normalized.model;
743
- const model = upstreamRequest.model;
930
+ logDebugPayload(config, upstreamRequest, {
931
+ stage: 'web_search_compatibility_payload',
932
+ rawRequest: request,
933
+ normalized,
934
+ chatRequest: loop.chatRequest,
935
+ });
744
936
  const payload = applyWebSearchOutputCompatibility(convertChatCompletionToResponses({
745
937
  completion: loop.completion,
746
- model,
938
+ model: upstreamRequest.model,
747
939
  previousResponseId,
748
940
  normalized,
749
941
  responseId,
750
942
  config,
751
943
  }), loop.searches, normalized, loop.openedPages);
752
- if (loop.incompleteReason) markPayloadIncomplete(payload, loop.incompleteReason);
753
- const assistantMessage = assistantMessageFromResponseOutput(payload.output);
754
-
755
- nextSession.history.push({
756
- request: normalized,
757
- chatRequest: loop.chatRequest,
758
- upstreamRequest,
759
- inputMessages: currentInputMessages,
760
- historyMessages: loop.chatRequest.messages.concat(assistantMessage),
761
- assistantMessage,
762
- createdAt: Date.now(),
763
- });
764
- sessions.set(responseId, nextSession);
765
- sessions.setConversation(conversationId, responseId);
766
-
767
- if (!streamWebSearch) {
768
- sendJson(res, 200, payload);
769
- return;
770
- }
771
-
772
- for (const event of outputEventsFromPayload(payload, streamMapper, { skipTypes: new Set(['web_search_call']) })) {
773
- writeSseEvent(res, event);
774
- }
775
- writeSseEvent(res, completedEventFromPayload(payload, streamMapper));
776
- writeSseDone(res);
777
- res.end();
944
+ persistTurn(loop.chatRequest.messages, assistantMessageFromResponseOutput(payload.output));
945
+ sendJson(res, 200, payload);
778
946
  return;
779
947
  }
780
948
 
781
- logDebugPayload(config, upstreamRequest);
949
+ logDebugPayload(config, upstreamRequest, { rawRequest: request, normalized, chatRequest });
782
950
 
783
951
  const upstreamResponse = await callChatCompletions({
784
952
  baseUrl: config.upstreamBaseUrl,
785
953
  apiKey: config.upstreamApiKey,
786
954
  request: upstreamRequest,
787
955
  timeoutMs: config.upstreamTimeoutMs,
956
+ signal: clientSignal,
788
957
  });
789
958
 
790
959
  const model = upstreamRequest.model;
@@ -805,16 +974,7 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
805
974
  responseId,
806
975
  config,
807
976
  });
808
- nextSession.history.push({
809
- request: normalized,
810
- chatRequest,
811
- upstreamRequest,
812
- inputMessages: currentInputMessages,
813
- assistantMessage: assistantMessageFromResponseOutput(payload.output),
814
- createdAt: Date.now(),
815
- });
816
- sessions.set(responseId, nextSession);
817
- sessions.setConversation(conversationId, responseId);
977
+ persistTurn(chatRequest.messages, assistantMessageFromResponseOutput(payload.output));
818
978
  sendJson(res, 200, payload);
819
979
  return;
820
980
  }
@@ -833,48 +993,46 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
833
993
  previousResponseId,
834
994
  normalized,
835
995
  config,
836
- ...(() => {
837
- const reasoningMode = resolveReasoningStreamMode(config, upstreamRequest);
838
- return {
839
- bufferOutputUntilDone: upstreamRequest?.thinking?.type === 'enabled',
840
- ...reasoningMode,
841
- };
842
- })(),
996
+ ...resolveReasoningStreamMode(config, upstreamRequest),
997
+ knownToolNames: chatToolNamesFromTools(upstreamRequest.tools),
843
998
  });
844
999
  let doneSent = false;
845
1000
  const writeResponsesDone = () => {
846
1001
  if (doneSent) return;
847
1002
  doneSent = true;
848
- res.write(serializeResponsesSseEvent({ done: true }));
1003
+ writeSseEvent(res, { done: true });
849
1004
  };
850
1005
 
851
- res.write(serializeResponsesSseEvent(mapper.createdEvent()));
852
- res.write(serializeResponsesSseEvent(mapper.inProgressEvent()));
1006
+ writeSseEvent(res, mapper.createdEvent());
1007
+ writeSseEvent(res, mapper.inProgressEvent());
1008
+ let lastEventWriteAt = Date.now();
853
1009
 
854
- await relayChatCompletionsResponse({
855
- upstreamResponse,
856
- res,
857
- passThrough: false,
858
- writeHeaders: false,
859
- onStreamChunk(event) {
860
- const events = mapper.mapChatEvent(event);
861
- for (const responseEvent of events) {
862
- res.write(serializeResponsesSseEvent(responseEvent));
863
- }
864
- if (event?.done) writeResponsesDone();
865
- },
866
- });
1010
+ try {
1011
+ await relayChatCompletionsResponse({
1012
+ upstreamResponse,
1013
+ res,
1014
+ passThrough: false,
1015
+ writeHeaders: false,
1016
+ onStreamChunk(event) {
1017
+ const events = mapper.mapChatEvent(event);
1018
+ for (const responseEvent of events) {
1019
+ writeSseEvent(res, responseEvent);
1020
+ }
1021
+ if (events.length) {
1022
+ lastEventWriteAt = Date.now();
1023
+ } else if (!event?.done && Date.now() - lastEventWriteAt >= STREAM_HEARTBEAT_MS) {
1024
+ writeSseEvent(res, mapper.inProgressEvent());
1025
+ lastEventWriteAt = Date.now();
1026
+ }
1027
+ if (event?.done) writeResponsesDone();
1028
+ },
1029
+ });
1030
+ } catch (error) {
1031
+ if (clientSignal.aborted) return;
1032
+ throw error;
1033
+ }
867
1034
 
868
- nextSession.history.push({
869
- request: normalized,
870
- chatRequest,
871
- upstreamRequest,
872
- inputMessages: currentInputMessages,
873
- assistantMessage: mapper.assistantMessage(),
874
- createdAt: Date.now(),
875
- });
876
- sessions.set(responseId, nextSession);
877
- sessions.setConversation(conversationId, responseId);
1035
+ persistTurn(chatRequest.messages, mapper.assistantMessage());
878
1036
  }
879
1037
 
880
1038
  return http.createServer(async (req, res) => {
@@ -917,6 +1075,7 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
917
1075
  apiKey: config.upstreamApiKey,
918
1076
  request: upstreamRequest,
919
1077
  timeoutMs: config.upstreamTimeoutMs,
1078
+ signal: clientAbortControllerFor(res).signal,
920
1079
  });
921
1080
  await relayChatCompletionsResponse({ upstreamResponse, res, passThrough: true });
922
1081
  return;
@@ -924,6 +1083,7 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
924
1083
 
925
1084
  sendJson(res, 404, { error: { message: 'Not found' } });
926
1085
  } catch (error) {
1086
+ if (res.destroyed) return;
927
1087
  if (res.headersSent) {
928
1088
  if (!res.writableEnded) {
929
1089
  res.write(serializeResponsesSseEvent({