@galaxy-yearn/codex-deepseek-gateway 0.1.7 → 0.2.1

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
- import { SessionStore } from './session-store.js';
24
+ import { ReasoningCache } from './reasoning-cache.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,
@@ -52,6 +62,14 @@ function getRequestPath(req) {
52
62
  return url.pathname;
53
63
  }
54
64
 
65
+ function codexRequestKind(req) {
66
+ const value = req.headers['x-codex-turn-metadata'];
67
+ if (typeof value !== 'string') return '';
68
+ const parsed = safeJsonParse(value);
69
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== 'object') return '';
70
+ return String(parsed.value.request_kind || '');
71
+ }
72
+
55
73
  function isAuthorized(req, config) {
56
74
  if (!config.proxyApiKey) return true;
57
75
  const authorization = req.headers.authorization || '';
@@ -59,34 +77,7 @@ function isAuthorized(req, config) {
59
77
  return bearerToken === config.proxyApiKey || req.headers['x-api-key'] === config.proxyApiKey;
60
78
  }
61
79
 
62
- function conversationIdFromRequest(request, normalized) {
63
- const conversation = normalized.conversation ?? request.conversation;
64
- if (typeof conversation === 'string') return conversation;
65
- if (conversation && typeof conversation === 'object') return conversation.id || conversation.conversation_id || null;
66
- return request.conversation_id || null;
67
- }
68
-
69
- 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
- }
86
- return messages;
87
- }
88
-
89
- function prependMissingAssistantToolMessages(messages, sessions) {
80
+ function prependMissingAssistantToolMessages(messages, reasoningCache) {
90
81
  const existingToolCallIds = new Set();
91
82
  const missingToolOutputIds = [];
92
83
  for (const message of messages) {
@@ -106,7 +97,7 @@ function prependMissingAssistantToolMessages(messages, sessions) {
106
97
  const inserted = new Set(existingToolCallIds);
107
98
  for (const callId of missingToolOutputIds) {
108
99
  if (inserted.has(callId)) continue;
109
- const assistantMessage = sessions.getAssistantMessageForToolCall(callId);
100
+ const assistantMessage = reasoningCache.getAssistantMessageForToolCall(callId);
110
101
  if (!assistantMessage) continue;
111
102
  prefix.push(assistantMessage);
112
103
  for (const id of extractToolCallIdsFromMessages([assistantMessage])) inserted.add(id);
@@ -114,6 +105,22 @@ function prependMissingAssistantToolMessages(messages, sessions) {
114
105
  return prefix.length ? prefix.concat(messages) : messages;
115
106
  }
116
107
 
108
+ function restoreAssistantReasoningContent(messages, reasoningCache) {
109
+ return messages.map((message) => {
110
+ if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls) || !message.tool_calls.length) {
111
+ return message;
112
+ }
113
+ if (typeof message.reasoning_content === 'string' && message.reasoning_content) return message;
114
+ for (const toolCall of message.tool_calls) {
115
+ const stored = reasoningCache.getAssistantMessageForToolCall(toolCall?.id);
116
+ if (typeof stored?.reasoning_content === 'string' && stored.reasoning_content) {
117
+ return { ...message, reasoning_content: stored.reasoning_content };
118
+ }
119
+ }
120
+ return message;
121
+ });
122
+ }
123
+
117
124
  function summarizeMessage(message, index) {
118
125
  return {
119
126
  index,
@@ -126,16 +133,66 @@ function summarizeMessage(message, index) {
126
133
  };
127
134
  }
128
135
 
129
- function logDebugPayload(config, request) {
136
+ function summarizeTool(tool, index) {
137
+ if (!tool || typeof tool !== 'object') {
138
+ return { index, type: typeof tool };
139
+ }
140
+ const fn = tool.type === 'function' && tool.function && typeof tool.function === 'object'
141
+ ? tool.function
142
+ : null;
143
+ return {
144
+ index,
145
+ type: tool.type,
146
+ name: fn?.name || tool.name || tool.tool_name || tool.server_label,
147
+ namespace: fn?.namespace || tool.namespace,
148
+ child_tools: Array.isArray(tool.tools) ? tool.tools.length : undefined,
149
+ has_parameters: Boolean(fn?.parameters || tool.parameters || tool.input_schema),
150
+ };
151
+ }
152
+
153
+ function summarizeTools(tools) {
154
+ return Array.isArray(tools) ? tools.map(summarizeTool) : [];
155
+ }
156
+
157
+ const DEBUG_PAYLOAD_LOG_MAX_BYTES = 5 * 1024 * 1024;
158
+
159
+ function rotateDebugPayloadLog(logPath, maxBytes) {
160
+ try {
161
+ if (statSync(logPath).size < maxBytes) return;
162
+ rmSync(`${logPath}.1`, { force: true });
163
+ renameSync(logPath, `${logPath}.1`);
164
+ } catch {
165
+ }
166
+ }
167
+
168
+ function writeDebugPayloadLine(config, line) {
169
+ process.stderr.write(line);
170
+ if (!config.debugPayloadLogPath) return;
171
+ try {
172
+ const logPath = resolve(config.debugPayloadLogPath);
173
+ rotateDebugPayloadLog(logPath, config.debugPayloadLogMaxBytes || DEBUG_PAYLOAD_LOG_MAX_BYTES);
174
+ appendFileSync(logPath, line, 'utf8');
175
+ } catch (error) {
176
+ process.stderr.write(`[codex-deepseek-gateway] failed to write debug payload log: ${error.message || error}\n`);
177
+ }
178
+ }
179
+
180
+ function logDebugPayload(config, request, context = {}) {
130
181
  if (!config.debugPayload) return;
131
182
  const summary = {
183
+ stage: context.stage || 'upstream',
132
184
  model: request.model,
133
185
  stream: request.stream,
134
186
  thinking: request.thinking,
135
187
  reasoning_effort: request.reasoning_effort,
188
+ codex_tools: summarizeTools(context.rawRequest?.tools),
189
+ normalized_tools: summarizeTools(context.normalized?.tools),
190
+ chat_tools: summarizeTools(context.chatRequest?.tools),
191
+ upstream_tools: summarizeTools(request.tools),
192
+ tool_choice: request.tool_choice,
136
193
  messages: Array.isArray(request.messages) ? request.messages.map(summarizeMessage) : [],
137
194
  };
138
- process.stderr.write(`[codex-deepseek-gateway] upstream request ${JSON.stringify(summary)}\n`);
195
+ writeDebugPayloadLine(config, `[codex-deepseek-gateway] upstream request ${JSON.stringify(summary)}\n`);
139
196
  }
140
197
 
141
198
  function resolveReasoningStreamMode(config, upstreamRequest) {
@@ -168,29 +225,26 @@ function webToolTimeoutMs(config = {}) {
168
225
  return Math.max(tavilyTimeout, firecrawlTimeout);
169
226
  }
170
227
 
171
- function addUsage(left, right) {
172
- if (!left) return right || null;
173
- if (!right) return left;
228
+ function combineUsage(completions) {
229
+ const usages = completions.map((completion) => completion?.usage).filter(Boolean);
230
+ if (!usages.length) return null;
231
+ const last = usages[usages.length - 1];
232
+ const sum = (pick) => usages.reduce((total, usage) => total + (Number(pick(usage)) || 0), 0);
233
+ const promptTokens = Number(last.prompt_tokens) || 0;
234
+ const completionTokens = sum((usage) => usage.completion_tokens);
174
235
  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),
236
+ prompt_tokens: promptTokens,
237
+ completion_tokens: completionTokens,
238
+ total_tokens: promptTokens + completionTokens,
179
239
  prompt_tokens_details: {
180
- cached_tokens: (left.prompt_tokens_details?.cached_tokens || 0) + (right.prompt_tokens_details?.cached_tokens || 0),
240
+ cached_tokens: last.prompt_tokens_details?.cached_tokens ?? last.prompt_cache_hit_tokens ?? 0,
181
241
  },
182
242
  completion_tokens_details: {
183
- reasoning_tokens:
184
- (left.completion_tokens_details?.reasoning_tokens || 0) +
185
- (right.completion_tokens_details?.reasoning_tokens || 0),
243
+ reasoning_tokens: sum((usage) => usage.completion_tokens_details?.reasoning_tokens ?? usage.reasoning_tokens),
186
244
  },
187
245
  };
188
246
  }
189
247
 
190
- function combineUsage(completions) {
191
- return completions.reduce((usage, completion) => addUsage(usage, completion?.usage), null);
192
- }
193
-
194
248
  function cloneCompletionWithUsage(completion, usage) {
195
249
  if (!usage) return completion;
196
250
  return {
@@ -202,16 +256,14 @@ function cloneCompletionWithUsage(completion, usage) {
202
256
  function toolCallsToFinalAnswerRequest(request) {
203
257
  return {
204
258
  ...request,
205
- tool_choice: 'none',
259
+ tool_choice: undefined,
206
260
  tools: undefined,
207
261
  messages: removeWebSearchInstructions(request.messages).concat({
208
262
  role: 'user',
209
263
  content: [
210
- 'Web tools are not available now.',
211
- 'Use the completed tool results already provided in this conversation.',
212
- 'Give the final answer now as visible assistant message content.',
213
- 'Do not write tool calls, DSML, XML, or JSON tool-call blocks in the answer.',
214
- 'If the available tool results are incomplete, say what is missing and answer only what the provided context supports.',
264
+ 'Web tools are now unavailable.',
265
+ 'Answer now using only completed tool results as visible assistant text, without tool calls or tool-call markup.',
266
+ 'If results are incomplete, state what is missing and do not infer beyond them.',
215
267
  ].join(' '),
216
268
  }),
217
269
  };
@@ -245,6 +297,12 @@ function hasWebSearchContext(searches, openedPages) {
245
297
  return searches.length > 0 || openedPages.length > 0;
246
298
  }
247
299
 
300
+ function gatewayIncompleteMessageContent(reason) {
301
+ return reason === 'pseudo_tool_call_text_after_web_limit'
302
+ ? 'Gateway incomplete: after web tools were disabled, the model wrote a tool call as text instead of producing a final answer.'
303
+ : 'Gateway incomplete: the model did not produce visible assistant content after web tool results and a final-answer request.';
304
+ }
305
+
248
306
  function completionWithGatewayIncompleteMessage(completion, reason = 'no_visible_assistant_content') {
249
307
  const next = completion == null ? {} : JSON.parse(JSON.stringify(completion));
250
308
  if (!Array.isArray(next.choices) || !next.choices.length) {
@@ -252,9 +310,7 @@ function completionWithGatewayIncompleteMessage(completion, reason = 'no_visible
252
310
  }
253
311
  const choice = next.choices[0];
254
312
  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.';
313
+ const content = gatewayIncompleteMessageContent(reason);
258
314
  choice.message = {
259
315
  role: 'assistant',
260
316
  content,
@@ -276,24 +332,62 @@ function completionWithoutToolCalls(completion) {
276
332
  return next;
277
333
  }
278
334
 
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 }) {
335
+ async function callUpstreamJson({ upstreamRequest, config, signal }) {
286
336
  const response = await callChatCompletions({
287
337
  baseUrl: config.upstreamBaseUrl,
288
338
  apiKey: config.upstreamApiKey,
289
339
  request: upstreamRequest,
290
340
  timeoutMs: config.upstreamTimeoutMs,
341
+ signal,
291
342
  });
292
343
  const data = await readJsonResponse(response);
293
- return { response, data };
344
+ return {
345
+ response,
346
+ data: resolveEmittedToolCallNamesInCompletion(
347
+ expandParallelToolCallsInCompletion(data),
348
+ chatToolNamesFromTools(upstreamRequest.tools),
349
+ ),
350
+ };
351
+ }
352
+
353
+ function clientAbortControllerFor(res) {
354
+ const controller = new AbortController();
355
+ res.on('close', () => {
356
+ if (!res.writableEnded) controller.abort();
357
+ });
358
+ return controller;
359
+ }
360
+
361
+ function mergeAbortSignals(signals) {
362
+ const active = signals.filter(Boolean);
363
+ if (!active.length) return undefined;
364
+ if (active.length === 1) return active[0];
365
+ return AbortSignal.any(active);
294
366
  }
295
367
 
296
- async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchStart, onSearchDone }) {
368
+ function commentaryToolMessages(commentaryCalls) {
369
+ return (Array.isArray(commentaryCalls) ? commentaryCalls : []).map((toolCall) => ({
370
+ role: 'tool',
371
+ tool_call_id: toolCall.id || generateId('call'),
372
+ content: 'Delivered to the user.',
373
+ }));
374
+ }
375
+
376
+ function restoreCommentaryToolCalls(completion, commentaryCalls) {
377
+ if (!Array.isArray(commentaryCalls) || !commentaryCalls.length) return completion;
378
+ const choice = Array.isArray(completion?.choices) ? completion.choices[0] : null;
379
+ if (!choice?.message) return completion;
380
+ const existing = Array.isArray(choice.message.tool_calls) ? choice.message.tool_calls : [];
381
+ return {
382
+ ...completion,
383
+ choices: [
384
+ { ...choice, message: { ...choice.message, tool_calls: [...commentaryCalls, ...existing] } },
385
+ ...completion.choices.slice(1),
386
+ ],
387
+ };
388
+ }
389
+
390
+ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, config, clientSignal, onSearchStart, onSearchDone }) {
297
391
  const effectiveChatRequest = { ...chatRequest, normalized };
298
392
  const state = prepareWebSearchRequest({ normalized, chatRequest: effectiveChatRequest, config });
299
393
  const searchConfig = state.config || config;
@@ -310,9 +404,16 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
310
404
  currentChatRequest = toolCallsToFinalAnswerRequest(baseRequest);
311
405
  const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
312
406
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
407
+ logDebugPayload(config, upstreamRequest, {
408
+ stage: 'web_search_final_answer',
409
+ rawRequest,
410
+ normalized,
411
+ chatRequest: currentChatRequest,
412
+ });
313
413
  const { response, data } = await callUpstreamJson({
314
414
  upstreamRequest: disableStreaming(upstreamRequest),
315
415
  config,
416
+ signal: clientSignal,
316
417
  });
317
418
  finalResponse = response;
318
419
  if (!response.ok) return { ok: false, status: response.status, data };
@@ -329,17 +430,30 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
329
430
  for (let round = 0; round <= maxRounds + 1; round += 1) {
330
431
  const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
331
432
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
433
+ logDebugPayload(config, upstreamRequest, {
434
+ stage: `web_search_round_${round}`,
435
+ rawRequest,
436
+ normalized,
437
+ chatRequest: currentChatRequest,
438
+ });
332
439
  const { response, data } = await callUpstreamJson({
333
440
  upstreamRequest: disableStreaming(upstreamRequest),
334
441
  config,
442
+ signal: clientSignal,
335
443
  });
336
444
  finalResponse = response;
337
445
  if (!response.ok) return { ok: false, status: response.status, data };
338
446
  completions.push(data);
339
447
  finalCompletion = data;
340
-
341
- if (state.enabled && hasKnownExternalToolCalls(data, currentChatRequest.tools)) {
342
- finalCompletion = knownExternalToolCallsCompletion(data, currentChatRequest.tools);
448
+ const roundChatMessage = Array.isArray(data?.choices) ? data.choices[0]?.message : null;
449
+ const commentaryCalls = bridgedCommentaryToolCallsFromMessage(roundChatMessage, currentChatRequest.tools);
450
+ const routingData = stripBridgedCommentaryFromCompletion(data, currentChatRequest.tools);
451
+
452
+ if (state.enabled && hasKnownExternalToolCalls(routingData, currentChatRequest.tools)) {
453
+ finalCompletion = restoreCommentaryToolCalls(
454
+ knownExternalToolCallsCompletion(routingData, currentChatRequest.tools),
455
+ commentaryCalls,
456
+ );
343
457
  completions[completions.length - 1] = finalCompletion;
344
458
  break;
345
459
  }
@@ -348,12 +462,13 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
348
462
  break;
349
463
  }
350
464
 
351
- const wantsInternalWeb = shouldContinueWebSearchLoop(data);
352
- const hasToolCalls = hasAnyToolCalls(data);
465
+ const wantsInternalWeb = shouldContinueWebSearchLoop(routingData);
466
+ const wantsInternalRound = wantsInternalWeb || commentaryCalls.length > 0;
467
+ const hasToolCalls = hasAnyToolCalls(routingData);
353
468
 
354
- if (!wantsInternalWeb || round >= maxRounds) {
355
- if (hasToolCalls && hasUnknownExternalToolCalls(data, currentChatRequest.tools)) {
356
- const unsupportedMessages = unhandledToolMessagesFromCompletion(data, undefined, {
469
+ if (!wantsInternalRound || round >= maxRounds) {
470
+ if (hasToolCalls && hasUnknownExternalToolCalls(routingData, currentChatRequest.tools)) {
471
+ const unsupportedMessages = unhandledToolMessagesFromCompletion(routingData, undefined, {
357
472
  onlyUnknownExternal: true,
358
473
  tools: currentChatRequest.tools,
359
474
  });
@@ -365,7 +480,7 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
365
480
  break;
366
481
  }
367
482
 
368
- if (wantsInternalWeb || (!hasToolCalls && hasWebSearchContext(searches, openedPages) && !hasVisibleAssistantContent(data))) {
483
+ if (wantsInternalRound || (!hasToolCalls && hasWebSearchContext(searches, openedPages) && !hasVisibleAssistantContent(routingData))) {
369
484
  const finalAnswerError = await requestFinalAnswer(currentChatRequest);
370
485
  if (finalAnswerError) return finalAnswerError;
371
486
  break;
@@ -380,7 +495,7 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
380
495
  toolResult = await executeWebSearchCalls({
381
496
  completion: data,
382
497
  config: searchConfig,
383
- signal: controller.signal,
498
+ signal: mergeAbortSignals([controller.signal, clientSignal]),
384
499
  onSearchStart,
385
500
  onSearchDone,
386
501
  });
@@ -391,7 +506,7 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
391
506
  openedPages.push(...(toolResult.openedPages || []));
392
507
  currentChatRequest = {
393
508
  ...currentChatRequest,
394
- messages: currentChatRequest.messages.concat(toolResult.messages),
509
+ messages: currentChatRequest.messages.concat(toolResult.messages, commentaryToolMessages(commentaryCalls)),
395
510
  tool_choice:
396
511
  currentChatRequest.tool_choice?.function?.name === INTERNAL_WEB_SEARCH_TOOL
397
512
  ? 'auto'
@@ -410,170 +525,16 @@ async function runWebSearchChatLoop({ normalized, chatRequest, config, onSearchS
410
525
  };
411
526
  }
412
527
 
413
- const REPLAY_REASONING_DELTA_CHARS = 1200;
528
+ const STREAM_HEARTBEAT_MS = 10000;
414
529
 
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
- };
530
+ function streamHeartbeatMs(config) {
531
+ const value = Number(config?.streamHeartbeatMs);
532
+ if (Number.isFinite(value) && value > 0) return value;
533
+ return STREAM_HEARTBEAT_MS;
574
534
  }
575
535
 
576
536
  function writeSseEvent(res, event) {
537
+ if (res.writableEnded || res.destroyed) return;
577
538
  res.write(serializeResponsesSseEvent(event));
578
539
  }
579
540
 
@@ -609,7 +570,246 @@ function webSearchSseWriter({ res, mapper, outputIndexBySearch, includeSources =
609
570
  };
610
571
  }
611
572
 
612
- export function createProxyServer({ config = loadConfig(), sessions = new SessionStore() } = {}) {
573
+ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest, config, res, responseId, clientSignal }) {
574
+ const effectiveChatRequest = { ...chatRequest, normalized };
575
+ const state = prepareWebSearchRequest({ normalized, chatRequest: effectiveChatRequest, config });
576
+ if (!state.enabled) return { handled: false };
577
+
578
+ const searchConfig = state.config || config;
579
+ const maxRounds = maxWebSearchRounds(searchConfig);
580
+ let currentChatRequest = state.chatRequest;
581
+
582
+ const buildUpstreamRequest = (stage) => {
583
+ const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
584
+ if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
585
+ logDebugPayload(config, upstreamRequest, { stage, rawRequest, normalized, chatRequest: currentChatRequest });
586
+ return upstreamRequest;
587
+ };
588
+
589
+ const firstUpstreamRequest = buildUpstreamRequest('web_search_stream_round_0');
590
+ let upstreamResponse = await callChatCompletions({
591
+ baseUrl: config.upstreamBaseUrl,
592
+ apiKey: config.upstreamApiKey,
593
+ request: firstUpstreamRequest,
594
+ timeoutMs: config.upstreamTimeoutMs,
595
+ signal: clientSignal,
596
+ });
597
+ if (!upstreamResponse.ok) {
598
+ sendJson(res, upstreamResponse.status, await readJsonResponse(upstreamResponse));
599
+ return { handled: true };
600
+ }
601
+
602
+ const mapper = new ResponsesStreamMapper({
603
+ responseId,
604
+ model: firstUpstreamRequest.model,
605
+ createdAt: Math.floor(Date.now() / 1000),
606
+ normalized,
607
+ config,
608
+ ...resolveReasoningStreamMode(config, firstUpstreamRequest),
609
+ holdToolItemEvents: true,
610
+ knownToolNames: chatToolNamesFromTools(firstUpstreamRequest.tools),
611
+ });
612
+ const searchWriter = webSearchSseWriter({
613
+ res,
614
+ mapper,
615
+ outputIndexBySearch: new Map(),
616
+ includeSources: shouldIncludeSearchSources(normalized),
617
+ });
618
+ const streamSearchItems = new Set();
619
+ const roundUsages = [];
620
+ const searches = [];
621
+ const openedPages = [];
622
+ let finalAnswerForced = false;
623
+ let roundEnd = null;
624
+
625
+ res.writeHead(200, {
626
+ 'content-type': 'text/event-stream; charset=utf-8',
627
+ 'cache-control': 'no-cache, no-transform',
628
+ connection: 'keep-alive',
629
+ 'x-accel-buffering': 'no',
630
+ });
631
+ writeSseEvent(res, mapper.createdEvent());
632
+ writeSseEvent(res, mapper.inProgressEvent());
633
+ const heartbeat = setInterval(() => {
634
+ writeSseEvent(res, mapper.inProgressEvent());
635
+ }, STREAM_HEARTBEAT_MS);
636
+
637
+ const endStream = () => {
638
+ writeSseDone(res);
639
+ res.end();
640
+ };
641
+ const failStream = (message) => {
642
+ for (const event of mapper.streamFailed(message)) writeSseEvent(res, event);
643
+ endStream();
644
+ return { handled: true };
645
+ };
646
+ const finishTurn = () => {
647
+ if (mapper.messageItem?.content?.length) {
648
+ annotateMessagePartWithWebCitations(mapper.messageItem.content[0], searches, openedPages);
649
+ }
650
+ const combinedUsage = combineUsage(roundUsages.concat([{ usage: mapper.pendingUsage }]));
651
+ for (const event of mapper.finalize(mapper.pendingFinishReason || 'stop', combinedUsage)) {
652
+ writeSseEvent(res, event);
653
+ }
654
+ endStream();
655
+ return {
656
+ handled: true,
657
+ chatRequest: currentChatRequest,
658
+ assistantMessage: mapper.terminalStatus === 'completed' ? mapper.roundAssistantMessage() : null,
659
+ };
660
+ };
661
+ const relayRound = async () => {
662
+ roundEnd = null;
663
+ mapper.markRoundStart();
664
+ await relayChatCompletionsResponse({
665
+ upstreamResponse,
666
+ res,
667
+ passThrough: false,
668
+ writeHeaders: false,
669
+ endResponse: false,
670
+ onStreamChunk(event) {
671
+ if (event?.done) {
672
+ roundEnd = { eof: Boolean(event.eof) };
673
+ return;
674
+ }
675
+ for (const responseEvent of mapper.mapChatEvent(event)) {
676
+ writeSseEvent(res, responseEvent);
677
+ }
678
+ },
679
+ });
680
+ };
681
+
682
+ try {
683
+ await relayRound();
684
+ for (let round = 0; ; round += 1) {
685
+ if (clientSignal?.aborted) {
686
+ return { handled: true };
687
+ }
688
+ if (!mapper.pendingFinishReason && roundEnd?.eof) {
689
+ return failStream('upstream stream ended before completion');
690
+ }
691
+ mapper.expandParallelToolItems();
692
+ const roundMessage = mapper.roundAssistantMessage();
693
+ const commentaryCalls = bridgedCommentaryToolCallsFromMessage(roundMessage, currentChatRequest.tools);
694
+ for (const event of mapper.convertBridgedCommentaryItems()) writeSseEvent(res, event);
695
+ const completionLike = {
696
+ choices: [{ index: 0, message: roundMessage, finish_reason: mapper.pendingFinishReason || 'stop' }],
697
+ };
698
+ const routingCompletion = stripBridgedCommentaryFromCompletion(completionLike, currentChatRequest.tools);
699
+
700
+ if (finalAnswerForced) {
701
+ mapper.removeToolItems();
702
+ const reason = finalAnswerIncompleteReason(routingCompletion);
703
+ if (reason) {
704
+ const events = mapper.replaceBufferedAssistantText(gatewayIncompleteMessageContent(reason));
705
+ for (const event of events || []) writeSseEvent(res, event);
706
+ }
707
+ return finishTurn();
708
+ }
709
+
710
+ if (hasKnownExternalToolCalls(routingCompletion, currentChatRequest.tools)) {
711
+ const kept = knownExternalToolCallsCompletion(routingCompletion, currentChatRequest.tools);
712
+ const keptIds = new Set(
713
+ (kept.choices?.[0]?.message?.tool_calls || []).map((toolCall) => toolCall.id).filter(Boolean),
714
+ );
715
+ mapper.removeToolItems((item) => !keptIds.has(item.call_id));
716
+ return finishTurn();
717
+ }
718
+
719
+ const wantsInternalWeb = shouldContinueWebSearchLoop(routingCompletion);
720
+ const wantsInternalRound = wantsInternalWeb || commentaryCalls.length > 0;
721
+ const hasToolCallsRound = hasAnyToolCalls(routingCompletion);
722
+
723
+ if (!wantsInternalRound || round >= maxRounds) {
724
+ if (hasToolCallsRound && hasUnknownExternalToolCalls(routingCompletion, currentChatRequest.tools)) {
725
+ const unsupportedMessages = unhandledToolMessagesFromCompletion(routingCompletion, undefined, {
726
+ onlyUnknownExternal: true,
727
+ tools: currentChatRequest.tools,
728
+ });
729
+ currentChatRequest = toolCallsToFinalAnswerRequest({
730
+ ...currentChatRequest,
731
+ messages: currentChatRequest.messages.concat(unsupportedMessages),
732
+ });
733
+ } else if (wantsInternalRound || (!hasToolCallsRound && hasWebSearchContext(searches, openedPages) && !hasVisibleAssistantContent(routingCompletion))) {
734
+ currentChatRequest = toolCallsToFinalAnswerRequest(currentChatRequest);
735
+ } else {
736
+ return finishTurn();
737
+ }
738
+ finalAnswerForced = true;
739
+ roundUsages.push({ usage: mapper.pendingUsage });
740
+ mapper.removeToolItems();
741
+ for (const event of mapper.beginNextRound()) writeSseEvent(res, event);
742
+ mapper.holdVisibleTextUntilDone();
743
+ } else {
744
+ roundUsages.push({ usage: mapper.pendingUsage });
745
+ for (const event of mapper.beginNextRound()) writeSseEvent(res, event);
746
+ const controller = new AbortController();
747
+ const timeout = setTimeout(() => controller.abort(), webToolTimeoutMs(searchConfig));
748
+ let toolResult;
749
+ try {
750
+ toolResult = await executeWebSearchCalls({
751
+ completion: completionLike,
752
+ config: searchConfig,
753
+ signal: mergeAbortSignals([controller.signal, clientSignal]),
754
+ onSearchStart(search) {
755
+ if (search.action && search.auto) return;
756
+ streamSearchItems.add(search);
757
+ searchWriter.start(search);
758
+ },
759
+ onSearchDone(search) {
760
+ if (!streamSearchItems.has(search)) return;
761
+ searchWriter.done(search);
762
+ },
763
+ });
764
+ } finally {
765
+ clearTimeout(timeout);
766
+ }
767
+ searches.push(...toolResult.searches);
768
+ openedPages.push(...(toolResult.openedPages || []));
769
+ currentChatRequest = {
770
+ ...currentChatRequest,
771
+ messages: currentChatRequest.messages.concat(toolResult.messages, commentaryToolMessages(commentaryCalls)),
772
+ tool_choice:
773
+ currentChatRequest.tool_choice?.function?.name === INTERNAL_WEB_SEARCH_TOOL
774
+ ? 'auto'
775
+ : currentChatRequest.tool_choice,
776
+ };
777
+ }
778
+
779
+ if (clientSignal?.aborted) {
780
+ return { handled: true };
781
+ }
782
+ const upstreamRequest = buildUpstreamRequest(`web_search_stream_round_${round + 1}`);
783
+ upstreamResponse = await callChatCompletions({
784
+ baseUrl: config.upstreamBaseUrl,
785
+ apiKey: config.upstreamApiKey,
786
+ request: upstreamRequest,
787
+ timeoutMs: config.upstreamTimeoutMs,
788
+ signal: clientSignal,
789
+ });
790
+ if (!upstreamResponse.ok) {
791
+ const data = await readJsonResponse(upstreamResponse);
792
+ return failStream(data?.error?.message || `upstream error ${upstreamResponse.status}`);
793
+ }
794
+ await relayRound();
795
+ }
796
+ } catch (error) {
797
+ if (clientSignal?.aborted) {
798
+ return { handled: true };
799
+ }
800
+ throw error;
801
+ } finally {
802
+ clearInterval(heartbeat);
803
+ }
804
+ }
805
+
806
+ export function createProxyServer({ config = loadConfig(), reasoningCache } = {}) {
807
+ reasoningCache ||= new ReasoningCache({
808
+ persistPath: config.reasoningCacheEnabled === false ? '' : config.reasoningCachePath,
809
+ legacyPath: config.legacyReasoningCachePath,
810
+ maxMessages: config.reasoningCacheMaxMessages,
811
+ maxBytes: config.reasoningCacheMaxBytes,
812
+ });
613
813
  let modelCache = null;
614
814
 
615
815
  async function getModelList() {
@@ -646,19 +846,22 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
646
846
  }
647
847
 
648
848
  const request = parsed.value;
649
- const normalized = normalizeResponsesRequest(request);
650
- const currentInputMessages = normalized.messages.slice();
651
- const previousResponseId = normalized.previous_response_id || request.previous_response_id || null;
652
- const conversationId = conversationIdFromRequest(request, normalized);
653
- const priorSession = previousResponseId ? sessions.get(previousResponseId) : conversationId ? sessions.getConversation(conversationId) : null;
654
- if (priorSession?.history?.length) {
655
- const priorMessages = historyMessagesFromSession(priorSession);
656
- normalized.messages = priorMessages.concat(normalized.messages);
657
- }
658
- normalized.messages = prependMissingAssistantToolMessages(normalized.messages, sessions);
849
+ const normalized = normalizeResponsesRequest(request, {
850
+ restoreDiscoveredTools: codexRequestKind(req) !== 'compaction',
851
+ });
852
+ normalized.messages = prependMissingAssistantToolMessages(normalized.messages, reasoningCache);
853
+ normalized.messages = restoreAssistantReasoningContent(normalized.messages, reasoningCache);
659
854
 
660
855
  const chatRequest = toChatCompletionsRequest(normalized);
661
856
  const useWebSearchEmulator = hasTavilyWebSearch(config, normalized);
857
+ if (!useWebSearchEmulator) {
858
+ const existingToolNames = new Set(chatToolNamesFromTools(chatRequest.tools));
859
+ const webShims = unavailableWebSearchToolShims(normalized.tools)
860
+ .filter((tool) => !existingToolNames.has(tool.function.name));
861
+ if (webShims.length) chatRequest.tools = [...(chatRequest.tools ?? []), ...webShims];
862
+ }
863
+ const clientAbort = clientAbortControllerFor(res);
864
+ const clientSignal = clientAbort.signal;
662
865
  let upstreamRequest = toProviderChatCompletionsRequest(chatRequest, config);
663
866
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || normalized.model;
664
867
 
@@ -672,119 +875,64 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
672
875
  }
673
876
 
674
877
  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());
707
- }
878
+ const persistReasoning = (assistantMessage) => reasoningCache.rememberAssistantMessage(assistantMessage);
708
879
 
880
+ if (useWebSearchEmulator && request.stream) {
881
+ const result = await runStreamingWebSearchTurn({
882
+ rawRequest: request,
883
+ normalized,
884
+ chatRequest,
885
+ config,
886
+ res,
887
+ responseId,
888
+ clientSignal,
889
+ });
890
+ if (result.handled) {
891
+ if (result.assistantMessage) persistReasoning(result.assistantMessage);
892
+ return;
893
+ }
894
+ } else if (useWebSearchEmulator) {
709
895
  const loop = await runWebSearchChatLoop({
896
+ rawRequest: request,
710
897
  normalized,
711
898
  chatRequest,
712
899
  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,
900
+ clientSignal,
726
901
  });
727
902
  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
903
  sendJson(res, loop.status || 500, loop.data);
739
904
  return;
740
905
  }
741
906
  upstreamRequest = toProviderChatCompletionsRequest(loop.chatRequest, config);
742
907
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || normalized.model;
743
- const model = upstreamRequest.model;
908
+ logDebugPayload(config, upstreamRequest, {
909
+ stage: 'web_search_compatibility_payload',
910
+ rawRequest: request,
911
+ normalized,
912
+ chatRequest: loop.chatRequest,
913
+ });
744
914
  const payload = applyWebSearchOutputCompatibility(convertChatCompletionToResponses({
745
915
  completion: loop.completion,
746
- model,
747
- previousResponseId,
916
+ model: upstreamRequest.model,
748
917
  normalized,
749
918
  responseId,
750
919
  config,
751
920
  }), 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;
921
+ if (payload.status === 'completed') {
922
+ persistReasoning(assistantMessageFromResponseOutput(payload.output));
770
923
  }
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();
924
+ sendJson(res, 200, payload);
778
925
  return;
779
926
  }
780
927
 
781
- logDebugPayload(config, upstreamRequest);
928
+ logDebugPayload(config, upstreamRequest, { rawRequest: request, normalized, chatRequest });
782
929
 
783
930
  const upstreamResponse = await callChatCompletions({
784
931
  baseUrl: config.upstreamBaseUrl,
785
932
  apiKey: config.upstreamApiKey,
786
933
  request: upstreamRequest,
787
934
  timeoutMs: config.upstreamTimeoutMs,
935
+ signal: clientSignal,
788
936
  });
789
937
 
790
938
  const model = upstreamRequest.model;
@@ -800,21 +948,13 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
800
948
  const payload = convertChatCompletionToResponses({
801
949
  completion: data,
802
950
  model,
803
- previousResponseId,
804
951
  normalized,
805
952
  responseId,
806
953
  config,
807
954
  });
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);
955
+ if (payload.status === 'completed') {
956
+ persistReasoning(assistantMessageFromResponseOutput(payload.output));
957
+ }
818
958
  sendJson(res, 200, payload);
819
959
  return;
820
960
  }
@@ -830,51 +970,55 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
830
970
  responseId,
831
971
  model,
832
972
  createdAt: Math.floor(Date.now() / 1000),
833
- previousResponseId,
834
973
  normalized,
835
974
  config,
836
- ...(() => {
837
- const reasoningMode = resolveReasoningStreamMode(config, upstreamRequest);
838
- return {
839
- bufferOutputUntilDone: upstreamRequest?.thinking?.type === 'enabled',
840
- ...reasoningMode,
841
- };
842
- })(),
975
+ ...resolveReasoningStreamMode(config, upstreamRequest),
976
+ knownToolNames: chatToolNamesFromTools(upstreamRequest.tools),
843
977
  });
844
978
  let doneSent = false;
845
979
  const writeResponsesDone = () => {
846
980
  if (doneSent) return;
847
981
  doneSent = true;
848
- res.write(serializeResponsesSseEvent({ done: true }));
982
+ writeSseEvent(res, { done: true });
849
983
  };
850
984
 
851
- res.write(serializeResponsesSseEvent(mapper.createdEvent()));
852
- res.write(serializeResponsesSseEvent(mapper.inProgressEvent()));
985
+ writeSseEvent(res, mapper.createdEvent());
986
+ writeSseEvent(res, mapper.inProgressEvent());
987
+ let lastEventWriteAt = Date.now();
988
+ const heartbeatMs = streamHeartbeatMs(config);
989
+ const heartbeat = setInterval(() => {
990
+ if (doneSent || Date.now() - lastEventWriteAt < heartbeatMs) return;
991
+ writeSseEvent(res, mapper.inProgressEvent());
992
+ lastEventWriteAt = Date.now();
993
+ }, heartbeatMs);
853
994
 
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
- });
995
+ try {
996
+ await relayChatCompletionsResponse({
997
+ upstreamResponse,
998
+ res,
999
+ passThrough: false,
1000
+ writeHeaders: false,
1001
+ onStreamChunk(event) {
1002
+ const events = mapper.mapChatEvent(event);
1003
+ for (const responseEvent of events) {
1004
+ writeSseEvent(res, responseEvent);
1005
+ }
1006
+ if (events.length) {
1007
+ lastEventWriteAt = Date.now();
1008
+ }
1009
+ if (event?.done) writeResponsesDone();
1010
+ },
1011
+ });
1012
+ } catch (error) {
1013
+ if (clientSignal.aborted) return;
1014
+ throw error;
1015
+ } finally {
1016
+ clearInterval(heartbeat);
1017
+ }
867
1018
 
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);
1019
+ if (mapper.terminalStatus === 'completed') {
1020
+ persistReasoning(mapper.assistantMessage());
1021
+ }
878
1022
  }
879
1023
 
880
1024
  return http.createServer(async (req, res) => {
@@ -917,6 +1061,7 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
917
1061
  apiKey: config.upstreamApiKey,
918
1062
  request: upstreamRequest,
919
1063
  timeoutMs: config.upstreamTimeoutMs,
1064
+ signal: clientAbortControllerFor(res).signal,
920
1065
  });
921
1066
  await relayChatCompletionsResponse({ upstreamResponse, res, passThrough: true });
922
1067
  return;
@@ -924,6 +1069,7 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
924
1069
 
925
1070
  sendJson(res, 404, { error: { message: 'Not found' } });
926
1071
  } catch (error) {
1072
+ if (res.destroyed) return;
927
1073
  if (res.headersSent) {
928
1074
  if (!res.writableEnded) {
929
1075
  res.write(serializeResponsesSseEvent({
@@ -938,7 +1084,8 @@ export function createProxyServer({ config = loadConfig(), sessions = new Sessio
938
1084
  }
939
1085
  return;
940
1086
  }
941
- sendJson(res, 500, { error: { message: error.message || 'Internal server error' } });
1087
+ const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
1088
+ sendJson(res, statusCode, { error: { code: error.code, message: error.message || 'Internal server error' } });
942
1089
  }
943
1090
  });
944
1091
  }