@librechat/agents 3.3.13 → 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.
- package/dist/cjs/graphs/Graph.cjs +10 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/index.cjs +35 -206
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +121 -241
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/google/index.cjs +19 -12
- package/dist/cjs/llm/google/index.cjs.map +1 -1
- package/dist/cjs/llm/mistral/index.cjs +26 -0
- package/dist/cjs/llm/mistral/index.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +82 -80
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/providers.cjs +19 -19
- package/dist/cjs/llm/providers.cjs.map +1 -1
- package/dist/cjs/llm/stream/chunkAdapters.cjs +198 -0
- package/dist/cjs/llm/stream/chunkAdapters.cjs.map +1 -0
- package/dist/cjs/llm/stream/smoother.cjs +369 -0
- package/dist/cjs/llm/stream/smoother.cjs.map +1 -0
- package/dist/cjs/llm/vertexai/index.cjs +13 -1
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +18 -10
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +34 -2
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +76 -29
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +10 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +34 -205
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +120 -240
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/google/index.mjs +19 -12
- package/dist/esm/llm/google/index.mjs.map +1 -1
- package/dist/esm/llm/mistral/index.mjs +26 -0
- package/dist/esm/llm/mistral/index.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +82 -80
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/providers.mjs +3 -3
- package/dist/esm/llm/providers.mjs.map +1 -1
- package/dist/esm/llm/stream/chunkAdapters.mjs +195 -0
- package/dist/esm/llm/stream/chunkAdapters.mjs.map +1 -0
- package/dist/esm/llm/stream/smoother.mjs +365 -0
- package/dist/esm/llm/stream/smoother.mjs.map +1 -0
- package/dist/esm/llm/vertexai/index.mjs +13 -1
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +4 -2
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +34 -2
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +76 -30
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +3 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/llm/google/index.d.ts +2 -0
- package/dist/types/llm/mistral/index.d.ts +11 -0
- package/dist/types/llm/openai/index.d.ts +20 -4
- package/dist/types/llm/openrouter/index.d.ts +4 -1
- package/dist/types/llm/stream/chunkAdapters.d.ts +48 -0
- package/dist/types/llm/stream/smoother.d.ts +95 -0
- package/dist/types/llm/vertexai/index.d.ts +2 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +3 -0
- package/dist/types/types/llm.d.ts +21 -14
- package/dist/types/utils/tokens.d.ts +10 -0
- package/package.json +2 -2
- package/src/graphs/Graph.ts +11 -0
- package/src/index.ts +8 -0
- package/src/llm/anthropic/index.ts +85 -354
- package/src/llm/bedrock/index.ts +174 -390
- package/src/llm/bedrock/llm.spec.ts +2 -0
- package/src/llm/bedrock/streamSealDispatch.test.ts +76 -0
- package/src/llm/custom-chat-models.smoke.test.ts +16 -1
- package/src/llm/google/index.ts +17 -18
- package/src/llm/google/streamSmoothing.test.ts +121 -0
- package/src/llm/mistral/index.ts +33 -0
- package/src/llm/mistral/streamSmoothing.test.ts +97 -0
- package/src/llm/openai/deepseek.test.ts +56 -0
- package/src/llm/openai/index.ts +119 -126
- package/src/llm/openrouter/index.ts +4 -1
- package/src/llm/providers.ts +3 -3
- package/src/llm/stream/chunkAdapters.test.ts +202 -0
- package/src/llm/stream/chunkAdapters.ts +317 -0
- package/src/llm/stream/reassembly.test.ts +241 -0
- package/src/llm/stream/smoother.bench.test.ts +155 -0
- package/src/llm/stream/smoother.test.ts +519 -0
- package/src/llm/stream/smoother.ts +574 -0
- package/src/llm/vertexai/index.ts +17 -1
- package/src/llm/vertexai/streamSmoothing.test.ts +109 -0
- package/src/specs/subagent.test.ts +44 -0
- package/src/specs/tokens.test.ts +159 -14
- package/src/tools/__tests__/SubagentExecutor.test.ts +48 -1
- package/src/tools/subagent/SubagentExecutor.ts +69 -5
- package/src/types/llm.ts +53 -36
- package/src/utils/tokens.ts +115 -30
|
@@ -252,4 +252,80 @@ describe('Converse stream seal dispatch', () => {
|
|
|
252
252
|
setTimeoutSpy.mockRestore();
|
|
253
253
|
}
|
|
254
254
|
});
|
|
255
|
+
|
|
256
|
+
test('emits toolUse seal chunks without pacing delay while text is paced', async () => {
|
|
257
|
+
const model = new CustomChatBedrockConverse({
|
|
258
|
+
model: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
|
259
|
+
region: 'us-east-1',
|
|
260
|
+
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
|
|
261
|
+
_lc_stream_delay: 60,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
(model as unknown as { client: { send: unknown } }).client.send = jest.fn(
|
|
265
|
+
async () => ({
|
|
266
|
+
stream: (async function* () {
|
|
267
|
+
yield {
|
|
268
|
+
contentBlockDelta: {
|
|
269
|
+
contentBlockIndex: 0,
|
|
270
|
+
delta: { text: 'alpha beta' },
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
yield {
|
|
274
|
+
contentBlockStart: {
|
|
275
|
+
contentBlockIndex: 1,
|
|
276
|
+
start: { toolUse: { toolUseId: 'call_1', name: 'weather' } },
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
yield {
|
|
280
|
+
contentBlockDelta: {
|
|
281
|
+
contentBlockIndex: 1,
|
|
282
|
+
delta: { toolUse: { input: '{"city":"NYC"}' } },
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
yield { contentBlockStop: { contentBlockIndex: 1 } };
|
|
286
|
+
})(),
|
|
287
|
+
})
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const arrivals: { isSeal: boolean; hasText: boolean; at: number }[] = [];
|
|
291
|
+
for await (const chunk of model._streamResponseChunks(
|
|
292
|
+
[new HumanMessage('hi')],
|
|
293
|
+
{} as Parameters<CustomChatBedrockConverse['_streamResponseChunks']>[1],
|
|
294
|
+
undefined
|
|
295
|
+
)) {
|
|
296
|
+
const message = chunk.message as AIMessageChunk;
|
|
297
|
+
arrivals.push({
|
|
298
|
+
isSeal:
|
|
299
|
+
(message.response_metadata as Record<string, unknown>)[
|
|
300
|
+
STREAMED_TOOL_CALL_SEAL_METADATA_KEY
|
|
301
|
+
] != null,
|
|
302
|
+
hasText: chunk.text !== '',
|
|
303
|
+
at: Date.now(),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const lastText = [...arrivals].reverse().find((entry) => entry.hasText);
|
|
308
|
+
const seal = arrivals.find((entry) => entry.isSeal);
|
|
309
|
+
expect(lastText).toBeDefined();
|
|
310
|
+
expect(seal).toBeDefined();
|
|
311
|
+
expect((seal as { at: number }).at).toBeGreaterThanOrEqual(
|
|
312
|
+
(lastText as { at: number }).at
|
|
313
|
+
);
|
|
314
|
+
expect(
|
|
315
|
+
(seal as { at: number }).at - (lastText as { at: number }).at
|
|
316
|
+
).toBeLessThan(45);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('defaults to 25ms adaptive smoothing with 0 disabling', () => {
|
|
320
|
+
const base = {
|
|
321
|
+
model: 'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
|
322
|
+
region: 'us-east-1',
|
|
323
|
+
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
|
|
324
|
+
};
|
|
325
|
+
expect(new CustomChatBedrockConverse(base)._lc_stream_delay).toBe(25);
|
|
326
|
+
expect(
|
|
327
|
+
new CustomChatBedrockConverse({ ...base, _lc_stream_delay: 0 })
|
|
328
|
+
._lc_stream_delay
|
|
329
|
+
).toBe(0);
|
|
330
|
+
});
|
|
255
331
|
});
|
|
@@ -1836,7 +1836,7 @@ describe('custom chat model class smoke tests', () => {
|
|
|
1836
1836
|
);
|
|
1837
1837
|
expect(model.applicationInferenceProfile).toBe(applicationInferenceProfile);
|
|
1838
1838
|
expect(model._lc_stream_delay).toBe(12);
|
|
1839
|
-
expect(defaultStreamDelayModel._lc_stream_delay).toBe(
|
|
1839
|
+
expect(defaultStreamDelayModel._lc_stream_delay).toBe(25);
|
|
1840
1840
|
expect(model.invocationParams({}).serviceTier).toEqual({
|
|
1841
1841
|
type: 'priority',
|
|
1842
1842
|
});
|
|
@@ -1949,3 +1949,18 @@ describe('custom chat model class smoke tests', () => {
|
|
|
1949
1949
|
expect(capturedFetch.getSignal()?.aborted).toBe(true);
|
|
1950
1950
|
});
|
|
1951
1951
|
});
|
|
1952
|
+
|
|
1953
|
+
describe('StreamSmoothingOptions type surface', () => {
|
|
1954
|
+
it('accepts _lc_stream_delay: 0 across the typed provider options', () => {
|
|
1955
|
+
const azure: import('@/types').AzureClientOptions = { _lc_stream_delay: 0 };
|
|
1956
|
+
const openrouter: ChatOpenRouterCallOptions &
|
|
1957
|
+
import('@/types').StreamSmoothingOptions = { _lc_stream_delay: 0 };
|
|
1958
|
+
const mistral: import('@/types').MistralAIClientOptions = {
|
|
1959
|
+
model: 'mistral-large-latest',
|
|
1960
|
+
_lc_stream_delay: 0,
|
|
1961
|
+
};
|
|
1962
|
+
expect(azure._lc_stream_delay).toBe(0);
|
|
1963
|
+
expect(openrouter._lc_stream_delay).toBe(0);
|
|
1964
|
+
expect(mistral._lc_stream_delay).toBe(0);
|
|
1965
|
+
});
|
|
1966
|
+
});
|
package/src/llm/google/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager
|
|
|
16
16
|
import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
|
|
17
17
|
import type { GeminiApiUsageMetadata, InputTokenDetails } from './types';
|
|
18
18
|
import type { GoogleClientOptions, GoogleThinkingConfig } from '@/types';
|
|
19
|
+
import { smoothGenerationChunks } from '@/llm/stream/chunkAdapters';
|
|
20
|
+
import { resolveStreamDelay } from '@/llm/stream/smoother';
|
|
19
21
|
import {
|
|
20
22
|
convertResponseContentToChatGenerationChunk,
|
|
21
23
|
convertBaseMessagesToContent,
|
|
@@ -36,6 +38,7 @@ type GoogleToolConfigWithServerSideInvocations = ToolConfig & {
|
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
export class CustomChatGoogleGenerativeAI extends ChatGoogleGenerativeAI {
|
|
41
|
+
_lc_stream_delay: number;
|
|
39
42
|
thinkingConfig?: GoogleThinkingConfig;
|
|
40
43
|
includeServerSideToolInvocations?: boolean;
|
|
41
44
|
|
|
@@ -55,6 +58,7 @@ export class CustomChatGoogleGenerativeAI extends ChatGoogleGenerativeAI {
|
|
|
55
58
|
constructor(fields: GoogleClientOptions) {
|
|
56
59
|
super(fields);
|
|
57
60
|
|
|
61
|
+
this._lc_stream_delay = resolveStreamDelay(fields._lc_stream_delay);
|
|
58
62
|
this.model = fields.model.replace(/^models\//, '');
|
|
59
63
|
|
|
60
64
|
this.maxOutputTokens = fields.maxOutputTokens ?? this.maxOutputTokens;
|
|
@@ -296,6 +300,18 @@ export class CustomChatGoogleGenerativeAI extends ChatGoogleGenerativeAI {
|
|
|
296
300
|
messages: BaseMessage[],
|
|
297
301
|
options: this['ParsedCallOptions'],
|
|
298
302
|
runManager?: CallbackManagerForLLMRun
|
|
303
|
+
): AsyncGenerator<ChatGenerationChunk> {
|
|
304
|
+
yield* smoothGenerationChunks({
|
|
305
|
+
chunks: this._streamProviderChunks(messages, options),
|
|
306
|
+
delayMs: this._lc_stream_delay,
|
|
307
|
+
signal: options.signal,
|
|
308
|
+
runManager,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async *_streamProviderChunks(
|
|
313
|
+
messages: BaseMessage[],
|
|
314
|
+
options: this['ParsedCallOptions']
|
|
299
315
|
): AsyncGenerator<ChatGenerationChunk> {
|
|
300
316
|
const prompt = convertBaseMessagesToContent(
|
|
301
317
|
messages,
|
|
@@ -349,33 +365,16 @@ export class CustomChatGoogleGenerativeAI extends ChatGoogleGenerativeAI {
|
|
|
349
365
|
}
|
|
350
366
|
|
|
351
367
|
yield chunk;
|
|
352
|
-
await runManager?.handleLLMNewToken(
|
|
353
|
-
chunk.text || '',
|
|
354
|
-
undefined,
|
|
355
|
-
undefined,
|
|
356
|
-
undefined,
|
|
357
|
-
undefined,
|
|
358
|
-
{ chunk }
|
|
359
|
-
);
|
|
360
368
|
}
|
|
361
369
|
|
|
362
370
|
if (lastUsageMetadata) {
|
|
363
|
-
|
|
371
|
+
yield new ChatGenerationChunk({
|
|
364
372
|
text: '',
|
|
365
373
|
message: new AIMessageChunk({
|
|
366
374
|
content: '',
|
|
367
375
|
usage_metadata: lastUsageMetadata,
|
|
368
376
|
}),
|
|
369
377
|
});
|
|
370
|
-
yield finalChunk;
|
|
371
|
-
await runManager?.handleLLMNewToken(
|
|
372
|
-
finalChunk.text || '',
|
|
373
|
-
undefined,
|
|
374
|
-
undefined,
|
|
375
|
-
undefined,
|
|
376
|
-
undefined,
|
|
377
|
-
{ chunk: finalChunk }
|
|
378
|
-
);
|
|
379
378
|
}
|
|
380
379
|
}
|
|
381
380
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { expect, test, describe, jest } from '@jest/globals';
|
|
2
|
+
import { HumanMessage, AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
4
|
+
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
5
|
+
import { CustomChatGoogleGenerativeAI } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Google stream smoothing', () => {
|
|
8
|
+
function textResponse(text: string): Record<string, unknown> {
|
|
9
|
+
return {
|
|
10
|
+
candidates: [
|
|
11
|
+
{
|
|
12
|
+
content: { role: 'model', parts: [{ text }] },
|
|
13
|
+
index: 0,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function runStream(
|
|
20
|
+
responses: Record<string, unknown>[],
|
|
21
|
+
modelFields: Record<string, unknown> = {}
|
|
22
|
+
): Promise<{
|
|
23
|
+
yielded: ChatGenerationChunk[];
|
|
24
|
+
dispatchedTokens: string[];
|
|
25
|
+
}> {
|
|
26
|
+
const model = new CustomChatGoogleGenerativeAI({
|
|
27
|
+
model: 'gemini-2.5-flash',
|
|
28
|
+
apiKey: 'test-key',
|
|
29
|
+
...modelFields,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
(
|
|
33
|
+
model as unknown as {
|
|
34
|
+
client: { generateContentStream: unknown };
|
|
35
|
+
}
|
|
36
|
+
).client.generateContentStream = jest.fn(async () => ({
|
|
37
|
+
stream: (async function* () {
|
|
38
|
+
yield* responses;
|
|
39
|
+
})(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const dispatchedTokens: string[] = [];
|
|
43
|
+
const runManager = {
|
|
44
|
+
handleLLMNewToken: jest.fn(async (token: string) => {
|
|
45
|
+
dispatchedTokens.push(token);
|
|
46
|
+
}),
|
|
47
|
+
} as unknown as CallbackManagerForLLMRun;
|
|
48
|
+
|
|
49
|
+
const yielded: ChatGenerationChunk[] = [];
|
|
50
|
+
for await (const chunk of model._streamResponseChunks(
|
|
51
|
+
[new HumanMessage('hi')],
|
|
52
|
+
{} as Parameters<
|
|
53
|
+
CustomChatGoogleGenerativeAI['_streamResponseChunks']
|
|
54
|
+
>[1],
|
|
55
|
+
runManager
|
|
56
|
+
)) {
|
|
57
|
+
yielded.push(chunk);
|
|
58
|
+
}
|
|
59
|
+
return { yielded, dispatchedTokens };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('splits large text responses at stream boundaries with pacing', async () => {
|
|
63
|
+
const { yielded, dispatchedTokens } = await runStream(
|
|
64
|
+
[textResponse('alpha beta gamma')],
|
|
65
|
+
{ _lc_stream_delay: 1 }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const texts = yielded.map((chunk) => chunk.text).filter(Boolean);
|
|
69
|
+
expect(texts).toEqual(['alpha ', 'beta ', 'gamma']);
|
|
70
|
+
expect(dispatchedTokens.filter(Boolean)).toEqual([
|
|
71
|
+
'alpha ',
|
|
72
|
+
'beta ',
|
|
73
|
+
'gamma',
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('passes chunks through unsplit when smoothing is disabled', async () => {
|
|
78
|
+
const { yielded } = await runStream([textResponse('alpha beta gamma')], {
|
|
79
|
+
_lc_stream_delay: 0,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(yielded.map((chunk) => chunk.text).filter(Boolean)).toEqual([
|
|
83
|
+
'alpha beta gamma',
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('emits the final usage chunk without pacing delay', async () => {
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
const { yielded } = await runStream(
|
|
90
|
+
[
|
|
91
|
+
{
|
|
92
|
+
...textResponse('short text here'),
|
|
93
|
+
usageMetadata: {
|
|
94
|
+
promptTokenCount: 3,
|
|
95
|
+
candidatesTokenCount: 4,
|
|
96
|
+
totalTokenCount: 7,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
{ _lc_stream_delay: 30 }
|
|
101
|
+
);
|
|
102
|
+
const elapsed = Date.now() - start;
|
|
103
|
+
|
|
104
|
+
const usageChunk = yielded.find(
|
|
105
|
+
(chunk) =>
|
|
106
|
+
chunk.text === '' &&
|
|
107
|
+
(chunk.message as AIMessageChunk).usage_metadata != null
|
|
108
|
+
);
|
|
109
|
+
expect(usageChunk).toBeDefined();
|
|
110
|
+
expect(elapsed).toBeLessThan(1000);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('defaults to 25ms adaptive smoothing with 0 disabling', () => {
|
|
114
|
+
const base = { model: 'gemini-2.5-flash', apiKey: 'test-key' };
|
|
115
|
+
expect(new CustomChatGoogleGenerativeAI(base)._lc_stream_delay).toBe(25);
|
|
116
|
+
expect(
|
|
117
|
+
new CustomChatGoogleGenerativeAI({ ...base, _lc_stream_delay: 0 })
|
|
118
|
+
._lc_stream_delay
|
|
119
|
+
).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ChatMistralAI } from '@langchain/mistralai';
|
|
2
|
+
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
3
|
+
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
4
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
5
|
+
import type { MistralAIClientOptions } from '@/types';
|
|
6
|
+
import { smoothGenerationChunks } from '@/llm/stream/chunkAdapters';
|
|
7
|
+
import { resolveStreamDelay } from '@/llm/stream/smoother';
|
|
8
|
+
|
|
9
|
+
export class CustomChatMistralAI extends ChatMistralAI {
|
|
10
|
+
_lc_stream_delay: number;
|
|
11
|
+
|
|
12
|
+
static lc_name(): 'LibreChatMistralAI' {
|
|
13
|
+
return 'LibreChatMistralAI';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
constructor(fields?: MistralAIClientOptions) {
|
|
17
|
+
super(fields);
|
|
18
|
+
this._lc_stream_delay = resolveStreamDelay(fields?._lc_stream_delay);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async *_streamResponseChunks(
|
|
22
|
+
messages: BaseMessage[],
|
|
23
|
+
options: this['ParsedCallOptions'],
|
|
24
|
+
runManager?: CallbackManagerForLLMRun
|
|
25
|
+
): AsyncGenerator<ChatGenerationChunk> {
|
|
26
|
+
yield* smoothGenerationChunks({
|
|
27
|
+
chunks: super._streamResponseChunks(messages, options, undefined),
|
|
28
|
+
delayMs: this._lc_stream_delay,
|
|
29
|
+
signal: options.signal,
|
|
30
|
+
runManager,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { expect, test, describe, jest, afterEach } from '@jest/globals';
|
|
2
|
+
import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
4
|
+
import { ChatMistralAI } from '@langchain/mistralai';
|
|
5
|
+
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
6
|
+
import { CustomChatMistralAI } from './index';
|
|
7
|
+
|
|
8
|
+
describe('Mistral stream smoothing', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
jest.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function stubParentStream(texts: string[]): void {
|
|
14
|
+
jest
|
|
15
|
+
.spyOn(
|
|
16
|
+
ChatMistralAI.prototype as unknown as {
|
|
17
|
+
_streamResponseChunks: (
|
|
18
|
+
...args: unknown[]
|
|
19
|
+
) => AsyncGenerator<ChatGenerationChunk>;
|
|
20
|
+
},
|
|
21
|
+
'_streamResponseChunks'
|
|
22
|
+
)
|
|
23
|
+
.mockImplementation(async function* () {
|
|
24
|
+
for (const text of texts) {
|
|
25
|
+
yield new ChatGenerationChunk({
|
|
26
|
+
text,
|
|
27
|
+
message: new AIMessageChunk({ content: text }),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function collect(
|
|
34
|
+
model: CustomChatMistralAI,
|
|
35
|
+
runManager?: CallbackManagerForLLMRun
|
|
36
|
+
): Promise<string[]> {
|
|
37
|
+
const texts: string[] = [];
|
|
38
|
+
for await (const chunk of model._streamResponseChunks(
|
|
39
|
+
[new HumanMessage('hi')],
|
|
40
|
+
{} as Parameters<CustomChatMistralAI['_streamResponseChunks']>[1],
|
|
41
|
+
runManager
|
|
42
|
+
)) {
|
|
43
|
+
if (chunk.text) {
|
|
44
|
+
texts.push(chunk.text);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return texts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('splits large text chunks at stream boundaries with pacing', async () => {
|
|
51
|
+
stubParentStream(['alpha beta gamma']);
|
|
52
|
+
const model = new CustomChatMistralAI({
|
|
53
|
+
model: 'mistral-large-latest',
|
|
54
|
+
apiKey: 'test-key',
|
|
55
|
+
_lc_stream_delay: 1,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const dispatchedTokens: string[] = [];
|
|
59
|
+
const runManager = {
|
|
60
|
+
handleLLMNewToken: jest.fn(async (token: string) => {
|
|
61
|
+
dispatchedTokens.push(token);
|
|
62
|
+
}),
|
|
63
|
+
} as unknown as CallbackManagerForLLMRun;
|
|
64
|
+
|
|
65
|
+
expect(await collect(model, runManager)).toEqual([
|
|
66
|
+
'alpha ',
|
|
67
|
+
'beta ',
|
|
68
|
+
'gamma',
|
|
69
|
+
]);
|
|
70
|
+
expect(dispatchedTokens.filter(Boolean)).toEqual([
|
|
71
|
+
'alpha ',
|
|
72
|
+
'beta ',
|
|
73
|
+
'gamma',
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('passes chunks through unsplit when smoothing is disabled', async () => {
|
|
78
|
+
stubParentStream(['alpha beta gamma']);
|
|
79
|
+
const model = new CustomChatMistralAI({
|
|
80
|
+
model: 'mistral-large-latest',
|
|
81
|
+
apiKey: 'test-key',
|
|
82
|
+
_lc_stream_delay: 0,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(await collect(model)).toEqual(['alpha beta gamma']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('defaults to 25ms adaptive smoothing with 0 disabling', () => {
|
|
89
|
+
const base = { model: 'mistral-large-latest', apiKey: 'test-key' };
|
|
90
|
+
expect(new CustomChatMistralAI(base)._lc_stream_delay).toBe(25);
|
|
91
|
+
expect(
|
|
92
|
+
new CustomChatMistralAI({ ...base, _lc_stream_delay: 0 })
|
|
93
|
+
._lc_stream_delay
|
|
94
|
+
).toBe(0);
|
|
95
|
+
expect(CustomChatMistralAI.lc_name()).toBe('LibreChatMistralAI');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -557,6 +557,62 @@ describe('ChatDeepSeek', () => {
|
|
|
557
557
|
expect(textChunks).toEqual(['alpha ', 'beta ', 'gamma']);
|
|
558
558
|
});
|
|
559
559
|
|
|
560
|
+
it('does not delay tool-call and finish chunks between paced text', async () => {
|
|
561
|
+
const toolCallChunk: OpenAIChatCompletionChunk = {
|
|
562
|
+
id: 'chatcmpl-deepseek-test',
|
|
563
|
+
object: 'chat.completion.chunk',
|
|
564
|
+
created: 0,
|
|
565
|
+
model: 'deepseek-v4-pro',
|
|
566
|
+
choices: [
|
|
567
|
+
{
|
|
568
|
+
index: 0,
|
|
569
|
+
delta: {
|
|
570
|
+
role: 'assistant',
|
|
571
|
+
tool_calls: [
|
|
572
|
+
{
|
|
573
|
+
index: 0,
|
|
574
|
+
id: 'call_1',
|
|
575
|
+
type: 'function',
|
|
576
|
+
function: { name: 'lookup', arguments: '{}' },
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
},
|
|
580
|
+
finish_reason: null,
|
|
581
|
+
logprobs: null,
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
};
|
|
585
|
+
const model = new CapturingChatDeepSeek(
|
|
586
|
+
{
|
|
587
|
+
apiKey: 'test-key',
|
|
588
|
+
model: 'deepseek-v4-pro',
|
|
589
|
+
streaming: true,
|
|
590
|
+
_lc_stream_delay: 150,
|
|
591
|
+
},
|
|
592
|
+
[createContentChunk('alpha beta'), toolCallChunk]
|
|
593
|
+
);
|
|
594
|
+
const arrivals: { text: string; hasToolCall: boolean; at: number }[] = [];
|
|
595
|
+
|
|
596
|
+
for await (const chunk of model.streamChunksWithSignal(
|
|
597
|
+
new AbortController().signal
|
|
598
|
+
)) {
|
|
599
|
+
const message = chunk.message as Partial<AIMessage>;
|
|
600
|
+
arrivals.push({
|
|
601
|
+
text: chunk.text,
|
|
602
|
+
hasToolCall: (message.tool_calls?.length ?? 0) > 0,
|
|
603
|
+
at: Date.now(),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const toolArrival = arrivals.find((entry) => entry.hasToolCall);
|
|
608
|
+
const lastText = [...arrivals].reverse().find((entry) => entry.text !== '');
|
|
609
|
+
expect(toolArrival).toBeDefined();
|
|
610
|
+
expect(lastText).toBeDefined();
|
|
611
|
+
expect(
|
|
612
|
+
(toolArrival as { at: number }).at - (lastText as { at: number }).at
|
|
613
|
+
).toBeLessThan(100);
|
|
614
|
+
});
|
|
615
|
+
|
|
560
616
|
it('keeps delayed DeepSeek logprob chunks intact', async () => {
|
|
561
617
|
const logprobs = { content: [], refusal: null } as NonNullable<
|
|
562
618
|
OpenAIChatCompletionChunk['choices'][number]['logprobs']
|