@galaxy-yearn/codex-deepseek-gateway 0.2.0 → 0.2.2

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
@@ -21,7 +21,7 @@ import {
21
21
  toProviderChatCompletionsRequest,
22
22
  unavailableWebSearchToolShims,
23
23
  } from './protocol.js';
24
- import { SessionStore } from './session-store.js';
24
+ import { ReasoningCache } from './reasoning-cache.js';
25
25
  import { callChatCompletions, callModels, readJsonResponse, relayChatCompletionsResponse } from './upstream.js';
26
26
  import {
27
27
  annotateMessagePartWithWebCitations,
@@ -32,7 +32,6 @@ import {
32
32
  hasAnyToolCalls,
33
33
  hasKnownExternalToolCalls,
34
34
  hasUnknownExternalToolCalls,
35
- INTERNAL_WEB_SEARCH_TOOL,
36
35
  maxWebSearchRounds,
37
36
  prepareWebSearchRequest,
38
37
  removeWebSearchInstructions,
@@ -62,6 +61,14 @@ function getRequestPath(req) {
62
61
  return url.pathname;
63
62
  }
64
63
 
64
+ function codexRequestKind(req) {
65
+ const value = req.headers['x-codex-turn-metadata'];
66
+ if (typeof value !== 'string') return '';
67
+ const parsed = safeJsonParse(value);
68
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== 'object') return '';
69
+ return String(parsed.value.request_kind || '');
70
+ }
71
+
65
72
  function isAuthorized(req, config) {
66
73
  if (!config.proxyApiKey) return true;
67
74
  const authorization = req.headers.authorization || '';
@@ -69,29 +76,7 @@ function isAuthorized(req, config) {
69
76
  return bearerToken === config.proxyApiKey || req.headers['x-api-key'] === config.proxyApiKey;
70
77
  }
71
78
 
72
- function conversationIdFromRequest(request, normalized) {
73
- const conversation = normalized.conversation ?? request.conversation;
74
- if (typeof conversation === 'string') return conversation;
75
- if (conversation && typeof conversation === 'object') return conversation.id || conversation.conversation_id || null;
76
- return request.conversation_id || null;
77
- }
78
-
79
- function historyMessagesFromSession(session) {
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
- return messages;
87
- }
88
-
89
- function shouldPersistSession(normalized, request, conversationId) {
90
- if (conversationId) return true;
91
- return (normalized.store ?? request.store) !== false;
92
- }
93
-
94
- function prependMissingAssistantToolMessages(messages, sessions) {
79
+ function prependMissingAssistantToolMessages(messages, reasoningCache) {
95
80
  const existingToolCallIds = new Set();
96
81
  const missingToolOutputIds = [];
97
82
  for (const message of messages) {
@@ -111,7 +96,7 @@ function prependMissingAssistantToolMessages(messages, sessions) {
111
96
  const inserted = new Set(existingToolCallIds);
112
97
  for (const callId of missingToolOutputIds) {
113
98
  if (inserted.has(callId)) continue;
114
- const assistantMessage = sessions.getAssistantMessageForToolCall(callId);
99
+ const assistantMessage = reasoningCache.getAssistantMessageForToolCall(callId);
115
100
  if (!assistantMessage) continue;
116
101
  prefix.push(assistantMessage);
117
102
  for (const id of extractToolCallIdsFromMessages([assistantMessage])) inserted.add(id);
@@ -119,14 +104,14 @@ function prependMissingAssistantToolMessages(messages, sessions) {
119
104
  return prefix.length ? prefix.concat(messages) : messages;
120
105
  }
121
106
 
122
- function restoreAssistantReasoningContent(messages, sessions) {
107
+ function restoreAssistantReasoningContent(messages, reasoningCache) {
123
108
  return messages.map((message) => {
124
109
  if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls) || !message.tool_calls.length) {
125
110
  return message;
126
111
  }
127
112
  if (typeof message.reasoning_content === 'string' && message.reasoning_content) return message;
128
113
  for (const toolCall of message.tool_calls) {
129
- const stored = sessions.getAssistantMessageForToolCall(toolCall?.id);
114
+ const stored = reasoningCache.getAssistantMessageForToolCall(toolCall?.id);
130
115
  if (typeof stored?.reasoning_content === 'string' && stored.reasoning_content) {
131
116
  return { ...message, reasoning_content: stored.reasoning_content };
132
117
  }
@@ -242,16 +227,15 @@ function webToolTimeoutMs(config = {}) {
242
227
  function combineUsage(completions) {
243
228
  const usages = completions.map((completion) => completion?.usage).filter(Boolean);
244
229
  if (!usages.length) return null;
245
- const last = usages[usages.length - 1];
246
230
  const sum = (pick) => usages.reduce((total, usage) => total + (Number(pick(usage)) || 0), 0);
247
- const promptTokens = Number(last.prompt_tokens) || 0;
231
+ const promptTokens = sum((usage) => usage.prompt_tokens);
248
232
  const completionTokens = sum((usage) => usage.completion_tokens);
249
233
  return {
250
234
  prompt_tokens: promptTokens,
251
235
  completion_tokens: completionTokens,
252
236
  total_tokens: promptTokens + completionTokens,
253
237
  prompt_tokens_details: {
254
- cached_tokens: last.prompt_tokens_details?.cached_tokens ?? last.prompt_cache_hit_tokens ?? 0,
238
+ cached_tokens: sum((usage) => usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens),
255
239
  },
256
240
  completion_tokens_details: {
257
241
  reasoning_tokens: sum((usage) => usage.completion_tokens_details?.reasoning_tokens ?? usage.reasoning_tokens),
@@ -259,27 +243,53 @@ function combineUsage(completions) {
259
243
  };
260
244
  }
261
245
 
262
- function cloneCompletionWithUsage(completion, usage) {
263
- if (!usage) return completion;
264
- return {
265
- ...completion,
266
- usage,
267
- };
246
+ function lastUsage(usages) {
247
+ for (let index = usages.length - 1; index >= 0; index -= 1) {
248
+ if (usages[index]) return usages[index];
249
+ }
250
+ return null;
251
+ }
252
+
253
+ function withUsageFallback(completion, usage) {
254
+ if (!completion || completion.usage || !usage) return completion;
255
+ return { ...completion, usage };
256
+ }
257
+
258
+ function toWebSearchUpstreamRequest(chatRequest, config, webTools) {
259
+ const request = toProviderChatCompletionsRequest(chatRequest, config);
260
+ if (
261
+ request.thinking?.type === 'enabled' &&
262
+ request.tool_choice?.type === 'function' &&
263
+ request.tool_choice.function?.name === webTools?.search
264
+ ) {
265
+ return { ...request, thinking: { type: 'disabled' }, reasoning_effort: undefined };
266
+ }
267
+ return request;
268
+ }
269
+
270
+ function logWebSearchUsage(config, stage, usages, finalUsage) {
271
+ if (!config.debugPayload) return;
272
+ const aggregateUsage = combineUsage(usages.map((usage) => ({ usage })));
273
+ if (!aggregateUsage && !finalUsage) return;
274
+ writeDebugPayloadLine(config, `[codex-deepseek-gateway] web search usage ${JSON.stringify({
275
+ stage,
276
+ rounds: usages.length,
277
+ aggregate: aggregateUsage,
278
+ final: finalUsage || null,
279
+ })}\n`);
268
280
  }
269
281
 
270
- function toolCallsToFinalAnswerRequest(request) {
282
+ function toolCallsToFinalAnswerRequest(request, webTools) {
271
283
  return {
272
284
  ...request,
273
285
  tool_choice: undefined,
274
286
  tools: undefined,
275
- messages: removeWebSearchInstructions(request.messages).concat({
287
+ messages: removeWebSearchInstructions(request.messages, webTools).concat({
276
288
  role: 'user',
277
289
  content: [
278
- 'Web tools are not available now.',
279
- 'Use the completed tool results already provided in this conversation.',
280
- 'Give the final answer now as visible assistant message content.',
281
- 'Do not write tool calls, DSML, XML, or JSON tool-call blocks in the answer.',
282
- 'If the available tool results are incomplete, say what is missing and answer only what the provided context supports.',
290
+ 'Web tools are now unavailable.',
291
+ 'Answer now using only completed tool results as visible assistant text, without tool calls or tool-call markup.',
292
+ 'If results are incomplete, state what is missing and do not infer beyond them.',
283
293
  ].join(' '),
284
294
  }),
285
295
  };
@@ -407,6 +417,8 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
407
417
  const effectiveChatRequest = { ...chatRequest, normalized };
408
418
  const state = prepareWebSearchRequest({ normalized, chatRequest: effectiveChatRequest, config });
409
419
  const searchConfig = state.config || config;
420
+ const webTools = state.webTools;
421
+ const webCache = state.webCache;
410
422
  let currentChatRequest = state.enabled ? state.chatRequest : effectiveChatRequest;
411
423
  const completions = [];
412
424
  const searches = [];
@@ -417,8 +429,8 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
417
429
  const maxRounds = maxWebSearchRounds(searchConfig);
418
430
 
419
431
  async function requestFinalAnswer(baseRequest) {
420
- currentChatRequest = toolCallsToFinalAnswerRequest(baseRequest);
421
- const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
432
+ currentChatRequest = toolCallsToFinalAnswerRequest(baseRequest, webTools);
433
+ const upstreamRequest = toWebSearchUpstreamRequest(currentChatRequest, config, webTools);
422
434
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
423
435
  logDebugPayload(config, upstreamRequest, {
424
436
  stage: 'web_search_final_answer',
@@ -444,7 +456,7 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
444
456
  }
445
457
 
446
458
  for (let round = 0; round <= maxRounds + 1; round += 1) {
447
- const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
459
+ const upstreamRequest = toWebSearchUpstreamRequest(currentChatRequest, config, webTools);
448
460
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
449
461
  logDebugPayload(config, upstreamRequest, {
450
462
  stage: `web_search_round_${round}`,
@@ -465,9 +477,9 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
465
477
  const commentaryCalls = bridgedCommentaryToolCallsFromMessage(roundChatMessage, currentChatRequest.tools);
466
478
  const routingData = stripBridgedCommentaryFromCompletion(data, currentChatRequest.tools);
467
479
 
468
- if (state.enabled && hasKnownExternalToolCalls(routingData, currentChatRequest.tools)) {
480
+ if (state.enabled && hasKnownExternalToolCalls(routingData, currentChatRequest.tools, webTools)) {
469
481
  finalCompletion = restoreCommentaryToolCalls(
470
- knownExternalToolCallsCompletion(routingData, currentChatRequest.tools),
482
+ knownExternalToolCallsCompletion(routingData, currentChatRequest.tools, webTools),
471
483
  commentaryCalls,
472
484
  );
473
485
  completions[completions.length - 1] = finalCompletion;
@@ -478,15 +490,16 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
478
490
  break;
479
491
  }
480
492
 
481
- const wantsInternalWeb = shouldContinueWebSearchLoop(routingData);
493
+ const wantsInternalWeb = shouldContinueWebSearchLoop(routingData, webTools);
482
494
  const wantsInternalRound = wantsInternalWeb || commentaryCalls.length > 0;
483
495
  const hasToolCalls = hasAnyToolCalls(routingData);
484
496
 
485
497
  if (!wantsInternalRound || round >= maxRounds) {
486
- if (hasToolCalls && hasUnknownExternalToolCalls(routingData, currentChatRequest.tools)) {
498
+ if (hasToolCalls && hasUnknownExternalToolCalls(routingData, currentChatRequest.tools, webTools)) {
487
499
  const unsupportedMessages = unhandledToolMessagesFromCompletion(routingData, undefined, {
488
500
  onlyUnknownExternal: true,
489
501
  tools: currentChatRequest.tools,
502
+ webTools,
490
503
  });
491
504
  const finalAnswerError = await requestFinalAnswer({
492
505
  ...currentChatRequest,
@@ -511,6 +524,8 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
511
524
  toolResult = await executeWebSearchCalls({
512
525
  completion: data,
513
526
  config: searchConfig,
527
+ webTools,
528
+ webCache,
514
529
  signal: mergeAbortSignals([controller.signal, clientSignal]),
515
530
  onSearchStart,
516
531
  onSearchDone,
@@ -524,18 +539,22 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
524
539
  ...currentChatRequest,
525
540
  messages: currentChatRequest.messages.concat(toolResult.messages, commentaryToolMessages(commentaryCalls)),
526
541
  tool_choice:
527
- currentChatRequest.tool_choice?.function?.name === INTERNAL_WEB_SEARCH_TOOL
542
+ currentChatRequest.tool_choice?.function?.name === webTools.search && toolResult.searches.length
528
543
  ? 'auto'
529
544
  : currentChatRequest.tool_choice,
530
545
  };
531
546
  }
532
547
 
548
+ const usages = completions.map((completion) => completion?.usage).filter(Boolean);
549
+ const reportedUsage = finalCompletion?.usage || lastUsage(usages);
550
+ logWebSearchUsage(config, 'web_search', usages, finalCompletion?.usage);
533
551
  return {
534
552
  ok: true,
535
553
  response: finalResponse,
536
- completion: cloneCompletionWithUsage(finalCompletion, combineUsage(completions)),
554
+ completion: withUsageFallback(finalCompletion, reportedUsage),
537
555
  searches,
538
556
  openedPages,
557
+ webTools,
539
558
  chatRequest: currentChatRequest,
540
559
  incompleteReason,
541
560
  };
@@ -543,23 +562,48 @@ async function runWebSearchChatLoop({ rawRequest, normalized, chatRequest, confi
543
562
 
544
563
  const STREAM_HEARTBEAT_MS = 10000;
545
564
 
565
+ function streamHeartbeatMs(config) {
566
+ const value = Number(config?.streamHeartbeatMs);
567
+ if (Number.isFinite(value) && value > 0) return value;
568
+ return STREAM_HEARTBEAT_MS;
569
+ }
570
+
571
+ function startStreamHeartbeat({ config, res, write }) {
572
+ const heartbeatMs = streamHeartbeatMs(config);
573
+ let lastActivityAt = Date.now();
574
+ let timer = null;
575
+ const stop = () => {
576
+ if (timer) clearInterval(timer);
577
+ timer = null;
578
+ res.off('close', stop);
579
+ };
580
+ timer = setInterval(() => {
581
+ if (res.writableEnded || res.destroyed || Date.now() - lastActivityAt < heartbeatMs) return;
582
+ write();
583
+ lastActivityAt = Date.now();
584
+ }, heartbeatMs);
585
+ res.once('close', stop);
586
+ return {
587
+ activity() {
588
+ lastActivityAt = Date.now();
589
+ },
590
+ stop,
591
+ };
592
+ }
593
+
546
594
  function writeSseEvent(res, event) {
547
595
  if (res.writableEnded || res.destroyed) return;
548
596
  res.write(serializeResponsesSseEvent(event));
549
597
  }
550
598
 
551
- function writeSseDone(res) {
552
- writeSseEvent(res, { done: true });
553
- }
554
-
555
- function webSearchSseWriter({ res, mapper, outputIndexBySearch, includeSources = false }) {
599
+ function webSearchSseWriter({ writeEvent, mapper, outputIndexBySearch, includeSources = false }) {
556
600
  return {
557
601
  start(search) {
558
602
  const outputIndex = mapper.output.length;
559
603
  outputIndexBySearch.set(search.id, outputIndex);
560
604
  const item = buildWebSearchCallItem(search, { status: 'in_progress', includeSources });
561
605
  mapper.output.push(item);
562
- writeSseEvent(res, {
606
+ writeEvent({
563
607
  type: 'response.output_item.added',
564
608
  sequence_number: mapper.nextSequence(),
565
609
  output_index: outputIndex,
@@ -570,7 +614,7 @@ function webSearchSseWriter({ res, mapper, outputIndexBySearch, includeSources =
570
614
  const outputIndex = outputIndexBySearch.get(search.id);
571
615
  const item = buildWebSearchCallItem(search, { includeSources });
572
616
  if (Number.isInteger(outputIndex)) mapper.output[outputIndex] = item;
573
- writeSseEvent(res, {
617
+ writeEvent({
574
618
  type: 'response.output_item.done',
575
619
  sequence_number: mapper.nextSequence(),
576
620
  output_index: Number.isInteger(outputIndex) ? outputIndex : mapper.output.indexOf(item),
@@ -580,17 +624,19 @@ function webSearchSseWriter({ res, mapper, outputIndexBySearch, includeSources =
580
624
  };
581
625
  }
582
626
 
583
- async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest, config, res, responseId, previousResponseId, clientSignal }) {
627
+ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest, config, res, responseId, clientSignal }) {
584
628
  const effectiveChatRequest = { ...chatRequest, normalized };
585
629
  const state = prepareWebSearchRequest({ normalized, chatRequest: effectiveChatRequest, config });
586
630
  if (!state.enabled) return { handled: false };
587
631
 
588
632
  const searchConfig = state.config || config;
633
+ const webTools = state.webTools;
634
+ const webCache = state.webCache;
589
635
  const maxRounds = maxWebSearchRounds(searchConfig);
590
636
  let currentChatRequest = state.chatRequest;
591
637
 
592
638
  const buildUpstreamRequest = (stage) => {
593
- const upstreamRequest = toProviderChatCompletionsRequest(currentChatRequest, config);
639
+ const upstreamRequest = toWebSearchUpstreamRequest(currentChatRequest, config, webTools);
594
640
  if (!upstreamRequest.model) upstreamRequest.model = config.upstreamModel || currentChatRequest.model;
595
641
  logDebugPayload(config, upstreamRequest, { stage, rawRequest, normalized, chatRequest: currentChatRequest });
596
642
  return upstreamRequest;
@@ -613,15 +659,19 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
613
659
  responseId,
614
660
  model: firstUpstreamRequest.model,
615
661
  createdAt: Math.floor(Date.now() / 1000),
616
- previousResponseId,
617
662
  normalized,
618
663
  config,
619
664
  ...resolveReasoningStreamMode(config, firstUpstreamRequest),
620
665
  holdToolItemEvents: true,
621
666
  knownToolNames: chatToolNamesFromTools(firstUpstreamRequest.tools),
622
667
  });
668
+ let heartbeat = null;
669
+ const writeEvent = (event) => {
670
+ writeSseEvent(res, event);
671
+ heartbeat?.activity();
672
+ };
623
673
  const searchWriter = webSearchSseWriter({
624
- res,
674
+ writeEvent,
625
675
  mapper,
626
676
  outputIndexBySearch: new Map(),
627
677
  includeSources: shouldIncludeSearchSources(normalized),
@@ -639,18 +689,20 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
639
689
  connection: 'keep-alive',
640
690
  'x-accel-buffering': 'no',
641
691
  });
642
- writeSseEvent(res, mapper.createdEvent());
643
- writeSseEvent(res, mapper.inProgressEvent());
644
- const heartbeat = setInterval(() => {
645
- writeSseEvent(res, mapper.inProgressEvent());
646
- }, STREAM_HEARTBEAT_MS);
692
+ writeEvent(mapper.createdEvent());
693
+ writeEvent(mapper.inProgressEvent());
694
+ heartbeat = startStreamHeartbeat({
695
+ config,
696
+ res,
697
+ write: () => writeEvent(mapper.inProgressEvent()),
698
+ });
647
699
 
648
700
  const endStream = () => {
649
- writeSseDone(res);
701
+ writeEvent({ done: true });
650
702
  res.end();
651
703
  };
652
704
  const failStream = (message) => {
653
- for (const event of mapper.streamFailed(message)) writeSseEvent(res, event);
705
+ for (const event of mapper.streamFailed(message)) writeEvent(event);
654
706
  endStream();
655
707
  return { handled: true };
656
708
  };
@@ -658,12 +710,18 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
658
710
  if (mapper.messageItem?.content?.length) {
659
711
  annotateMessagePartWithWebCitations(mapper.messageItem.content[0], searches, openedPages);
660
712
  }
661
- const combinedUsage = combineUsage(roundUsages.concat([{ usage: mapper.pendingUsage }]));
662
- for (const event of mapper.finalize(mapper.pendingFinishReason || 'stop', combinedUsage)) {
663
- writeSseEvent(res, event);
713
+ const usages = roundUsages.map((completion) => completion.usage).filter(Boolean).concat(mapper.pendingUsage ? [mapper.pendingUsage] : []);
714
+ const reportedUsage = mapper.pendingUsage || lastUsage(usages);
715
+ logWebSearchUsage(config, 'web_search_stream', usages, mapper.pendingUsage);
716
+ for (const event of mapper.finalize(mapper.pendingFinishReason || 'stop', reportedUsage)) {
717
+ writeEvent(event);
664
718
  }
665
719
  endStream();
666
- return { handled: true, chatRequest: currentChatRequest, assistantMessage: mapper.roundAssistantMessage() };
720
+ return {
721
+ handled: true,
722
+ chatRequest: currentChatRequest,
723
+ assistantMessage: mapper.terminalStatus === 'completed' ? mapper.roundAssistantMessage() : null,
724
+ };
667
725
  };
668
726
  const relayRound = async () => {
669
727
  roundEnd = null;
@@ -680,7 +738,7 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
680
738
  return;
681
739
  }
682
740
  for (const responseEvent of mapper.mapChatEvent(event)) {
683
- writeSseEvent(res, responseEvent);
741
+ writeEvent(responseEvent);
684
742
  }
685
743
  },
686
744
  });
@@ -698,7 +756,7 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
698
756
  mapper.expandParallelToolItems();
699
757
  const roundMessage = mapper.roundAssistantMessage();
700
758
  const commentaryCalls = bridgedCommentaryToolCallsFromMessage(roundMessage, currentChatRequest.tools);
701
- for (const event of mapper.convertBridgedCommentaryItems()) writeSseEvent(res, event);
759
+ for (const event of mapper.convertBridgedCommentaryItems()) writeEvent(event);
702
760
  const completionLike = {
703
761
  choices: [{ index: 0, message: roundMessage, finish_reason: mapper.pendingFinishReason || 'stop' }],
704
762
  };
@@ -709,13 +767,13 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
709
767
  const reason = finalAnswerIncompleteReason(routingCompletion);
710
768
  if (reason) {
711
769
  const events = mapper.replaceBufferedAssistantText(gatewayIncompleteMessageContent(reason));
712
- for (const event of events || []) writeSseEvent(res, event);
770
+ for (const event of events || []) writeEvent(event);
713
771
  }
714
772
  return finishTurn();
715
773
  }
716
774
 
717
- if (hasKnownExternalToolCalls(routingCompletion, currentChatRequest.tools)) {
718
- const kept = knownExternalToolCallsCompletion(routingCompletion, currentChatRequest.tools);
775
+ if (hasKnownExternalToolCalls(routingCompletion, currentChatRequest.tools, webTools)) {
776
+ const kept = knownExternalToolCallsCompletion(routingCompletion, currentChatRequest.tools, webTools);
719
777
  const keptIds = new Set(
720
778
  (kept.choices?.[0]?.message?.tool_calls || []).map((toolCall) => toolCall.id).filter(Boolean),
721
779
  );
@@ -723,33 +781,34 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
723
781
  return finishTurn();
724
782
  }
725
783
 
726
- const wantsInternalWeb = shouldContinueWebSearchLoop(routingCompletion);
784
+ const wantsInternalWeb = shouldContinueWebSearchLoop(routingCompletion, webTools);
727
785
  const wantsInternalRound = wantsInternalWeb || commentaryCalls.length > 0;
728
786
  const hasToolCallsRound = hasAnyToolCalls(routingCompletion);
729
787
 
730
788
  if (!wantsInternalRound || round >= maxRounds) {
731
- if (hasToolCallsRound && hasUnknownExternalToolCalls(routingCompletion, currentChatRequest.tools)) {
789
+ if (hasToolCallsRound && hasUnknownExternalToolCalls(routingCompletion, currentChatRequest.tools, webTools)) {
732
790
  const unsupportedMessages = unhandledToolMessagesFromCompletion(routingCompletion, undefined, {
733
791
  onlyUnknownExternal: true,
734
792
  tools: currentChatRequest.tools,
793
+ webTools,
735
794
  });
736
795
  currentChatRequest = toolCallsToFinalAnswerRequest({
737
796
  ...currentChatRequest,
738
797
  messages: currentChatRequest.messages.concat(unsupportedMessages),
739
- });
798
+ }, webTools);
740
799
  } else if (wantsInternalRound || (!hasToolCallsRound && hasWebSearchContext(searches, openedPages) && !hasVisibleAssistantContent(routingCompletion))) {
741
- currentChatRequest = toolCallsToFinalAnswerRequest(currentChatRequest);
800
+ currentChatRequest = toolCallsToFinalAnswerRequest(currentChatRequest, webTools);
742
801
  } else {
743
802
  return finishTurn();
744
803
  }
745
804
  finalAnswerForced = true;
746
805
  roundUsages.push({ usage: mapper.pendingUsage });
747
806
  mapper.removeToolItems();
748
- for (const event of mapper.beginNextRound()) writeSseEvent(res, event);
807
+ for (const event of mapper.beginNextRound()) writeEvent(event);
749
808
  mapper.holdVisibleTextUntilDone();
750
809
  } else {
751
810
  roundUsages.push({ usage: mapper.pendingUsage });
752
- for (const event of mapper.beginNextRound()) writeSseEvent(res, event);
811
+ for (const event of mapper.beginNextRound()) writeEvent(event);
753
812
  const controller = new AbortController();
754
813
  const timeout = setTimeout(() => controller.abort(), webToolTimeoutMs(searchConfig));
755
814
  let toolResult;
@@ -757,6 +816,8 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
757
816
  toolResult = await executeWebSearchCalls({
758
817
  completion: completionLike,
759
818
  config: searchConfig,
819
+ webTools,
820
+ webCache,
760
821
  signal: mergeAbortSignals([controller.signal, clientSignal]),
761
822
  onSearchStart(search) {
762
823
  if (search.action && search.auto) return;
@@ -777,7 +838,7 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
777
838
  ...currentChatRequest,
778
839
  messages: currentChatRequest.messages.concat(toolResult.messages, commentaryToolMessages(commentaryCalls)),
779
840
  tool_choice:
780
- currentChatRequest.tool_choice?.function?.name === INTERNAL_WEB_SEARCH_TOOL
841
+ currentChatRequest.tool_choice?.function?.name === webTools.search && toolResult.searches.length
781
842
  ? 'auto'
782
843
  : currentChatRequest.tool_choice,
783
844
  };
@@ -806,15 +867,16 @@ async function runStreamingWebSearchTurn({ rawRequest, normalized, chatRequest,
806
867
  }
807
868
  throw error;
808
869
  } finally {
809
- clearInterval(heartbeat);
870
+ heartbeat?.stop();
810
871
  }
811
872
  }
812
873
 
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,
874
+ export function createProxyServer({ config = loadConfig(), reasoningCache } = {}) {
875
+ reasoningCache ||= new ReasoningCache({
876
+ persistPath: config.reasoningCacheEnabled === false ? '' : config.reasoningCachePath,
877
+ legacyPath: config.legacyReasoningCachePath,
878
+ maxMessages: config.reasoningCacheMaxMessages,
879
+ maxBytes: config.reasoningCacheMaxBytes,
818
880
  });
819
881
  let modelCache = null;
820
882
 
@@ -852,16 +914,11 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
852
914
  }
853
915
 
854
916
  const request = parsed.value;
855
- const normalized = normalizeResponsesRequest(request);
856
- const previousResponseId = normalized.previous_response_id || request.previous_response_id || null;
857
- const conversationId = conversationIdFromRequest(request, normalized);
858
- const priorSession = previousResponseId ? sessions.get(previousResponseId) : conversationId ? sessions.getConversation(conversationId) : null;
859
- const priorMessages = historyMessagesFromSession(priorSession);
860
- if (priorMessages.length) {
861
- normalized.messages = priorMessages.concat(normalized.messages);
862
- }
863
- normalized.messages = prependMissingAssistantToolMessages(normalized.messages, sessions);
864
- normalized.messages = restoreAssistantReasoningContent(normalized.messages, sessions);
917
+ const normalized = normalizeResponsesRequest(request, {
918
+ restoreDiscoveredTools: codexRequestKind(req) !== 'compaction',
919
+ });
920
+ normalized.messages = prependMissingAssistantToolMessages(normalized.messages, reasoningCache);
921
+ normalized.messages = restoreAssistantReasoningContent(normalized.messages, reasoningCache);
865
922
 
866
923
  const chatRequest = toChatCompletionsRequest(normalized);
867
924
  const useWebSearchEmulator = hasTavilyWebSearch(config, normalized);
@@ -886,17 +943,7 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
886
943
  }
887
944
 
888
945
  const responseId = generateId('resp');
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);
898
- }
899
- };
946
+ const persistReasoning = (assistantMessage) => reasoningCache.rememberAssistantMessage(assistantMessage);
900
947
 
901
948
  if (useWebSearchEmulator && request.stream) {
902
949
  const result = await runStreamingWebSearchTurn({
@@ -906,11 +953,10 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
906
953
  config,
907
954
  res,
908
955
  responseId,
909
- previousResponseId,
910
956
  clientSignal,
911
957
  });
912
958
  if (result.handled) {
913
- if (result.assistantMessage) persistTurn(result.chatRequest.messages, result.assistantMessage);
959
+ if (result.assistantMessage) persistReasoning(result.assistantMessage);
914
960
  return;
915
961
  }
916
962
  } else if (useWebSearchEmulator) {
@@ -936,12 +982,13 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
936
982
  const payload = applyWebSearchOutputCompatibility(convertChatCompletionToResponses({
937
983
  completion: loop.completion,
938
984
  model: upstreamRequest.model,
939
- previousResponseId,
940
985
  normalized,
941
986
  responseId,
942
987
  config,
943
- }), loop.searches, normalized, loop.openedPages);
944
- persistTurn(loop.chatRequest.messages, assistantMessageFromResponseOutput(payload.output));
988
+ }), loop.searches, normalized, loop.openedPages, loop.webTools);
989
+ if (payload.status === 'completed') {
990
+ persistReasoning(assistantMessageFromResponseOutput(payload.output));
991
+ }
945
992
  sendJson(res, 200, payload);
946
993
  return;
947
994
  }
@@ -969,12 +1016,13 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
969
1016
  const payload = convertChatCompletionToResponses({
970
1017
  completion: data,
971
1018
  model,
972
- previousResponseId,
973
1019
  normalized,
974
1020
  responseId,
975
1021
  config,
976
1022
  });
977
- persistTurn(chatRequest.messages, assistantMessageFromResponseOutput(payload.output));
1023
+ if (payload.status === 'completed') {
1024
+ persistReasoning(assistantMessageFromResponseOutput(payload.output));
1025
+ }
978
1026
  sendJson(res, 200, payload);
979
1027
  return;
980
1028
  }
@@ -990,7 +1038,6 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
990
1038
  responseId,
991
1039
  model,
992
1040
  createdAt: Math.floor(Date.now() / 1000),
993
- previousResponseId,
994
1041
  normalized,
995
1042
  config,
996
1043
  ...resolveReasoningStreamMode(config, upstreamRequest),
@@ -1005,7 +1052,13 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
1005
1052
 
1006
1053
  writeSseEvent(res, mapper.createdEvent());
1007
1054
  writeSseEvent(res, mapper.inProgressEvent());
1008
- let lastEventWriteAt = Date.now();
1055
+ const heartbeat = startStreamHeartbeat({
1056
+ config,
1057
+ res,
1058
+ write: () => {
1059
+ if (!doneSent) writeSseEvent(res, mapper.inProgressEvent());
1060
+ },
1061
+ });
1009
1062
 
1010
1063
  try {
1011
1064
  await relayChatCompletionsResponse({
@@ -1019,10 +1072,7 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
1019
1072
  writeSseEvent(res, responseEvent);
1020
1073
  }
1021
1074
  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();
1075
+ heartbeat.activity();
1026
1076
  }
1027
1077
  if (event?.done) writeResponsesDone();
1028
1078
  },
@@ -1030,9 +1080,13 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
1030
1080
  } catch (error) {
1031
1081
  if (clientSignal.aborted) return;
1032
1082
  throw error;
1083
+ } finally {
1084
+ heartbeat.stop();
1033
1085
  }
1034
1086
 
1035
- persistTurn(chatRequest.messages, mapper.assistantMessage());
1087
+ if (mapper.terminalStatus === 'completed') {
1088
+ persistReasoning(mapper.assistantMessage());
1089
+ }
1036
1090
  }
1037
1091
 
1038
1092
  return http.createServer(async (req, res) => {
@@ -1098,7 +1152,8 @@ export function createProxyServer({ config = loadConfig(), sessions } = {}) {
1098
1152
  }
1099
1153
  return;
1100
1154
  }
1101
- sendJson(res, 500, { error: { message: error.message || 'Internal server error' } });
1155
+ const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
1156
+ sendJson(res, statusCode, { error: { code: error.code, message: error.message || 'Internal server error' } });
1102
1157
  }
1103
1158
  });
1104
1159
  }