@librechat/agents 3.3.12 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/cjs/graphs/Graph.cjs +10 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +1 -0
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuseSpanRegistry.cjs +6 -3
  6. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/index.cjs +35 -206
  8. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/index.cjs +121 -241
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/google/index.cjs +19 -12
  12. package/dist/cjs/llm/google/index.cjs.map +1 -1
  13. package/dist/cjs/llm/mistral/index.cjs +26 -0
  14. package/dist/cjs/llm/mistral/index.cjs.map +1 -0
  15. package/dist/cjs/llm/openai/index.cjs +82 -80
  16. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  18. package/dist/cjs/llm/providers.cjs +19 -19
  19. package/dist/cjs/llm/providers.cjs.map +1 -1
  20. package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
  21. package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
  22. package/dist/cjs/llm/stream/smoother.cjs +369 -0
  23. package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
  24. package/dist/cjs/llm/vertexai/index.cjs +13 -1
  25. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  26. package/dist/cjs/main.cjs +18 -10
  27. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +34 -2
  28. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  29. package/dist/cjs/utils/tokens.cjs +76 -29
  30. package/dist/cjs/utils/tokens.cjs.map +1 -1
  31. package/dist/esm/graphs/Graph.mjs +10 -0
  32. package/dist/esm/graphs/Graph.mjs.map +1 -1
  33. package/dist/esm/instrumentation.mjs +1 -0
  34. package/dist/esm/instrumentation.mjs.map +1 -1
  35. package/dist/esm/langfuseSpanRegistry.mjs +6 -3
  36. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -1
  37. package/dist/esm/llm/anthropic/index.mjs +34 -205
  38. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  39. package/dist/esm/llm/bedrock/index.mjs +120 -240
  40. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  41. package/dist/esm/llm/google/index.mjs +19 -12
  42. package/dist/esm/llm/google/index.mjs.map +1 -1
  43. package/dist/esm/llm/mistral/index.mjs +26 -0
  44. package/dist/esm/llm/mistral/index.mjs.map +1 -0
  45. package/dist/esm/llm/openai/index.mjs +82 -80
  46. package/dist/esm/llm/openai/index.mjs.map +1 -1
  47. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  48. package/dist/esm/llm/providers.mjs +3 -3
  49. package/dist/esm/llm/providers.mjs.map +1 -1
  50. package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
  51. package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
  52. package/dist/esm/llm/stream/smoother.mjs +365 -0
  53. package/dist/esm/llm/stream/smoother.mjs.map +1 -0
  54. package/dist/esm/llm/vertexai/index.mjs +13 -1
  55. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  56. package/dist/esm/main.mjs +4 -2
  57. package/dist/esm/tools/subagent/SubagentExecutor.mjs +34 -2
  58. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  59. package/dist/esm/utils/tokens.mjs +76 -30
  60. package/dist/esm/utils/tokens.mjs.map +1 -1
  61. package/dist/types/graphs/Graph.d.ts +3 -0
  62. package/dist/types/index.d.ts +3 -0
  63. package/dist/types/llm/google/index.d.ts +2 -0
  64. package/dist/types/llm/mistral/index.d.ts +11 -0
  65. package/dist/types/llm/openai/index.d.ts +20 -4
  66. package/dist/types/llm/openrouter/index.d.ts +4 -1
  67. package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
  68. package/dist/types/llm/stream/smoother.d.ts +95 -0
  69. package/dist/types/llm/vertexai/index.d.ts +2 -0
  70. package/dist/types/tools/subagent/SubagentExecutor.d.ts +3 -0
  71. package/dist/types/types/graph.d.ts +5 -0
  72. package/dist/types/types/llm.d.ts +21 -14
  73. package/dist/types/utils/tokens.d.ts +10 -0
  74. package/package.json +2 -2
  75. package/src/graphs/Graph.ts +11 -0
  76. package/src/index.ts +8 -0
  77. package/src/instrumentation.ts +1 -0
  78. package/src/langfuseSpanRegistry.ts +9 -0
  79. package/src/llm/anthropic/index.ts +85 -354
  80. package/src/llm/bedrock/index.ts +174 -390
  81. package/src/llm/bedrock/llm.spec.ts +2 -0
  82. package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
  83. package/src/llm/custom-chat-models.smoke.test.ts +16 -1
  84. package/src/llm/google/index.ts +17 -18
  85. package/src/llm/google/streamSmoothing.test.ts +121 -0
  86. package/src/llm/mistral/index.ts +33 -0
  87. package/src/llm/mistral/streamSmoothing.test.ts +97 -0
  88. package/src/llm/openai/deepseek.test.ts +56 -0
  89. package/src/llm/openai/index.ts +119 -126
  90. package/src/llm/openrouter/index.ts +4 -1
  91. package/src/llm/providers.ts +3 -3
  92. package/src/llm/stream/chunkAdapters.test.ts +202 -0
  93. package/src/llm/stream/chunkAdapters.ts +317 -0
  94. package/src/llm/stream/reassembly.test.ts +241 -0
  95. package/src/llm/stream/smoother.bench.test.ts +155 -0
  96. package/src/llm/stream/smoother.test.ts +519 -0
  97. package/src/llm/stream/smoother.ts +574 -0
  98. package/src/llm/vertexai/index.ts +17 -1
  99. package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
  100. package/src/specs/langfuse-instrumentation.test.ts +26 -0
  101. package/src/specs/langfuse-span-registry.test.ts +17 -0
  102. package/src/specs/subagent.test.ts +44 -0
  103. package/src/specs/summarization.test.ts +5 -2
  104. package/src/specs/tokens.test.ts +159 -14
  105. package/src/tools/__tests__/SubagentExecutor.test.ts +48 -1
  106. package/src/tools/subagent/SubagentExecutor.ts +69 -5
  107. package/src/types/graph.ts +5 -0
  108. package/src/types/llm.ts +53 -36
  109. package/src/utils/tokens.ts +115 -30
@@ -37,6 +37,7 @@ import type { BindToolsInput } from '@langchain/core/language_models/chat_models
37
37
  import type { ChatGeneration, ChatResult } from '@langchain/core/outputs';
38
38
  import type { ChatXAIInput } from '@langchain/xai';
39
39
  import type * as t from '@langchain/openai';
40
+ import type { SmoothItem, SmoothPiece } from '@/llm/stream/smoother';
40
41
  import type { ResponsesReplayPosition } from '@/messages/core';
41
42
  import type { SeenScalarMetadata } from './streamMetadata';
42
43
  import type { HeaderValue, HeadersLike } from './types';
@@ -58,14 +59,17 @@ import {
58
59
  } from '@/tools/streamedToolCallSeals';
59
60
  import { isReasoningModel, _convertMessagesToOpenAIParams } from './utils';
60
61
  import { INTENT_ARG, isIntentLabelProperty } from '@/tools/intentArg';
62
+ import { smoothStream, resolveStreamDelay } from '@/llm/stream/smoother';
63
+ import {
64
+ hasReasoningKwargs,
65
+ hasToolCallChunks,
66
+ getReasoningKwargsText,
67
+ } from '@/llm/stream/chunkAdapters';
61
68
  import { dropRepeatedScalarMetadata } from './streamMetadata';
62
69
 
63
70
  // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
64
71
  const iife = <T>(fn: () => T) => fn();
65
72
 
66
- const STREAM_CHUNK_MIN_SIZE = 4;
67
- const STREAM_BOUNDARIES = new Set([' ', '.', ',', '!', '?', ';', ':']);
68
-
69
73
  export function isHeaders(headers: unknown): headers is Headers {
70
74
  return (
71
75
  typeof Headers !== 'undefined' &&
@@ -1114,77 +1118,87 @@ function getCustomOpenAIClientOptions(
1114
1118
  return requestOptions;
1115
1119
  }
1116
1120
 
1117
- function findStreamChunkBoundary(text: string, minSize: number): number {
1118
- if (minSize >= text.length) {
1119
- return text.length;
1120
- }
1121
-
1122
- for (let position = minSize; position < text.length; position++) {
1123
- if (STREAM_BOUNDARIES.has(text[position])) {
1124
- return position + 1;
1125
- }
1126
- }
1127
-
1128
- return text.length;
1129
- }
1130
-
1131
- function splitStreamToken(text: string): string[] {
1132
- const chunks: string[] = [];
1133
- let currentIndex = 0;
1134
-
1135
- while (currentIndex < text.length) {
1136
- const remainingText = text.slice(currentIndex);
1137
- const chunkSize = findStreamChunkBoundary(
1138
- remainingText,
1139
- STREAM_CHUNK_MIN_SIZE
1140
- );
1141
- chunks.push(text.slice(currentIndex, currentIndex + chunkSize));
1142
- currentIndex += chunkSize;
1143
- }
1144
-
1145
- return chunks;
1146
- }
1147
-
1148
- function splitTextGenerationChunk(
1121
+ /**
1122
+ * Classifies a generation chunk for the smoothing engine:
1123
+ * - splittable: plain visible text (string content equal to `chunk.text`, no
1124
+ * logprobs / finish_reason) — sliced adaptively at the pacing cadence.
1125
+ * ANY logprobs value blocks splitting here (this family only attaches
1126
+ * logprobs on request; the DeepSeek suite pins chunks with them staying
1127
+ * intact) — deliberately stricter than `stream/chunkAdapters.ts`, where
1128
+ * google-common's always-present empty logprobs must not block.
1129
+ * - atomic: text- or reasoning-bearing chunks whose metadata cannot survive
1130
+ * slicing — paced as one piece, never split (legacy parity: these were
1131
+ * emitted whole but still paced).
1132
+ * - passthrough: tool-call deltas, usage-only, finish_reason and other
1133
+ * metadata chunks — strict FIFO, zero delay.
1134
+ */
1135
+ export function toSmoothItem(
1149
1136
  chunk: ChatGenerationChunk
1150
- ): ChatGenerationChunk[] {
1137
+ ): SmoothItem<ChatGenerationChunk> {
1151
1138
  const { message } = chunk;
1152
- if (
1153
- !chunk.text ||
1154
- !(message instanceof AIMessageChunk) ||
1155
- typeof message.content !== 'string' ||
1156
- message.content !== chunk.text ||
1157
- chunk.generationInfo?.logprobs != null ||
1158
- chunk.generationInfo?.finish_reason != null
1159
- ) {
1160
- return [chunk];
1139
+ const isMessageChunk = message instanceof AIMessageChunk;
1140
+ /** Chunks pairing visible text with a reasoning delta (reasoning_content,
1141
+ * reasoning summary, or OpenRouter reasoning_details) or with tool-call
1142
+ * deltas must pace whole: split pieces would each clone the same kwargs /
1143
+ * tool_call_chunks and downstream accumulation duplicates them per piece. */
1144
+ const splittable =
1145
+ Boolean(chunk.text) &&
1146
+ isMessageChunk &&
1147
+ typeof message.content === 'string' &&
1148
+ message.content === chunk.text &&
1149
+ chunk.generationInfo?.logprobs == null &&
1150
+ chunk.generationInfo?.finish_reason == null &&
1151
+ !hasReasoningKwargs(message) &&
1152
+ !hasToolCallChunks(message);
1153
+
1154
+ if (splittable) {
1155
+ return {
1156
+ text: chunk.text,
1157
+ smooth: true,
1158
+ emit: (piece) => cloneGenerationChunkPiece(chunk, piece),
1159
+ };
1161
1160
  }
1162
1161
 
1163
- const tokenChunks = splitStreamToken(chunk.text);
1164
- if (tokenChunks.length <= 1) {
1165
- return [chunk];
1162
+ const pacedText =
1163
+ chunk.text || (isMessageChunk ? getReasoningKwargsText(message) : '');
1164
+ if (pacedText !== '') {
1165
+ return {
1166
+ text: pacedText,
1167
+ smooth: true,
1168
+ atomic: true,
1169
+ emit: () => chunk,
1170
+ };
1166
1171
  }
1167
1172
 
1168
- let emittedUsage = false;
1169
- return tokenChunks.map((token) => {
1170
- const usageMetadata =
1171
- emittedUsage && message.usage_metadata != null
1172
- ? undefined
1173
- : message.usage_metadata;
1174
- if (message.usage_metadata != null && !emittedUsage) {
1175
- emittedUsage = true;
1176
- }
1173
+ return { text: '', smooth: false, emit: () => chunk };
1174
+ }
1177
1175
 
1178
- return new ChatGenerationChunk({
1179
- text: token,
1180
- generationInfo: chunk.generationInfo,
1181
- message: new AIMessageChunk(
1182
- Object.assign({}, message, {
1183
- content: token,
1184
- usage_metadata: usageMetadata,
1185
- })
1186
- ),
1187
- });
1176
+ /**
1177
+ * Usage metadata, additional kwargs and response metadata survive only on
1178
+ * the first piece: the aggregator's dict merge concatenates string fields
1179
+ * and sums usage, so replication across pieces corrupts them. Unlike the
1180
+ * generic adapter, `generationInfo` stays on every piece — per-piece token
1181
+ * indices ride in it and `dropRepeatedScalarMetadata` owns repetition there.
1182
+ */
1183
+ function cloneGenerationChunkPiece(
1184
+ chunk: ChatGenerationChunk,
1185
+ piece: SmoothPiece
1186
+ ): ChatGenerationChunk {
1187
+ if (piece.isFirst && piece.isLast) {
1188
+ return chunk;
1189
+ }
1190
+ const message = chunk.message as AIMessageChunk;
1191
+ return new ChatGenerationChunk({
1192
+ text: piece.text,
1193
+ generationInfo: chunk.generationInfo,
1194
+ message: new AIMessageChunk(
1195
+ Object.assign({}, message, {
1196
+ content: piece.text,
1197
+ usage_metadata: piece.isFirst ? message.usage_metadata : undefined,
1198
+ additional_kwargs: piece.isFirst ? message.additional_kwargs : {},
1199
+ response_metadata: piece.isFirst ? message.response_metadata : {},
1200
+ })
1201
+ ),
1188
1202
  });
1189
1203
  }
1190
1204
 
@@ -1215,66 +1229,45 @@ function getStreamChunkTokenIndices(
1215
1229
  return undefined;
1216
1230
  }
1217
1231
 
1232
+ /**
1233
+ * Adaptive smoothing adapter for the OpenAI chat-model family, layered over
1234
+ * the shared `smoothStream` engine. Keeps the historical signature so every
1235
+ * `_streamResponseChunks` call site is unchanged.
1236
+ *
1237
+ * `seenScalarMetadata`: when provided, de-duplicates repeated scalar metadata
1238
+ * just before emitting, so token callbacks and the yielded chunk observe the
1239
+ * same cleaned data. Omitted by callers that wrap this stream and finalize
1240
+ * downstream (e.g. `ChatOpenRouter`, which needs the raw `finish_reason` as
1241
+ * its flush signal and de-duplicates after its own processing).
1242
+ */
1218
1243
  async function* delayStreamChunks(
1219
1244
  chunks: AsyncGenerator<ChatGenerationChunk>,
1220
1245
  delay?: number,
1221
1246
  signal?: AbortSignal,
1222
1247
  runManager?: CallbackManagerForLLMRun,
1223
- // When provided, de-duplicate repeated scalar metadata just before emitting,
1224
- // so token callbacks and the yielded chunk observe the same cleaned data.
1225
- // Omitted by callers that wrap this stream and finalize downstream (e.g.
1226
- // `ChatOpenRouter`, which needs the raw `finish_reason` as its flush signal
1227
- // and de-duplicates after its own processing).
1228
1248
  seenScalarMetadata?: SeenScalarMetadata
1229
1249
  ): AsyncGenerator<ChatGenerationChunk> {
1230
- let lastYieldedAt: number | undefined;
1231
- for await (const chunk of chunks) {
1232
- const outputChunks =
1233
- delay != null && delay > 0 ? splitTextGenerationChunk(chunk) : [chunk];
1234
- for (const outputChunk of outputChunks) {
1235
- signal?.throwIfAborted();
1236
- if (delay != null && delay > 0 && lastYieldedAt != null) {
1237
- const timeSinceLastYield = Date.now() - lastYieldedAt;
1238
- const timeToWait = Math.max(0, delay - timeSinceLastYield);
1239
- if (timeToWait > 0) {
1240
- await sleepWithAbort(timeToWait, signal);
1241
- }
1242
- }
1243
- signal?.throwIfAborted();
1244
- lastYieldedAt = Date.now();
1245
- if (seenScalarMetadata != null) {
1246
- dropRepeatedScalarMetadata(outputChunk, seenScalarMetadata);
1247
- }
1248
- await emitStreamChunkCallback(outputChunk, runManager);
1249
- signal?.throwIfAborted();
1250
- yield outputChunk;
1251
- }
1252
- }
1253
- }
1250
+ const source = (async function* (): AsyncGenerator<
1251
+ SmoothItem<ChatGenerationChunk>
1252
+ > {
1253
+ for await (const chunk of chunks) {
1254
+ yield toSmoothItem(chunk);
1255
+ }
1256
+ })();
1257
+
1258
+ const smoothed = smoothStream({
1259
+ source,
1260
+ delayMs: delay != null && delay > 0 ? delay : 0,
1261
+ signal,
1262
+ });
1254
1263
 
1255
- async function sleepWithAbort(
1256
- delay: number,
1257
- signal?: AbortSignal
1258
- ): Promise<void> {
1259
- if (delay <= 0) {
1260
- return;
1261
- }
1262
- signal?.throwIfAborted();
1263
- await new Promise<void>((resolve, reject) => {
1264
- const timeout = setTimeout(() => {
1265
- signal?.removeEventListener('abort', onAbort);
1266
- resolve();
1267
- }, delay);
1268
- const onAbort = (): void => {
1269
- clearTimeout(timeout);
1270
- signal?.removeEventListener('abort', onAbort);
1271
- reject(signal?.reason ?? new Error('AbortError: User aborted request.'));
1272
- };
1273
- signal?.addEventListener('abort', onAbort, { once: true });
1274
- if (signal?.aborted === true) {
1275
- onAbort();
1264
+ for await (const outputChunk of smoothed) {
1265
+ if (seenScalarMetadata != null) {
1266
+ dropRepeatedScalarMetadata(outputChunk, seenScalarMetadata);
1276
1267
  }
1277
- });
1268
+ await emitStreamChunkCallback(outputChunk, runManager);
1269
+ yield outputChunk;
1270
+ }
1278
1271
  }
1279
1272
 
1280
1273
  function createAbortHandler(controller: AbortController): () => void {
@@ -2410,13 +2403,13 @@ function withLibreChatOpenAIFields(
2410
2403
  }
2411
2404
 
2412
2405
  export class ChatOpenAI extends OriginalChatOpenAI<t.ChatOpenAICallOptions> {
2413
- _lc_stream_delay?: number;
2406
+ _lc_stream_delay: number;
2414
2407
 
2415
2408
  constructor(
2416
2409
  fields?: LibreChatOpenAIFields & t.OpenAIChatInput['modelKwargs']
2417
2410
  ) {
2418
2411
  super(withLibreChatOpenAIFields(fields));
2419
- this._lc_stream_delay = fields?._lc_stream_delay;
2412
+ this._lc_stream_delay = resolveStreamDelay(fields?._lc_stream_delay);
2420
2413
  }
2421
2414
 
2422
2415
  public get exposedClient(): CustomOpenAIClient {
@@ -2507,13 +2500,13 @@ export class ChatOpenAI extends OriginalChatOpenAI<t.ChatOpenAICallOptions> {
2507
2500
  }
2508
2501
 
2509
2502
  export class AzureChatOpenAI extends OriginalAzureChatOpenAI {
2510
- _lc_stream_delay?: number;
2503
+ _lc_stream_delay: number;
2511
2504
 
2512
2505
  constructor(fields?: LibreChatAzureOpenAIFields) {
2513
2506
  super(fields);
2514
2507
  this.completions = new LibreChatAzureOpenAICompletions(fields);
2515
2508
  this.responses = new LibreChatAzureOpenAIResponses(fields);
2516
- this._lc_stream_delay = fields?._lc_stream_delay;
2509
+ this._lc_stream_delay = resolveStreamDelay(fields?._lc_stream_delay);
2517
2510
  }
2518
2511
 
2519
2512
  public get exposedClient(): CustomOpenAIClient {
@@ -2619,7 +2612,7 @@ export class AzureChatOpenAI extends OriginalAzureChatOpenAI {
2619
2612
  }
2620
2613
  }
2621
2614
  export class ChatDeepSeek extends OriginalChatDeepSeek {
2622
- _lc_stream_delay?: number;
2615
+ _lc_stream_delay: number;
2623
2616
 
2624
2617
  constructor(
2625
2618
  fields?: ConstructorParameters<typeof OriginalChatDeepSeek>[0] & {
@@ -2627,7 +2620,7 @@ export class ChatDeepSeek extends OriginalChatDeepSeek {
2627
2620
  }
2628
2621
  ) {
2629
2622
  super(fields);
2630
- this._lc_stream_delay = fields?._lc_stream_delay;
2623
+ this._lc_stream_delay = resolveStreamDelay(fields?._lc_stream_delay);
2631
2624
  }
2632
2625
 
2633
2626
  public get exposedClient(): CustomOpenAIClient {
@@ -3140,7 +3133,7 @@ export class ChatMoonshot extends ChatOpenAI {
3140
3133
  }
3141
3134
 
3142
3135
  export class ChatXAI extends OriginalChatXAI {
3143
- _lc_stream_delay?: number;
3136
+ _lc_stream_delay: number;
3144
3137
 
3145
3138
  constructor(
3146
3139
  fields?: Partial<ChatXAIInput> & {
@@ -3150,7 +3143,7 @@ export class ChatXAI extends OriginalChatXAI {
3150
3143
  }
3151
3144
  ) {
3152
3145
  super(fields);
3153
- this._lc_stream_delay = fields?._lc_stream_delay;
3146
+ this._lc_stream_delay = resolveStreamDelay(fields?._lc_stream_delay);
3154
3147
  const customBaseURL =
3155
3148
  fields?.configuration?.baseURL ?? fields?.clientConfig?.baseURL;
3156
3149
  if (customBaseURL != null && customBaseURL) {
@@ -45,7 +45,10 @@ export interface ChatOpenRouterCallOptions
45
45
 
46
46
  export type ChatOpenRouterInput = Partial<
47
47
  ChatOpenRouterCallOptions & OpenAIChatInput
48
- >;
48
+ > & {
49
+ /** Minimum delay in ms between visible streamed deltas (default 25; 0 disables). */
50
+ _lc_stream_delay?: number;
51
+ };
49
52
 
50
53
  /** invocationParams return type extended with OpenRouter reasoning */
51
54
  export type OpenRouterInvocationParams = Omit<
@@ -1,5 +1,5 @@
1
1
  // src/llm/providers.ts
2
- import { ChatMistralAI } from '@langchain/mistralai';
2
+ import { CustomChatMistralAI } from '@/llm/mistral';
3
3
  import type {
4
4
  ChatModelConstructorMap,
5
5
  ProviderOptionsMap,
@@ -25,8 +25,8 @@ export const llmProviders: Partial<ChatModelConstructorMap> = {
25
25
  [Providers.AZURE]: AzureChatOpenAI,
26
26
  [Providers.VERTEXAI]: ChatVertexAI,
27
27
  [Providers.DEEPSEEK]: ChatDeepSeek,
28
- [Providers.MISTRALAI]: ChatMistralAI,
29
- [Providers.MISTRAL]: ChatMistralAI,
28
+ [Providers.MISTRALAI]: CustomChatMistralAI,
29
+ [Providers.MISTRAL]: CustomChatMistralAI,
30
30
  [Providers.ANTHROPIC]: CustomAnthropic,
31
31
  [Providers.OPENROUTER]: ChatOpenRouter,
32
32
  [Providers.BEDROCK]: CustomChatBedrockConverse,
@@ -0,0 +1,202 @@
1
+ import { AIMessageChunk } from '@langchain/core/messages';
2
+ import { ChatGenerationChunk } from '@langchain/core/outputs';
3
+ import {
4
+ toGenerationSmoothItem,
5
+ getReasoningKwargsText,
6
+ cloneGenerationChunkPiece,
7
+ } from './chunkAdapters';
8
+
9
+ type MessageChunkFields = {
10
+ usage_metadata?: AIMessageChunk['usage_metadata'];
11
+ additional_kwargs?: AIMessageChunk['additional_kwargs'];
12
+ response_metadata?: AIMessageChunk['response_metadata'];
13
+ };
14
+
15
+ function textChunk(
16
+ text: string,
17
+ extra: MessageChunkFields = {}
18
+ ): ChatGenerationChunk {
19
+ return new ChatGenerationChunk({
20
+ text,
21
+ message: new AIMessageChunk({ content: text, ...extra }),
22
+ });
23
+ }
24
+
25
+ describe('toGenerationSmoothItem classification', () => {
26
+ it('splits plain string-content text chunks', () => {
27
+ const item = toGenerationSmoothItem(textChunk('alpha beta gamma'));
28
+ expect(item.smooth).toBe(true);
29
+ expect(item.atomic).toBeUndefined();
30
+ expect(item.text).toBe('alpha beta gamma');
31
+ });
32
+
33
+ it('keeps chunks carrying reasoning_content kwargs atomic', () => {
34
+ const chunk = textChunk('visible text here', {
35
+ additional_kwargs: { reasoning_content: 'hidden thought' },
36
+ });
37
+ const item = toGenerationSmoothItem(chunk);
38
+ expect(item.smooth).toBe(true);
39
+ expect(item.atomic).toBe(true);
40
+ expect(item.emit({ text: item.text, isFirst: true, isLast: true })).toBe(
41
+ chunk
42
+ );
43
+ });
44
+
45
+ it('keeps chunks carrying a reasoning summary object atomic', () => {
46
+ const chunk = textChunk('visible text here', {
47
+ additional_kwargs: { reasoning: { summary: [{ text: 'thought' }] } },
48
+ });
49
+ expect(toGenerationSmoothItem(chunk).atomic).toBe(true);
50
+ });
51
+
52
+ it('keeps chunks carrying OpenRouter reasoning_details atomic', () => {
53
+ const chunk = textChunk('visible text here', {
54
+ additional_kwargs: {
55
+ reasoning_details: [{ type: 'reasoning.text', text: 'thought' }],
56
+ },
57
+ });
58
+ expect(toGenerationSmoothItem(chunk).atomic).toBe(true);
59
+ });
60
+
61
+ it('keeps chunks carrying camelCase finishReason atomic', () => {
62
+ const chunk = new ChatGenerationChunk({
63
+ text: 'final text with several words here',
64
+ generationInfo: { finishReason: 'STOP' },
65
+ message: new AIMessageChunk({
66
+ content: 'final text with several words here',
67
+ }),
68
+ });
69
+ const item = toGenerationSmoothItem(chunk);
70
+ expect(item.atomic).toBe(true);
71
+ expect(item.emit({ text: item.text, isFirst: true, isLast: true })).toBe(
72
+ chunk
73
+ );
74
+ });
75
+
76
+ it('keeps mixed text/tool-call chunks atomic', () => {
77
+ const chunk = new ChatGenerationChunk({
78
+ text: 'calling the weather tool for you now',
79
+ message: new AIMessageChunk({
80
+ content: 'calling the weather tool for you now',
81
+ tool_call_chunks: [
82
+ { name: 'weather', args: '{"city":', id: 'call_1', index: 0 },
83
+ ],
84
+ }),
85
+ });
86
+ const item = toGenerationSmoothItem(chunk);
87
+ expect(item.atomic).toBe(true);
88
+ expect(item.emit({ text: item.text, isFirst: true, isLast: true })).toBe(
89
+ chunk
90
+ );
91
+ });
92
+
93
+ it('paces reasoning-only chunks atomically via the kwargs extractor', () => {
94
+ const thoughtOnly = new ChatGenerationChunk({
95
+ text: '',
96
+ message: new AIMessageChunk({
97
+ content: '',
98
+ additional_kwargs: { reasoning: 'a hidden gemini thought' },
99
+ }),
100
+ });
101
+ const item = toGenerationSmoothItem(thoughtOnly, getReasoningKwargsText);
102
+ expect(item.smooth).toBe(true);
103
+ expect(item.atomic).toBe(true);
104
+ expect(item.text).toBe('a hidden gemini thought');
105
+ });
106
+
107
+ it('paces reasoning_details-only chunks atomically via the kwargs extractor', () => {
108
+ const detailsOnly = new ChatGenerationChunk({
109
+ text: '',
110
+ message: new AIMessageChunk({
111
+ content: '',
112
+ additional_kwargs: {
113
+ reasoning_details: [
114
+ { type: 'reasoning.text', text: 'first thought ' },
115
+ { type: 'reasoning.text', text: 'second thought' },
116
+ ],
117
+ },
118
+ }),
119
+ });
120
+ const item = toGenerationSmoothItem(detailsOnly, getReasoningKwargsText);
121
+ expect(item.smooth).toBe(true);
122
+ expect(item.atomic).toBe(true);
123
+ expect(item.text).toBe('first thought second thought');
124
+ });
125
+
126
+ it('classifies usage-only chunks as passthrough', () => {
127
+ const chunk = new ChatGenerationChunk({
128
+ text: '',
129
+ message: new AIMessageChunk({
130
+ content: '',
131
+ usage_metadata: { input_tokens: 1, output_tokens: 2, total_tokens: 3 },
132
+ }),
133
+ });
134
+ const item = toGenerationSmoothItem(chunk);
135
+ expect(item.smooth).toBe(false);
136
+ expect(item.text).toBe('');
137
+ });
138
+ });
139
+
140
+ describe('cloneGenerationChunkPiece metadata scoping', () => {
141
+ const chunk = textChunk('alpha beta gamma', {
142
+ usage_metadata: { input_tokens: 1, output_tokens: 2, total_tokens: 3 },
143
+ additional_kwargs: { annotation: 'once' },
144
+ response_metadata: { model_name: 'test-model' },
145
+ });
146
+
147
+ it('keeps kwargs, response metadata and usage on the first piece only', () => {
148
+ const first = cloneGenerationChunkPiece(chunk, {
149
+ text: 'alpha ',
150
+ isFirst: true,
151
+ isLast: false,
152
+ });
153
+ const later = cloneGenerationChunkPiece(chunk, {
154
+ text: 'beta ',
155
+ isFirst: false,
156
+ isLast: false,
157
+ });
158
+
159
+ const firstMessage = first.message as AIMessageChunk;
160
+ const laterMessage = later.message as AIMessageChunk;
161
+ expect(firstMessage.additional_kwargs).toEqual({ annotation: 'once' });
162
+ expect(firstMessage.response_metadata).toEqual({
163
+ model_name: 'test-model',
164
+ });
165
+ expect(firstMessage.usage_metadata).toBeDefined();
166
+ expect(laterMessage.additional_kwargs).toEqual({});
167
+ expect(laterMessage.response_metadata).toEqual({});
168
+ expect(laterMessage.usage_metadata).toBeUndefined();
169
+ });
170
+
171
+ it('keeps generationInfo on the first piece only', () => {
172
+ const infoChunk = new ChatGenerationChunk({
173
+ text: 'alpha beta gamma',
174
+ generationInfo: {
175
+ usage_metadata: { input_tokens: 1, output_tokens: 2, total_tokens: 3 },
176
+ },
177
+ message: new AIMessageChunk({ content: 'alpha beta gamma' }),
178
+ });
179
+ const first = cloneGenerationChunkPiece(infoChunk, {
180
+ text: 'alpha ',
181
+ isFirst: true,
182
+ isLast: false,
183
+ });
184
+ const later = cloneGenerationChunkPiece(infoChunk, {
185
+ text: 'beta ',
186
+ isFirst: false,
187
+ isLast: false,
188
+ });
189
+ expect(first.generationInfo).toBeDefined();
190
+ expect(later.generationInfo).toBeUndefined();
191
+ });
192
+
193
+ it('returns the original chunk for unsplit pieces', () => {
194
+ expect(
195
+ cloneGenerationChunkPiece(chunk, {
196
+ text: 'alpha beta gamma',
197
+ isFirst: true,
198
+ isLast: true,
199
+ })
200
+ ).toBe(chunk);
201
+ });
202
+ });