@librechat/agents 3.1.85 → 3.1.87

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.
Files changed (166) hide show
  1. package/README.md +69 -0
  2. package/dist/cjs/agents/AgentContext.cjs +7 -2
  3. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  4. package/dist/cjs/events.cjs +23 -0
  5. package/dist/cjs/events.cjs.map +1 -1
  6. package/dist/cjs/graphs/Graph.cjs +133 -18
  7. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -1
  9. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  10. package/dist/cjs/llm/anthropic/index.cjs +251 -53
  11. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  12. package/dist/cjs/llm/init.cjs +1 -5
  13. package/dist/cjs/llm/init.cjs.map +1 -1
  14. package/dist/cjs/llm/openai/index.cjs +113 -24
  15. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  16. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openrouter/index.cjs +3 -1
  18. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  19. package/dist/cjs/main.cjs +18 -5
  20. package/dist/cjs/main.cjs.map +1 -1
  21. package/dist/cjs/openai/index.cjs +253 -0
  22. package/dist/cjs/openai/index.cjs.map +1 -0
  23. package/dist/cjs/responses/index.cjs +448 -0
  24. package/dist/cjs/responses/index.cjs.map +1 -0
  25. package/dist/cjs/run.cjs +108 -7
  26. package/dist/cjs/run.cjs.map +1 -1
  27. package/dist/cjs/session/AgentSession.cjs +1057 -0
  28. package/dist/cjs/session/AgentSession.cjs.map +1 -0
  29. package/dist/cjs/session/JsonlSessionStore.cjs +425 -0
  30. package/dist/cjs/session/JsonlSessionStore.cjs.map +1 -0
  31. package/dist/cjs/session/handlers.cjs +221 -0
  32. package/dist/cjs/session/handlers.cjs.map +1 -0
  33. package/dist/cjs/session/ids.cjs +22 -0
  34. package/dist/cjs/session/ids.cjs.map +1 -0
  35. package/dist/cjs/session/messageSerialization.cjs +179 -0
  36. package/dist/cjs/session/messageSerialization.cjs.map +1 -0
  37. package/dist/cjs/stream.cjs +472 -11
  38. package/dist/cjs/stream.cjs.map +1 -1
  39. package/dist/cjs/summarization/node.cjs +1 -1
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/ToolNode.cjs +177 -59
  42. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  43. package/dist/cjs/tools/eagerEventExecution.cjs +113 -0
  44. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -0
  45. package/dist/cjs/tools/handlers.cjs +1 -1
  46. package/dist/cjs/tools/handlers.cjs.map +1 -1
  47. package/dist/cjs/tools/streamedToolCallSeals.cjs +42 -0
  48. package/dist/cjs/tools/streamedToolCallSeals.cjs.map +1 -0
  49. package/dist/esm/agents/AgentContext.mjs +7 -2
  50. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  51. package/dist/esm/events.mjs +23 -1
  52. package/dist/esm/events.mjs.map +1 -1
  53. package/dist/esm/graphs/Graph.mjs +133 -18
  54. package/dist/esm/graphs/Graph.mjs.map +1 -1
  55. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
  56. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  57. package/dist/esm/llm/anthropic/index.mjs +251 -53
  58. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  59. package/dist/esm/llm/init.mjs +1 -5
  60. package/dist/esm/llm/init.mjs.map +1 -1
  61. package/dist/esm/llm/openai/index.mjs +113 -25
  62. package/dist/esm/llm/openai/index.mjs.map +1 -1
  63. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  64. package/dist/esm/llm/openrouter/index.mjs +4 -2
  65. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  66. package/dist/esm/main.mjs +5 -1
  67. package/dist/esm/main.mjs.map +1 -1
  68. package/dist/esm/openai/index.mjs +246 -0
  69. package/dist/esm/openai/index.mjs.map +1 -0
  70. package/dist/esm/responses/index.mjs +440 -0
  71. package/dist/esm/responses/index.mjs.map +1 -0
  72. package/dist/esm/run.mjs +108 -7
  73. package/dist/esm/run.mjs.map +1 -1
  74. package/dist/esm/session/AgentSession.mjs +1054 -0
  75. package/dist/esm/session/AgentSession.mjs.map +1 -0
  76. package/dist/esm/session/JsonlSessionStore.mjs +422 -0
  77. package/dist/esm/session/JsonlSessionStore.mjs.map +1 -0
  78. package/dist/esm/session/handlers.mjs +219 -0
  79. package/dist/esm/session/handlers.mjs.map +1 -0
  80. package/dist/esm/session/ids.mjs +17 -0
  81. package/dist/esm/session/ids.mjs.map +1 -0
  82. package/dist/esm/session/messageSerialization.mjs +173 -0
  83. package/dist/esm/session/messageSerialization.mjs.map +1 -0
  84. package/dist/esm/stream.mjs +473 -12
  85. package/dist/esm/stream.mjs.map +1 -1
  86. package/dist/esm/summarization/node.mjs +1 -1
  87. package/dist/esm/summarization/node.mjs.map +1 -1
  88. package/dist/esm/tools/ToolNode.mjs +177 -59
  89. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  90. package/dist/esm/tools/eagerEventExecution.mjs +107 -0
  91. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -0
  92. package/dist/esm/tools/handlers.mjs +1 -1
  93. package/dist/esm/tools/handlers.mjs.map +1 -1
  94. package/dist/esm/tools/streamedToolCallSeals.mjs +36 -0
  95. package/dist/esm/tools/streamedToolCallSeals.mjs.map +1 -0
  96. package/dist/types/events.d.ts +1 -0
  97. package/dist/types/graphs/Graph.d.ts +24 -9
  98. package/dist/types/index.d.ts +1 -0
  99. package/dist/types/llm/openai/index.d.ts +1 -0
  100. package/dist/types/openai/index.d.ts +75 -0
  101. package/dist/types/responses/index.d.ts +97 -0
  102. package/dist/types/run.d.ts +2 -0
  103. package/dist/types/session/AgentSession.d.ts +32 -0
  104. package/dist/types/session/JsonlSessionStore.d.ts +67 -0
  105. package/dist/types/session/handlers.d.ts +8 -0
  106. package/dist/types/session/ids.d.ts +4 -0
  107. package/dist/types/session/index.d.ts +5 -0
  108. package/dist/types/session/messageSerialization.d.ts +7 -0
  109. package/dist/types/session/types.d.ts +191 -0
  110. package/dist/types/tools/ToolNode.d.ts +12 -1
  111. package/dist/types/tools/eagerEventExecution.d.ts +23 -0
  112. package/dist/types/tools/streamedToolCallSeals.d.ts +13 -0
  113. package/dist/types/types/hitl.d.ts +4 -0
  114. package/dist/types/types/run.d.ts +11 -1
  115. package/dist/types/types/tools.d.ts +36 -0
  116. package/package.json +19 -2
  117. package/src/__tests__/stream.eagerEventExecution.test.ts +2458 -0
  118. package/src/agents/AgentContext.ts +7 -2
  119. package/src/agents/__tests__/AgentContext.test.ts +254 -5
  120. package/src/events.ts +29 -0
  121. package/src/graphs/Graph.ts +224 -50
  122. package/src/graphs/MultiAgentGraph.ts +1 -1
  123. package/src/graphs/__tests__/composition.smoke.test.ts +30 -0
  124. package/src/index.ts +3 -0
  125. package/src/llm/anthropic/index.ts +356 -84
  126. package/src/llm/anthropic/llm.spec.ts +64 -0
  127. package/src/llm/custom-chat-models.smoke.test.ts +175 -4
  128. package/src/llm/openai/contentBlocks.test.ts +35 -0
  129. package/src/llm/openai/deepseek.test.ts +201 -2
  130. package/src/llm/openai/index.ts +171 -26
  131. package/src/llm/openai/utils/index.ts +22 -0
  132. package/src/llm/openrouter/index.ts +4 -2
  133. package/src/openai/__tests__/openai.test.ts +337 -0
  134. package/src/openai/index.ts +404 -0
  135. package/src/responses/__tests__/responses.test.ts +652 -0
  136. package/src/responses/index.ts +677 -0
  137. package/src/run.ts +158 -8
  138. package/src/scripts/compare_pi_vs_ours.ts +592 -173
  139. package/src/scripts/session_live.ts +548 -0
  140. package/src/session/AgentSession.ts +1432 -0
  141. package/src/session/JsonlSessionStore.ts +572 -0
  142. package/src/session/__tests__/JsonlSessionStore.test.ts +1410 -0
  143. package/src/session/__tests__/handlers.test.ts +161 -0
  144. package/src/session/handlers.ts +272 -0
  145. package/src/session/ids.ts +17 -0
  146. package/src/session/index.ts +44 -0
  147. package/src/session/messageSerialization.ts +207 -0
  148. package/src/session/types.ts +275 -0
  149. package/src/specs/custom-event-await.test.ts +89 -0
  150. package/src/specs/summarization.test.ts +1 -1
  151. package/src/stream.ts +755 -48
  152. package/src/summarization/node.ts +1 -1
  153. package/src/tools/ToolNode.ts +299 -126
  154. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +373 -0
  155. package/src/tools/__tests__/handlers.test.ts +2 -1
  156. package/src/tools/__tests__/hitl.test.ts +206 -110
  157. package/src/tools/eagerEventExecution.ts +153 -0
  158. package/src/tools/handlers.ts +8 -4
  159. package/src/tools/streamedToolCallSeals.ts +57 -0
  160. package/src/types/hitl.ts +4 -0
  161. package/src/types/run.ts +11 -0
  162. package/src/types/tools.ts +36 -0
  163. package/dist/cjs/llm/text.cjs +0 -69
  164. package/dist/cjs/llm/text.cjs.map +0 -1
  165. package/dist/esm/llm/text.mjs +0 -67
  166. package/dist/esm/llm/text.mjs.map +0 -1
@@ -35,11 +35,13 @@ import type { ChatGeneration, ChatResult } from '@langchain/core/outputs';
35
35
  import type { ChatXAIInput } from '@langchain/xai';
36
36
  import type * as t from '@langchain/openai';
37
37
  import { isReasoningModel, _convertMessagesToOpenAIParams } from './utils';
38
- import { sleep } from '@/utils';
39
38
 
40
39
  // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
41
40
  const iife = <T>(fn: () => T) => fn();
42
41
 
42
+ const STREAM_CHUNK_MIN_SIZE = 4;
43
+ const STREAM_BOUNDARIES = new Set([' ', '.', ',', '!', '?', ';', ':']);
44
+
43
45
  export function isHeaders(headers: unknown): headers is Headers {
44
46
  return (
45
47
  typeof Headers !== 'undefined' &&
@@ -403,18 +405,160 @@ function getCustomOpenAIClientOptions(
403
405
  return requestOptions;
404
406
  }
405
407
 
406
- async function* delayStreamChunks<T>(
407
- chunks: AsyncGenerator<T>,
408
- delay?: number
409
- ): AsyncGenerator<T> {
408
+ function findStreamChunkBoundary(text: string, minSize: number): number {
409
+ if (minSize >= text.length) {
410
+ return text.length;
411
+ }
412
+
413
+ for (let position = minSize; position < text.length; position++) {
414
+ if (STREAM_BOUNDARIES.has(text[position])) {
415
+ return position + 1;
416
+ }
417
+ }
418
+
419
+ return text.length;
420
+ }
421
+
422
+ function splitStreamToken(text: string): string[] {
423
+ const chunks: string[] = [];
424
+ let currentIndex = 0;
425
+
426
+ while (currentIndex < text.length) {
427
+ const remainingText = text.slice(currentIndex);
428
+ const chunkSize = findStreamChunkBoundary(
429
+ remainingText,
430
+ STREAM_CHUNK_MIN_SIZE
431
+ );
432
+ chunks.push(text.slice(currentIndex, currentIndex + chunkSize));
433
+ currentIndex += chunkSize;
434
+ }
435
+
436
+ return chunks;
437
+ }
438
+
439
+ function splitTextGenerationChunk(
440
+ chunk: ChatGenerationChunk
441
+ ): ChatGenerationChunk[] {
442
+ const { message } = chunk;
443
+ if (
444
+ !chunk.text ||
445
+ !(message instanceof AIMessageChunk) ||
446
+ typeof message.content !== 'string' ||
447
+ message.content !== chunk.text ||
448
+ chunk.generationInfo?.logprobs != null ||
449
+ chunk.generationInfo?.finish_reason != null
450
+ ) {
451
+ return [chunk];
452
+ }
453
+
454
+ const tokenChunks = splitStreamToken(chunk.text);
455
+ if (tokenChunks.length <= 1) {
456
+ return [chunk];
457
+ }
458
+
459
+ let emittedUsage = false;
460
+ return tokenChunks.map((token) => {
461
+ const usageMetadata =
462
+ emittedUsage && message.usage_metadata != null
463
+ ? undefined
464
+ : message.usage_metadata;
465
+ if (message.usage_metadata != null && !emittedUsage) {
466
+ emittedUsage = true;
467
+ }
468
+
469
+ return new ChatGenerationChunk({
470
+ text: token,
471
+ generationInfo: chunk.generationInfo,
472
+ message: new AIMessageChunk(
473
+ Object.assign({}, message, {
474
+ content: token,
475
+ usage_metadata: usageMetadata,
476
+ })
477
+ ),
478
+ });
479
+ });
480
+ }
481
+
482
+ export async function emitStreamChunkCallback(
483
+ chunk: ChatGenerationChunk,
484
+ runManager?: CallbackManagerForLLMRun
485
+ ): Promise<void> {
486
+ await runManager?.handleLLMNewToken(
487
+ chunk.text,
488
+ getStreamChunkTokenIndices(chunk),
489
+ undefined,
490
+ undefined,
491
+ undefined,
492
+ { chunk }
493
+ );
494
+ }
495
+
496
+ function getStreamChunkTokenIndices(
497
+ chunk: ChatGenerationChunk
498
+ ): { prompt: number; completion: number } | undefined {
499
+ const prompt = chunk.generationInfo?.prompt;
500
+ const completion = chunk.generationInfo?.completion;
501
+
502
+ if (typeof prompt === 'number' && typeof completion === 'number') {
503
+ return { prompt, completion };
504
+ }
505
+
506
+ return undefined;
507
+ }
508
+
509
+ async function* delayStreamChunks(
510
+ chunks: AsyncGenerator<ChatGenerationChunk>,
511
+ delay?: number,
512
+ signal?: AbortSignal,
513
+ runManager?: CallbackManagerForLLMRun
514
+ ): AsyncGenerator<ChatGenerationChunk> {
515
+ let lastYieldedAt: number | undefined;
410
516
  for await (const chunk of chunks) {
411
- yield chunk;
412
- if (delay != null) {
413
- await sleep(delay);
517
+ const outputChunks =
518
+ delay != null && delay > 0 ? splitTextGenerationChunk(chunk) : [chunk];
519
+ for (const outputChunk of outputChunks) {
520
+ signal?.throwIfAborted();
521
+ if (delay != null && delay > 0 && lastYieldedAt != null) {
522
+ const timeSinceLastYield = Date.now() - lastYieldedAt;
523
+ const timeToWait = Math.max(0, delay - timeSinceLastYield);
524
+ if (timeToWait > 0) {
525
+ await sleepWithAbort(timeToWait, signal);
526
+ }
527
+ }
528
+ signal?.throwIfAborted();
529
+ lastYieldedAt = Date.now();
530
+ await emitStreamChunkCallback(outputChunk, runManager);
531
+ signal?.throwIfAborted();
532
+ yield outputChunk;
414
533
  }
415
534
  }
416
535
  }
417
536
 
537
+ async function sleepWithAbort(
538
+ delay: number,
539
+ signal?: AbortSignal
540
+ ): Promise<void> {
541
+ if (delay <= 0) {
542
+ return;
543
+ }
544
+ signal?.throwIfAborted();
545
+ await new Promise<void>((resolve, reject) => {
546
+ const timeout = setTimeout(() => {
547
+ signal?.removeEventListener('abort', onAbort);
548
+ resolve();
549
+ }, delay);
550
+ const onAbort = (): void => {
551
+ clearTimeout(timeout);
552
+ signal?.removeEventListener('abort', onAbort);
553
+ reject(signal?.reason ?? new Error('AbortError: User aborted request.'));
554
+ };
555
+ signal?.addEventListener('abort', onAbort, { once: true });
556
+ if (signal?.aborted === true) {
557
+ onAbort();
558
+ }
559
+ });
560
+ }
561
+
418
562
  function createAbortHandler(controller: AbortController): () => void {
419
563
  return function (): void {
420
564
  controller.abort();
@@ -468,7 +612,7 @@ export class CustomOpenAIClient extends OpenAIClient {
468
612
  this.abortHandler = handler;
469
613
  if (signal) signal.addEventListener('abort', handler, { once: true });
470
614
 
471
- const timeout = setTimeout(() => handler, ms);
615
+ const timeout = setTimeout(handler, ms);
472
616
 
473
617
  const fetchOptions = {
474
618
  signal: controller.signal as AbortSignal,
@@ -503,7 +647,7 @@ export class CustomAzureOpenAIClient extends AzureOpenAIClient {
503
647
  this.abortHandler = handler;
504
648
  if (signal) signal.addEventListener('abort', handler, { once: true });
505
649
 
506
- const timeout = setTimeout(() => handler, ms);
650
+ const timeout = setTimeout(handler, ms);
507
651
 
508
652
  const fetchOptions = {
509
653
  signal: controller.signal as AbortSignal,
@@ -1184,8 +1328,10 @@ export class ChatOpenAI extends OriginalChatOpenAI<t.ChatOpenAICallOptions> {
1184
1328
  runManager?: CallbackManagerForLLMRun
1185
1329
  ): AsyncGenerator<ChatGenerationChunk> {
1186
1330
  yield* delayStreamChunks(
1187
- super._streamResponseChunks(messages, options, runManager),
1188
- this._lc_stream_delay
1331
+ super._streamResponseChunks(messages, options, undefined),
1332
+ this._lc_stream_delay,
1333
+ options.signal,
1334
+ runManager
1189
1335
  );
1190
1336
  }
1191
1337
  }
@@ -1294,8 +1440,10 @@ export class AzureChatOpenAI extends OriginalAzureChatOpenAI {
1294
1440
  runManager?: CallbackManagerForLLMRun
1295
1441
  ): AsyncGenerator<ChatGenerationChunk> {
1296
1442
  yield* delayStreamChunks(
1297
- super._streamResponseChunks(messages, options, runManager),
1298
- this._lc_stream_delay
1443
+ super._streamResponseChunks(messages, options, undefined),
1444
+ this._lc_stream_delay,
1445
+ options.signal,
1446
+ runManager
1299
1447
  );
1300
1448
  }
1301
1449
  }
@@ -1425,8 +1573,10 @@ export class ChatDeepSeek extends OriginalChatDeepSeek {
1425
1573
  runManager?: CallbackManagerForLLMRun
1426
1574
  ): AsyncGenerator<ChatGenerationChunk> {
1427
1575
  yield* delayStreamChunks(
1428
- this._streamResponseChunksWithReasoning(messages, options, runManager),
1429
- this._lc_stream_delay
1576
+ this._streamResponseChunksWithReasoning(messages, options, undefined),
1577
+ this._lc_stream_delay,
1578
+ options.signal,
1579
+ runManager
1430
1580
  );
1431
1581
  }
1432
1582
 
@@ -1767,14 +1917,7 @@ export class ChatDeepSeek extends OriginalChatDeepSeek {
1767
1917
  protected _getDeepSeekTokenIndices(
1768
1918
  chunk: ChatGenerationChunk
1769
1919
  ): { prompt: number; completion: number } | undefined {
1770
- const prompt = chunk.generationInfo?.prompt;
1771
- const completion = chunk.generationInfo?.completion;
1772
-
1773
- if (typeof prompt === 'number' && typeof completion === 'number') {
1774
- return { prompt, completion };
1775
- }
1776
-
1777
- return undefined;
1920
+ return getStreamChunkTokenIndices(chunk);
1778
1921
  }
1779
1922
 
1780
1923
  protected _getDeepSeekPartialTagSplitIndex(
@@ -1891,8 +2034,10 @@ export class ChatXAI extends OriginalChatXAI {
1891
2034
  runManager?: CallbackManagerForLLMRun
1892
2035
  ): AsyncGenerator<ChatGenerationChunk> {
1893
2036
  yield* delayStreamChunks(
1894
- super._streamResponseChunks(messages, options, runManager),
1895
- this._lc_stream_delay
2037
+ super._streamResponseChunks(messages, options, undefined),
2038
+ this._lc_stream_delay,
2039
+ options.signal,
2040
+ runManager
1896
2041
  );
1897
2042
  }
1898
2043
  }
@@ -38,6 +38,11 @@ import type {
38
38
  ChatOpenAIReasoningSummary,
39
39
  } from '@langchain/openai';
40
40
  import { toLangChainContent } from '@/messages/langchain';
41
+ import {
42
+ STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
43
+ STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
44
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
45
+ } from '@/tools/streamedToolCallSeals';
41
46
 
42
47
  export type { OpenAICallOptions, OpenAIChatInput };
43
48
 
@@ -948,6 +953,8 @@ export function _convertOpenAIResponsesDeltaToBaseMessageChunk(
948
953
  chunk.type === 'response.output_item.added' &&
949
954
  chunk.item.type === 'function_call'
950
955
  ) {
956
+ response_metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY] =
957
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER;
951
958
  tool_call_chunks.push({
952
959
  type: 'tool_call_chunk',
953
960
  name: chunk.item.name,
@@ -988,11 +995,26 @@ export function _convertOpenAIResponsesDeltaToBaseMessageChunk(
988
995
  if (key !== 'id') response_metadata[key] = value;
989
996
  }
990
997
  } else if (chunk.type === 'response.function_call_arguments.delta') {
998
+ response_metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY] =
999
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER;
991
1000
  tool_call_chunks.push({
992
1001
  type: 'tool_call_chunk',
993
1002
  args: chunk.delta,
994
1003
  index: chunk.output_index,
995
1004
  });
1005
+ } else if (chunk.type === 'response.function_call_arguments.done') {
1006
+ response_metadata[STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY] =
1007
+ OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER;
1008
+ response_metadata[STREAMED_TOOL_CALL_SEAL_METADATA_KEY] = {
1009
+ kind: 'single',
1010
+ index: chunk.output_index,
1011
+ };
1012
+ tool_call_chunks.push({
1013
+ type: 'tool_call_chunk',
1014
+ name: chunk.name,
1015
+ args: chunk.arguments,
1016
+ index: chunk.output_index,
1017
+ });
996
1018
  } else if (
997
1019
  chunk.type === 'response.web_search_call.completed' ||
998
1020
  chunk.type === 'response.file_search_call.completed'
@@ -1,4 +1,4 @@
1
- import { ChatOpenAI } from '@/llm/openai';
1
+ import { ChatOpenAI, emitStreamChunkCallback } from '@/llm/openai';
2
2
  import type { BaseMessage } from '@langchain/core/messages';
3
3
  import type { ChatGenerationChunk } from '@langchain/core/outputs';
4
4
  import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
@@ -237,7 +237,7 @@ export class ChatOpenRouter extends ChatOpenAI {
237
237
  for await (const generationChunk of super._streamResponseChunks(
238
238
  messages,
239
239
  options,
240
- runManager
240
+ undefined
241
241
  )) {
242
242
  let currentReasoningText = '';
243
243
  const reasoningDetails = getReasoningDetails(
@@ -283,11 +283,13 @@ export class ChatOpenRouter extends ChatOpenAI {
283
283
  } else {
284
284
  delete generationChunk.message.additional_kwargs.reasoning_details;
285
285
  }
286
+ await emitStreamChunkCallback(generationChunk, runManager);
286
287
  yield generationChunk;
287
288
  continue;
288
289
  }
289
290
 
290
291
  delete generationChunk.message.additional_kwargs.reasoning_details;
292
+ await emitStreamChunkCallback(generationChunk, runManager);
291
293
  yield generationChunk;
292
294
  }
293
295
  }
@@ -0,0 +1,337 @@
1
+ import { GraphEvents } from '@/common';
2
+ import {
3
+ createChatCompletionChunk,
4
+ createOpenAIHandlers,
5
+ createOpenAIStreamTracker,
6
+ sendOpenAIFinalChunk,
7
+ } from '@/openai';
8
+ import type * as t from '@/types';
9
+
10
+ describe('OpenAI-compatible adapters', () => {
11
+ it('creates chunks and streams message deltas as SSE data', async () => {
12
+ const writes: string[] = [];
13
+ const handlers = createOpenAIHandlers({
14
+ writer: { write: (data) => void writes.push(data) },
15
+ context: { requestId: 'chatcmpl_1', model: 'agent', created: 1 },
16
+ tracker: createOpenAIStreamTracker(),
17
+ });
18
+
19
+ await handlers[GraphEvents.ON_MESSAGE_DELTA].handle(
20
+ GraphEvents.ON_MESSAGE_DELTA,
21
+ {
22
+ id: 'msg',
23
+ delta: { content: [{ type: 'text', text: 'hello' }] },
24
+ } satisfies t.MessageDeltaEvent
25
+ );
26
+
27
+ expect(writes).toHaveLength(2);
28
+ expect(writes[0]).toContain('"role":"assistant"');
29
+ expect(writes[1]).toContain('"content":"hello"');
30
+ });
31
+
32
+ it('sends a final usage chunk and done marker', async () => {
33
+ const writes: string[] = [];
34
+ const tracker = createOpenAIStreamTracker();
35
+ tracker.usage.promptTokens = 3;
36
+ tracker.usage.completionTokens = 5;
37
+
38
+ await sendOpenAIFinalChunk({
39
+ writer: { write: (data) => void writes.push(data) },
40
+ context: { requestId: 'chatcmpl_2', model: 'agent', created: 1 },
41
+ tracker,
42
+ });
43
+
44
+ expect(writes).toHaveLength(4);
45
+ expect(writes[0]).toContain('"role":"assistant"');
46
+ expect(writes[1]).toContain('"finish_reason":"stop"');
47
+ expect(writes[1]).not.toContain('"usage"');
48
+ expect(writes[2]).toContain('"choices":[]');
49
+ expect(writes[2]).toContain('"total_tokens":8');
50
+ expect(writes[3]).toBe('data: [DONE]\n\n');
51
+ });
52
+
53
+ it('uses tool_calls finish reason after streaming tool deltas', async () => {
54
+ const writes: string[] = [];
55
+ const tracker = createOpenAIStreamTracker();
56
+ const handlers = createOpenAIHandlers({
57
+ writer: { write: (data) => void writes.push(data) },
58
+ context: { requestId: 'chatcmpl_tools', model: 'agent', created: 1 },
59
+ tracker,
60
+ });
61
+
62
+ await handlers[GraphEvents.ON_RUN_STEP_DELTA].handle(
63
+ GraphEvents.ON_RUN_STEP_DELTA,
64
+ {
65
+ id: 'step_1',
66
+ delta: {
67
+ type: 'tool_calls',
68
+ tool_calls: [
69
+ {
70
+ index: 0,
71
+ id: 'call_1',
72
+ name: 'search',
73
+ args: '{"query":"sessions"}',
74
+ },
75
+ ],
76
+ },
77
+ } as t.RunStepDeltaEvent
78
+ );
79
+ await sendOpenAIFinalChunk({
80
+ writer: { write: (data) => void writes.push(data) },
81
+ context: { requestId: 'chatcmpl_tools', model: 'agent', created: 1 },
82
+ tracker,
83
+ });
84
+
85
+ expect(writes[0]).toContain('"role":"assistant"');
86
+ expect(writes[1]).toContain('"tool_calls"');
87
+ expect(writes.at(-3)).toContain('"finish_reason":"tool_calls"');
88
+ expect(writes.at(-2)).toContain('"choices":[]');
89
+ });
90
+
91
+ it('uses stop finish reason when assistant text follows tool calls', async () => {
92
+ const writes: string[] = [];
93
+ const tracker = createOpenAIStreamTracker();
94
+ const handlers = createOpenAIHandlers({
95
+ writer: { write: (data) => void writes.push(data) },
96
+ context: { requestId: 'chatcmpl_tools_done', model: 'agent', created: 1 },
97
+ tracker,
98
+ });
99
+
100
+ await handlers[GraphEvents.ON_RUN_STEP_DELTA].handle(
101
+ GraphEvents.ON_RUN_STEP_DELTA,
102
+ {
103
+ id: 'step_1',
104
+ delta: {
105
+ type: 'tool_calls',
106
+ tool_calls: [{ index: 0, id: 'call_1', name: 'search' }],
107
+ },
108
+ } as t.RunStepDeltaEvent
109
+ );
110
+ await handlers[GraphEvents.ON_MESSAGE_DELTA].handle(
111
+ GraphEvents.ON_MESSAGE_DELTA,
112
+ {
113
+ id: 'msg',
114
+ delta: { content: [{ type: 'text', text: 'done' }] },
115
+ } satisfies t.MessageDeltaEvent
116
+ );
117
+ await handlers[GraphEvents.ON_RUN_STEP_DELTA].handle(
118
+ GraphEvents.ON_RUN_STEP_DELTA,
119
+ {
120
+ id: 'step_1',
121
+ delta: {
122
+ type: 'tool_calls',
123
+ tool_calls: [{ index: 0, id: 'call_1', name: 'search' }],
124
+ },
125
+ } as t.RunStepDeltaEvent
126
+ );
127
+ await sendOpenAIFinalChunk({
128
+ writer: { write: (data) => void writes.push(data) },
129
+ context: { requestId: 'chatcmpl_tools_done', model: 'agent', created: 1 },
130
+ tracker,
131
+ });
132
+
133
+ expect(writes.at(-3)).toContain('"finish_reason":"stop"');
134
+ expect(writes.at(-2)).toContain('"choices":[]');
135
+ });
136
+
137
+ it('scopes tool-call argument state by run step', async () => {
138
+ const writes: string[] = [];
139
+ const tracker = createOpenAIStreamTracker();
140
+ const handlers = createOpenAIHandlers({
141
+ writer: { write: (data) => void writes.push(data) },
142
+ context: { requestId: 'chatcmpl_step_tools', model: 'agent', created: 1 },
143
+ tracker,
144
+ });
145
+
146
+ await handlers[GraphEvents.ON_RUN_STEP_DELTA].handle(
147
+ GraphEvents.ON_RUN_STEP_DELTA,
148
+ {
149
+ id: 'step_1',
150
+ delta: {
151
+ type: 'tool_calls',
152
+ tool_calls: [
153
+ {
154
+ index: 0,
155
+ id: 'call_1',
156
+ name: 'search',
157
+ args: '{"query":"first"}',
158
+ },
159
+ ],
160
+ },
161
+ } as t.RunStepDeltaEvent
162
+ );
163
+ await handlers[GraphEvents.ON_RUN_STEP_DELTA].handle(
164
+ GraphEvents.ON_RUN_STEP_DELTA,
165
+ {
166
+ id: 'step_2',
167
+ delta: {
168
+ type: 'tool_calls',
169
+ tool_calls: [
170
+ {
171
+ index: 0,
172
+ id: 'call_2',
173
+ name: 'search',
174
+ args: '{"query":"second"}',
175
+ },
176
+ ],
177
+ },
178
+ } as t.RunStepDeltaEvent
179
+ );
180
+
181
+ const toolCallDeltas = writes
182
+ .map(
183
+ (data) =>
184
+ JSON.parse(data.slice(6)) as {
185
+ choices: Array<{
186
+ delta: {
187
+ tool_calls?: Array<{
188
+ id?: string;
189
+ function?: { name?: string; arguments?: string };
190
+ }>;
191
+ };
192
+ }>;
193
+ }
194
+ )
195
+ .flatMap((chunk) => chunk.choices[0].delta.tool_calls ?? []);
196
+
197
+ expect(toolCallDeltas).toHaveLength(2);
198
+ expect(toolCallDeltas[1]).toMatchObject({
199
+ id: 'call_2',
200
+ function: { name: 'search', arguments: '{"query":"second"}' },
201
+ });
202
+ expect(tracker.toolCalls.get(0)?.function.arguments).toBe(
203
+ '{"query":"second"}'
204
+ );
205
+ });
206
+
207
+ it('streams completed tool-call run steps without deltas', async () => {
208
+ const writes: string[] = [];
209
+ const tracker = createOpenAIStreamTracker();
210
+ const handlers = createOpenAIHandlers({
211
+ writer: { write: (data) => void writes.push(data) },
212
+ context: {
213
+ requestId: 'chatcmpl_complete_tools',
214
+ model: 'agent',
215
+ created: 1,
216
+ },
217
+ tracker,
218
+ });
219
+
220
+ await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, {
221
+ id: 'step_complete',
222
+ index: 2,
223
+ type: 'tool_calls',
224
+ stepDetails: {
225
+ type: 'tool_calls',
226
+ tool_calls: [
227
+ {
228
+ id: 'call_complete',
229
+ type: 'function',
230
+ function: {
231
+ name: 'search',
232
+ arguments: { query: 'sessions' },
233
+ },
234
+ },
235
+ ],
236
+ },
237
+ } as t.RunStep);
238
+ await sendOpenAIFinalChunk({
239
+ writer: { write: (data) => void writes.push(data) },
240
+ context: {
241
+ requestId: 'chatcmpl_complete_tools',
242
+ model: 'agent',
243
+ created: 1,
244
+ },
245
+ tracker,
246
+ });
247
+
248
+ expect(writes[0]).toContain('"role":"assistant"');
249
+ expect(writes[1]).toContain('"tool_calls"');
250
+ expect(writes[1]).toContain('"id":"call_complete"');
251
+ expect(writes[1]).toContain('"name":"search"');
252
+ expect(writes[1]).toContain('"{\\"query\\":\\"sessions\\"}"');
253
+ expect(writes.at(-3)).toContain('"finish_reason":"tool_calls"');
254
+ expect(writes.at(-2)).toContain('"choices":[]');
255
+ });
256
+
257
+ it('tracks partial usage metadata without NaN totals', async () => {
258
+ const tracker = createOpenAIStreamTracker();
259
+ const handlers = createOpenAIHandlers({
260
+ writer: { write: jest.fn() },
261
+ context: { requestId: 'chatcmpl_usage', model: 'agent', created: 1 },
262
+ tracker,
263
+ });
264
+
265
+ await handlers[GraphEvents.CHAT_MODEL_END].handle(
266
+ GraphEvents.CHAT_MODEL_END,
267
+ {
268
+ output: { usage_metadata: { input_tokens: 3 } },
269
+ } as t.ModelEndData
270
+ );
271
+ await handlers[GraphEvents.CHAT_MODEL_END].handle(
272
+ GraphEvents.CHAT_MODEL_END,
273
+ {
274
+ output: { usage_metadata: { output_tokens: 5 } },
275
+ } as t.ModelEndData
276
+ );
277
+
278
+ expect(tracker.usage.promptTokens).toBe(3);
279
+ expect(tracker.usage.completionTokens).toBe(5);
280
+ });
281
+
282
+ it('includes reasoning token usage in the final chunk', async () => {
283
+ const writes: string[] = [];
284
+ const tracker = createOpenAIStreamTracker();
285
+ const handlers = createOpenAIHandlers({
286
+ writer: { write: (data) => void writes.push(data) },
287
+ context: {
288
+ requestId: 'chatcmpl_reasoning_usage',
289
+ model: 'agent',
290
+ created: 1,
291
+ },
292
+ tracker,
293
+ });
294
+
295
+ await handlers[GraphEvents.CHAT_MODEL_END].handle(
296
+ GraphEvents.CHAT_MODEL_END,
297
+ {
298
+ output: {
299
+ usage_metadata: {
300
+ input_tokens: 3,
301
+ output_tokens: 5,
302
+ output_token_details: { reasoning: 2 },
303
+ },
304
+ },
305
+ } as t.ModelEndData
306
+ );
307
+ await sendOpenAIFinalChunk({
308
+ writer: { write: (data) => void writes.push(data) },
309
+ context: {
310
+ requestId: 'chatcmpl_reasoning_usage',
311
+ model: 'agent',
312
+ created: 1,
313
+ },
314
+ tracker,
315
+ });
316
+
317
+ expect(writes.at(-2)).toContain('"choices":[]');
318
+ expect(writes.at(-2)).toContain(
319
+ '"completion_tokens_details":{"reasoning_tokens":2}'
320
+ );
321
+ });
322
+
323
+ it('builds a chat completion chunk without transport dependencies', () => {
324
+ expect(
325
+ createChatCompletionChunk(
326
+ { requestId: 'chatcmpl_3', model: 'agent', created: 1 },
327
+ { content: 'x' }
328
+ )
329
+ ).toEqual({
330
+ id: 'chatcmpl_3',
331
+ object: 'chat.completion.chunk',
332
+ created: 1,
333
+ model: 'agent',
334
+ choices: [{ index: 0, delta: { content: 'x' }, finish_reason: null }],
335
+ });
336
+ });
337
+ });