@librechat/agents 3.1.77-dev.1 → 3.1.77

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.
@@ -0,0 +1,479 @@
1
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
3
+ import type { BaseMessage } from '@langchain/core/messages';
4
+ import type { OpenAIClient } from '@langchain/openai';
5
+
6
+ import { ChatDeepSeek } from './index';
7
+
8
+ type DeepSeekRequest =
9
+ | OpenAIClient.Chat.ChatCompletionCreateParamsStreaming
10
+ | OpenAIClient.Chat.ChatCompletionCreateParamsNonStreaming;
11
+ type OpenAIChatCompletion = OpenAIClient.Chat.Completions.ChatCompletion;
12
+ type OpenAIChatCompletionChunk =
13
+ OpenAIClient.Chat.Completions.ChatCompletionChunk;
14
+ type ReasoningAssistantMessageParam =
15
+ OpenAIClient.Chat.Completions.ChatCompletionAssistantMessageParam & {
16
+ reasoning_content?: string;
17
+ };
18
+
19
+ class CapturingChatDeepSeek extends ChatDeepSeek {
20
+ readonly requests: DeepSeekRequest[] = [];
21
+
22
+ constructor(
23
+ fields: ConstructorParameters<typeof ChatDeepSeek>[0],
24
+ private readonly streamChunks = createCompletionStreamChunks(),
25
+ private readonly completion = createCompletion()
26
+ ) {
27
+ super(fields);
28
+ }
29
+
30
+ async completionWithRetry(
31
+ request: OpenAIClient.Chat.ChatCompletionCreateParamsStreaming,
32
+ requestOptions?: OpenAIClient.RequestOptions
33
+ ): Promise<AsyncIterable<OpenAIChatCompletionChunk>>;
34
+ async completionWithRetry(
35
+ request: OpenAIClient.Chat.ChatCompletionCreateParamsNonStreaming,
36
+ requestOptions?: OpenAIClient.RequestOptions
37
+ ): Promise<OpenAIChatCompletion>;
38
+ async completionWithRetry(
39
+ request: DeepSeekRequest,
40
+ _requestOptions?: OpenAIClient.RequestOptions
41
+ ): Promise<AsyncIterable<OpenAIChatCompletionChunk> | OpenAIChatCompletion> {
42
+ this.requests.push(request);
43
+
44
+ if (request.stream === true) {
45
+ return createCompletionStream(this.streamChunks);
46
+ }
47
+
48
+ return this.completion;
49
+ }
50
+
51
+ streamChunksWithSignal(
52
+ signal: AbortSignal
53
+ ): AsyncGenerator<ChatGenerationChunk> {
54
+ return this._streamResponseChunks([new HumanMessage('hi')], {
55
+ signal,
56
+ } as this['ParsedCallOptions']);
57
+ }
58
+ }
59
+
60
+ function createToolContextMessages(): BaseMessage[] {
61
+ return [
62
+ new AIMessage({
63
+ content: '',
64
+ tool_calls: [
65
+ {
66
+ id: 'call_1',
67
+ name: 'web_search',
68
+ args: { query: 'trending news today' },
69
+ type: 'tool_call',
70
+ },
71
+ ],
72
+ additional_kwargs: {
73
+ reasoning_content: 'Need current news from the web.',
74
+ },
75
+ }),
76
+ new ToolMessage({
77
+ content: 'Search results',
78
+ tool_call_id: 'call_1',
79
+ }),
80
+ ];
81
+ }
82
+
83
+ function createCompletionStreamChunks(): OpenAIChatCompletionChunk[] {
84
+ return [
85
+ createContentChunk('ok'),
86
+ {
87
+ id: 'chatcmpl-deepseek-test',
88
+ object: 'chat.completion.chunk',
89
+ created: 0,
90
+ model: 'deepseek-v4-pro',
91
+ choices: [
92
+ {
93
+ index: 0,
94
+ delta: {},
95
+ finish_reason: 'stop',
96
+ logprobs: null,
97
+ },
98
+ ],
99
+ },
100
+ ];
101
+ }
102
+
103
+ function createContentChunk(content: string): OpenAIChatCompletionChunk {
104
+ return {
105
+ id: 'chatcmpl-deepseek-test',
106
+ object: 'chat.completion.chunk',
107
+ created: 0,
108
+ model: 'deepseek-v4-pro',
109
+ choices: [
110
+ {
111
+ index: 0,
112
+ delta: {
113
+ role: 'assistant',
114
+ content,
115
+ },
116
+ finish_reason: null,
117
+ logprobs: null,
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ async function* createCompletionStream(
124
+ chunks: OpenAIChatCompletionChunk[]
125
+ ): AsyncGenerator<OpenAIChatCompletionChunk> {
126
+ for (const chunk of chunks) {
127
+ yield chunk;
128
+ }
129
+ }
130
+
131
+ function createCompletion(
132
+ usage: OpenAIClient.Completions.CompletionUsage = {
133
+ prompt_tokens: 1,
134
+ completion_tokens: 1,
135
+ total_tokens: 2,
136
+ }
137
+ ): OpenAIChatCompletion {
138
+ return {
139
+ id: 'chatcmpl-deepseek-test',
140
+ object: 'chat.completion',
141
+ created: 0,
142
+ model: 'deepseek-v4-pro',
143
+ choices: [
144
+ {
145
+ index: 0,
146
+ message: {
147
+ role: 'assistant',
148
+ content: 'ok',
149
+ refusal: null,
150
+ },
151
+ finish_reason: 'stop',
152
+ logprobs: null,
153
+ },
154
+ ],
155
+ usage,
156
+ };
157
+ }
158
+
159
+ function getReasoningAssistantMessage(
160
+ request: DeepSeekRequest
161
+ ): ReasoningAssistantMessageParam {
162
+ return request.messages[0] as ReasoningAssistantMessageParam;
163
+ }
164
+
165
+ async function drainStream(stream: AsyncIterable<unknown>): Promise<void> {
166
+ for await (const chunk of stream) {
167
+ void chunk;
168
+ }
169
+ }
170
+
171
+ describe('ChatDeepSeek', () => {
172
+ it('passes reasoning_content back on same-run streaming tool continuations', async () => {
173
+ const model = new CapturingChatDeepSeek({
174
+ apiKey: 'test-key',
175
+ model: 'deepseek-v4-pro',
176
+ streaming: true,
177
+ });
178
+ const chunks = [];
179
+
180
+ for await (const chunk of await model.stream(createToolContextMessages())) {
181
+ chunks.push(chunk);
182
+ }
183
+
184
+ expect(chunks).toHaveLength(2);
185
+ expect(model.requests).toHaveLength(1);
186
+ expect(getReasoningAssistantMessage(model.requests[0])).toEqual(
187
+ expect.objectContaining({
188
+ role: 'assistant',
189
+ content: '',
190
+ reasoning_content: 'Need current news from the web.',
191
+ })
192
+ );
193
+ });
194
+
195
+ it('passes reasoning_content back on same-run non-streaming tool continuations', async () => {
196
+ const model = new CapturingChatDeepSeek({
197
+ apiKey: 'test-key',
198
+ model: 'deepseek-v4-pro',
199
+ streaming: false,
200
+ });
201
+
202
+ await model.invoke(createToolContextMessages());
203
+
204
+ expect(model.requests).toHaveLength(1);
205
+ expect(getReasoningAssistantMessage(model.requests[0])).toEqual(
206
+ expect.objectContaining({
207
+ role: 'assistant',
208
+ content: '',
209
+ reasoning_content: 'Need current news from the web.',
210
+ })
211
+ );
212
+ });
213
+
214
+ it('keeps raw think fallback content out of streamed assistant content', async () => {
215
+ const model = new CapturingChatDeepSeek(
216
+ {
217
+ apiKey: 'test-key',
218
+ model: 'deepseek-v4-pro',
219
+ streaming: true,
220
+ },
221
+ [
222
+ createContentChunk('prefix <thi'),
223
+ createContentChunk('nk>hidden'),
224
+ createContentChunk('</think>visible'),
225
+ ]
226
+ );
227
+ const chunks = [];
228
+ const callbackTokens: string[] = [];
229
+
230
+ const stream = await model.stream([new HumanMessage('hi')], {
231
+ callbacks: [
232
+ {
233
+ handleLLMNewToken(token: string): void {
234
+ callbackTokens.push(token);
235
+ },
236
+ },
237
+ ],
238
+ });
239
+
240
+ for await (const chunk of stream) {
241
+ chunks.push(chunk);
242
+ }
243
+
244
+ const streamedText = chunks
245
+ .map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
246
+ .join('');
247
+ const hasHiddenReasoning = chunks.some(
248
+ (chunk) => chunk.additional_kwargs.reasoning_content === 'hidden'
249
+ );
250
+
251
+ expect(streamedText).toBe('prefix visible');
252
+ expect(callbackTokens.join('')).toBe('prefix visible');
253
+ expect(callbackTokens.join('')).not.toContain('hidden');
254
+ expect(callbackTokens.join('')).not.toContain('think');
255
+ expect(hasHiddenReasoning).toBe(true);
256
+ });
257
+
258
+ it('keeps multiple raw think fallback blocks hidden from content and callbacks', async () => {
259
+ const model = new CapturingChatDeepSeek(
260
+ {
261
+ apiKey: 'test-key',
262
+ model: 'deepseek-v4-pro',
263
+ streaming: true,
264
+ },
265
+ [
266
+ createContentChunk(
267
+ 'before<think>hidden one</think>visible<think>hidden two</think>done'
268
+ ),
269
+ ]
270
+ );
271
+ const chunks = [];
272
+ const callbackTokens: string[] = [];
273
+
274
+ const stream = await model.stream([new HumanMessage('hi')], {
275
+ callbacks: [
276
+ {
277
+ handleLLMNewToken(token: string): void {
278
+ callbackTokens.push(token);
279
+ },
280
+ },
281
+ ],
282
+ });
283
+
284
+ for await (const chunk of stream) {
285
+ chunks.push(chunk);
286
+ }
287
+
288
+ const streamedText = chunks
289
+ .map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
290
+ .join('');
291
+ const reasoningContent = chunks
292
+ .map((chunk) => chunk.additional_kwargs.reasoning_content)
293
+ .filter((content): content is string => typeof content === 'string');
294
+
295
+ expect(streamedText).toBe('beforevisibledone');
296
+ expect(callbackTokens.join('')).toBe('beforevisibledone');
297
+ expect(reasoningContent).toEqual(['hidden one', 'hidden two']);
298
+ });
299
+
300
+ it('keeps cross-chunk multiple raw think fallback blocks hidden from content and callbacks', async () => {
301
+ const model = new CapturingChatDeepSeek(
302
+ {
303
+ apiKey: 'test-key',
304
+ model: 'deepseek-v4-pro',
305
+ streaming: true,
306
+ },
307
+ [
308
+ createContentChunk('before<think>hidden one</thi'),
309
+ createContentChunk('nk>visible<thi'),
310
+ createContentChunk('nk>hidden two</think>done'),
311
+ ]
312
+ );
313
+ const chunks = [];
314
+ const callbackTokens: string[] = [];
315
+
316
+ const stream = await model.stream([new HumanMessage('hi')], {
317
+ callbacks: [
318
+ {
319
+ handleLLMNewToken(token: string): void {
320
+ callbackTokens.push(token);
321
+ },
322
+ },
323
+ ],
324
+ });
325
+
326
+ for await (const chunk of stream) {
327
+ chunks.push(chunk);
328
+ }
329
+
330
+ const streamedText = chunks
331
+ .map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
332
+ .join('');
333
+ const reasoningContent = chunks
334
+ .map((chunk) => chunk.additional_kwargs.reasoning_content)
335
+ .filter((content): content is string => typeof content === 'string');
336
+
337
+ expect(streamedText).toBe('beforevisibledone');
338
+ expect(callbackTokens.join('')).toBe('beforevisibledone');
339
+ expect(reasoningContent).toEqual(['hidden one', 'hidden two']);
340
+ });
341
+
342
+ it('emits trailing unfinished raw think fallback as reasoning content', async () => {
343
+ const model = new CapturingChatDeepSeek(
344
+ {
345
+ apiKey: 'test-key',
346
+ model: 'deepseek-v4-pro',
347
+ streaming: true,
348
+ },
349
+ [createContentChunk('<think>truncated')]
350
+ );
351
+ const chunks = [];
352
+ const callbackTokens: string[] = [];
353
+
354
+ const stream = await model.stream([new HumanMessage('hi')], {
355
+ callbacks: [
356
+ {
357
+ handleLLMNewToken(token: string): void {
358
+ callbackTokens.push(token);
359
+ },
360
+ },
361
+ ],
362
+ });
363
+
364
+ for await (const chunk of stream) {
365
+ chunks.push(chunk);
366
+ }
367
+
368
+ const streamedText = chunks
369
+ .map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
370
+ .join('');
371
+ const reasoningContent = chunks
372
+ .map((chunk) => chunk.additional_kwargs.reasoning_content)
373
+ .filter((content): content is string => typeof content === 'string');
374
+
375
+ expect(streamedText).toBe('');
376
+ expect(callbackTokens.join('')).toBe('');
377
+ expect(reasoningContent).toEqual(['truncated']);
378
+ });
379
+
380
+ it('preserves detailed usage metadata in non-streaming responses', async () => {
381
+ const model = new CapturingChatDeepSeek(
382
+ {
383
+ apiKey: 'test-key',
384
+ model: 'deepseek-v4-pro',
385
+ streaming: false,
386
+ },
387
+ createCompletionStreamChunks(),
388
+ createCompletion({
389
+ prompt_tokens: 11,
390
+ completion_tokens: 7,
391
+ total_tokens: 18,
392
+ prompt_tokens_details: {
393
+ audio_tokens: 2,
394
+ cached_tokens: 3,
395
+ },
396
+ completion_tokens_details: {
397
+ audio_tokens: 4,
398
+ reasoning_tokens: 5,
399
+ },
400
+ })
401
+ );
402
+
403
+ const response = await model.invoke([new HumanMessage('hi')]);
404
+
405
+ expect(response.usage_metadata).toEqual({
406
+ input_tokens: 11,
407
+ output_tokens: 7,
408
+ total_tokens: 18,
409
+ input_token_details: {
410
+ audio: 2,
411
+ cache_read: 3,
412
+ },
413
+ output_token_details: {
414
+ audio: 4,
415
+ reasoning: 5,
416
+ },
417
+ });
418
+ });
419
+
420
+ it('does not serialize non-streaming requests when aborted before generation', async () => {
421
+ const controller = new AbortController();
422
+ const model = new CapturingChatDeepSeek({
423
+ apiKey: 'test-key',
424
+ model: 'deepseek-v4-pro',
425
+ streaming: false,
426
+ });
427
+
428
+ controller.abort();
429
+
430
+ await expect(
431
+ model.invoke([new HumanMessage('hi')], {
432
+ signal: controller.signal,
433
+ })
434
+ ).rejects.toThrow();
435
+ expect(model.requests).toHaveLength(0);
436
+ });
437
+
438
+ it('throws AbortError when a DeepSeek stream is canceled', async () => {
439
+ const controller = new AbortController();
440
+ const model = new CapturingChatDeepSeek({
441
+ apiKey: 'test-key',
442
+ model: 'deepseek-v4-pro',
443
+ streaming: true,
444
+ });
445
+
446
+ controller.abort();
447
+
448
+ await expect(
449
+ drainStream(model.streamChunksWithSignal(controller.signal))
450
+ ).rejects.toThrow('AbortError');
451
+ });
452
+
453
+ it('throws AbortError when a DeepSeek stream is canceled mid-stream', async () => {
454
+ const controller = new AbortController();
455
+ const model = new CapturingChatDeepSeek(
456
+ {
457
+ apiKey: 'test-key',
458
+ model: 'deepseek-v4-pro',
459
+ streaming: true,
460
+ },
461
+ [createContentChunk('first '), createContentChunk('second')]
462
+ );
463
+ const stream = model.streamChunksWithSignal(controller.signal);
464
+ const iterator = stream[Symbol.asyncIterator]();
465
+
466
+ await expect(iterator.next()).resolves.toEqual(
467
+ expect.objectContaining({
468
+ done: false,
469
+ value: expect.objectContaining({
470
+ text: 'first ',
471
+ }),
472
+ })
473
+ );
474
+
475
+ controller.abort();
476
+
477
+ await expect(iterator.next()).rejects.toThrow('AbortError');
478
+ });
479
+ });