@librechat/agents 3.2.0 → 3.2.1

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,747 @@
1
+ import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
2
+ import { ChatGenerationChunk } from '@langchain/core/outputs';
3
+ import { FakeListChatModel } from '@langchain/core/utils/testing';
4
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
5
+ import type { RunnableConfig } from '@langchain/core/runnables';
6
+ import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
7
+ import type * as t from '@/types';
8
+ import { ContentTypes, GraphEvents, Providers } from '@/common';
9
+ import { createContentAggregator } from '@/stream';
10
+ import { ModelEndHandler, ToolEndHandler } from '@/events';
11
+ import { Run } from '@/run';
12
+
13
+ type ReasoningKey = 'reasoning_content' | 'reasoning';
14
+
15
+ class InvokeOnlyReasoningModel implements t.ChatModel {
16
+ constructor(
17
+ private readonly response: {
18
+ content: string;
19
+ reasoningContent: string;
20
+ }
21
+ ) {}
22
+
23
+ async invoke(
24
+ _messages: BaseMessage[],
25
+ _config?: RunnableConfig
26
+ ): Promise<AIMessageChunk> {
27
+ return new AIMessageChunk({
28
+ content: this.response.content,
29
+ additional_kwargs: {
30
+ reasoning_content: this.response.reasoningContent,
31
+ },
32
+ });
33
+ }
34
+ }
35
+
36
+ class InvokeOnlyMessageModel implements t.ChatModel {
37
+ constructor(private readonly message: AIMessageChunk) {}
38
+
39
+ async invoke(
40
+ _messages: BaseMessage[],
41
+ _config?: RunnableConfig
42
+ ): Promise<AIMessageChunk> {
43
+ return this.message;
44
+ }
45
+ }
46
+
47
+ class StreamingReasoningModel implements t.ChatModel {
48
+ constructor(private readonly chunks: AIMessageChunk[]) {}
49
+
50
+ async invoke(
51
+ _messages: BaseMessage[],
52
+ _config?: RunnableConfig
53
+ ): Promise<AIMessageChunk> {
54
+ return this.chunks[this.chunks.length - 1] ?? new AIMessageChunk('');
55
+ }
56
+
57
+ async stream(
58
+ _messages: BaseMessage[],
59
+ _config?: RunnableConfig
60
+ ): Promise<AsyncIterable<AIMessageChunk>> {
61
+ const chunks = this.chunks;
62
+ return (async function* streamChunks(): AsyncGenerator<AIMessageChunk> {
63
+ for (const chunk of chunks) {
64
+ yield chunk;
65
+ }
66
+ })();
67
+ }
68
+ }
69
+
70
+ class CallbackStreamingReasoningModel extends FakeListChatModel {
71
+ constructor(private readonly chunks: AIMessageChunk[]) {
72
+ super({ responses: [''] });
73
+ }
74
+
75
+ _llmType(): string {
76
+ return 'callback-streaming-reasoning';
77
+ }
78
+
79
+ async *_streamResponseChunks(
80
+ _messages: BaseMessage[],
81
+ _options: this['ParsedCallOptions'],
82
+ runManager?: CallbackManagerForLLMRun
83
+ ): AsyncGenerator<ChatGenerationChunk> {
84
+ for (const chunk of this.chunks) {
85
+ const text = typeof chunk.content === 'string' ? chunk.content : '';
86
+ yield new ChatGenerationChunk({
87
+ text,
88
+ generationInfo: {},
89
+ message: chunk,
90
+ });
91
+ void runManager?.handleLLMNewToken(text);
92
+ }
93
+ }
94
+ }
95
+
96
+ function createReasoningChunk(
97
+ reasoningKey: ReasoningKey,
98
+ reasoningText: string
99
+ ): AIMessageChunk {
100
+ return new AIMessageChunk({
101
+ content: '',
102
+ additional_kwargs: {
103
+ [reasoningKey]: reasoningText,
104
+ },
105
+ });
106
+ }
107
+
108
+ function createOpenAIReasoningSummaryChunk(reasoningText: string): AIMessageChunk {
109
+ return new AIMessageChunk({
110
+ content: '',
111
+ additional_kwargs: {
112
+ reasoning: {
113
+ summary: [{ text: reasoningText }],
114
+ },
115
+ },
116
+ });
117
+ }
118
+
119
+ function createReasoningHandlers(
120
+ aggregateContent: t.ContentAggregator,
121
+ reasoningDeltas: t.ReasoningDeltaEvent[],
122
+ messageDeltas?: t.MessageDeltaEvent[]
123
+ ): Record<string | GraphEvents, t.EventHandler> {
124
+ return {
125
+ [GraphEvents.ON_RUN_STEP]: {
126
+ handle: (event: GraphEvents.ON_RUN_STEP, data: t.StreamEventData): void => {
127
+ aggregateContent({ event, data: data as t.RunStep });
128
+ },
129
+ },
130
+ [GraphEvents.ON_MESSAGE_DELTA]: {
131
+ handle: (
132
+ event: GraphEvents.ON_MESSAGE_DELTA,
133
+ data: t.StreamEventData
134
+ ): void => {
135
+ const messageDelta = data as t.MessageDeltaEvent;
136
+ messageDeltas?.push(messageDelta);
137
+ aggregateContent({ event, data: messageDelta });
138
+ },
139
+ },
140
+ [GraphEvents.ON_REASONING_DELTA]: {
141
+ handle: (
142
+ event: GraphEvents.ON_REASONING_DELTA,
143
+ data: t.StreamEventData
144
+ ): void => {
145
+ const reasoningDelta = data as t.ReasoningDeltaEvent;
146
+ reasoningDeltas.push(reasoningDelta);
147
+ aggregateContent({ event, data: reasoningDelta });
148
+ },
149
+ },
150
+ };
151
+ }
152
+
153
+ function createLibreChatLikeHandlers({
154
+ aggregateContent,
155
+ collectedUsage,
156
+ emittedEvents,
157
+ }: {
158
+ aggregateContent: t.ContentAggregator;
159
+ collectedUsage: UsageMetadata[];
160
+ emittedEvents: Array<{ event: string; data: unknown }>;
161
+ }): Record<string | GraphEvents, t.EventHandler> {
162
+ const modelEndHandler = new ModelEndHandler(collectedUsage);
163
+ const toolEndHandler = new ToolEndHandler();
164
+ const aggregateAndEmit = (
165
+ event: GraphEvents,
166
+ data: t.StreamEventData
167
+ ): void => {
168
+ aggregateContent({
169
+ event,
170
+ data: data as
171
+ | t.RunStep
172
+ | t.MessageDeltaEvent
173
+ | t.ReasoningDeltaEvent
174
+ | t.RunStepDeltaEvent
175
+ | { result: t.ToolEndEvent },
176
+ });
177
+ emittedEvents.push({
178
+ event,
179
+ data,
180
+ });
181
+ };
182
+
183
+ return {
184
+ [GraphEvents.CHAT_MODEL_END]: {
185
+ handle: async (event, data, metadata, graph): Promise<void> => {
186
+ await modelEndHandler.handle(
187
+ event,
188
+ data as t.ModelEndData,
189
+ metadata,
190
+ graph
191
+ );
192
+ emittedEvents.push({
193
+ event,
194
+ data,
195
+ });
196
+ },
197
+ },
198
+ [GraphEvents.TOOL_END]: toolEndHandler,
199
+ [GraphEvents.ON_RUN_STEP]: {
200
+ handle: (event: GraphEvents.ON_RUN_STEP, data: t.StreamEventData): void =>
201
+ aggregateAndEmit(event, data),
202
+ },
203
+ [GraphEvents.ON_RUN_STEP_DELTA]: {
204
+ handle: (
205
+ event: GraphEvents.ON_RUN_STEP_DELTA,
206
+ data: t.StreamEventData
207
+ ): void => aggregateAndEmit(event, data),
208
+ },
209
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: {
210
+ handle: (
211
+ event: GraphEvents.ON_RUN_STEP_COMPLETED,
212
+ data: t.StreamEventData
213
+ ): void => aggregateAndEmit(event, data),
214
+ },
215
+ [GraphEvents.ON_MESSAGE_DELTA]: {
216
+ handle: (
217
+ event: GraphEvents.ON_MESSAGE_DELTA,
218
+ data: t.StreamEventData
219
+ ): void => aggregateAndEmit(event, data),
220
+ },
221
+ [GraphEvents.ON_REASONING_DELTA]: {
222
+ handle: (
223
+ event: GraphEvents.ON_REASONING_DELTA,
224
+ data: t.StreamEventData
225
+ ): void => aggregateAndEmit(event, data),
226
+ },
227
+ };
228
+ }
229
+
230
+ describe('StandardGraph final response reasoning fallback', () => {
231
+ const config = {
232
+ configurable: {
233
+ thread_id: 'reasoning-fallback-thread',
234
+ },
235
+ streamMode: 'values' as const,
236
+ version: 'v2' as const,
237
+ };
238
+ const llmConfig: t.LLMConfig = {
239
+ provider: Providers.OPENAI,
240
+ disableStreaming: true,
241
+ streamUsage: false,
242
+ };
243
+
244
+ it('emits reasoning_content from invoke-only final responses', async () => {
245
+ const reasoningText = 'Need to inspect the Home Assistant tool state.';
246
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
247
+ const { contentParts, aggregateContent } = createContentAggregator();
248
+ const run = await Run.create<t.IState>({
249
+ runId: 'reasoning-fallback-empty-content',
250
+ graphConfig: {
251
+ type: 'standard',
252
+ llmConfig,
253
+ },
254
+ returnContent: true,
255
+ skipCleanup: true,
256
+ customHandlers: createReasoningHandlers(
257
+ aggregateContent,
258
+ reasoningDeltas
259
+ ),
260
+ });
261
+
262
+ if (!run.Graph) {
263
+ throw new Error('Expected graph to be initialized');
264
+ }
265
+
266
+ run.Graph.overrideModel = new InvokeOnlyReasoningModel({
267
+ content: '',
268
+ reasoningContent: reasoningText,
269
+ });
270
+
271
+ const finalContentParts = await run.processStream(
272
+ { messages: [new HumanMessage('turn on the bedroom light')] },
273
+ config
274
+ );
275
+
276
+ expect(finalContentParts).toEqual([
277
+ { type: ContentTypes.THINK, think: reasoningText },
278
+ ]);
279
+ expect(reasoningDeltas).toHaveLength(1);
280
+ expect(reasoningDeltas[0].delta.content?.[0]).toEqual({
281
+ type: ContentTypes.THINK,
282
+ think: reasoningText,
283
+ });
284
+ expect(contentParts).toContainEqual({
285
+ type: ContentTypes.THINK,
286
+ think: reasoningText,
287
+ });
288
+ });
289
+
290
+ it('keeps final reasoning before final text when both are present', async () => {
291
+ const text = 'Done.';
292
+ const reasoningText = 'Decide whether a tool is needed first.';
293
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
294
+ const { contentParts, aggregateContent } = createContentAggregator();
295
+ const run = await Run.create<t.IState>({
296
+ runId: 'reasoning-fallback-with-text',
297
+ graphConfig: {
298
+ type: 'standard',
299
+ llmConfig,
300
+ },
301
+ returnContent: true,
302
+ skipCleanup: true,
303
+ customHandlers: createReasoningHandlers(
304
+ aggregateContent,
305
+ reasoningDeltas
306
+ ),
307
+ });
308
+
309
+ if (!run.Graph) {
310
+ throw new Error('Expected graph to be initialized');
311
+ }
312
+
313
+ run.Graph.overrideModel = new InvokeOnlyReasoningModel({
314
+ content: text,
315
+ reasoningContent: reasoningText,
316
+ });
317
+
318
+ await run.processStream(
319
+ { messages: [new HumanMessage('say done')] },
320
+ config
321
+ );
322
+
323
+ expect(contentParts).toEqual([
324
+ { type: ContentTypes.THINK, think: reasoningText },
325
+ { type: ContentTypes.TEXT, text },
326
+ ]);
327
+ });
328
+
329
+ it('returns reasoning content without a custom aggregator', async () => {
330
+ const reasoningText = 'Reasoning should persist for returnContent.';
331
+ const run = await Run.create<t.IState>({
332
+ runId: 'reasoning-fallback-return-content',
333
+ graphConfig: {
334
+ type: 'standard',
335
+ llmConfig,
336
+ },
337
+ returnContent: true,
338
+ skipCleanup: true,
339
+ });
340
+
341
+ if (!run.Graph) {
342
+ throw new Error('Expected graph to be initialized');
343
+ }
344
+
345
+ run.Graph.overrideModel = new InvokeOnlyReasoningModel({
346
+ content: '',
347
+ reasoningContent: reasoningText,
348
+ });
349
+
350
+ const finalContentParts = await run.processStream(
351
+ { messages: [new HumanMessage('return reasoning content')] },
352
+ {
353
+ ...config,
354
+ configurable: {
355
+ thread_id: 'reasoning-fallback-return-content',
356
+ },
357
+ }
358
+ );
359
+
360
+ expect(finalContentParts).toEqual([
361
+ { type: ContentTypes.THINK, think: reasoningText },
362
+ ]);
363
+ });
364
+
365
+ it('emits every OpenAI reasoning summary segment in invoke-only fallback', async () => {
366
+ const reasoningText = 'First summary. Second summary.';
367
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
368
+ const { contentParts, aggregateContent } = createContentAggregator();
369
+ const run = await Run.create<t.IState>({
370
+ runId: 'reasoning-fallback-openai-multi-summary',
371
+ graphConfig: {
372
+ type: 'standard',
373
+ llmConfig: {
374
+ provider: Providers.OPENAI,
375
+ disableStreaming: true,
376
+ streamUsage: false,
377
+ },
378
+ reasoningKey: 'reasoning',
379
+ },
380
+ returnContent: true,
381
+ skipCleanup: true,
382
+ customHandlers: createReasoningHandlers(
383
+ aggregateContent,
384
+ reasoningDeltas
385
+ ),
386
+ });
387
+
388
+ if (!run.Graph) {
389
+ throw new Error('Expected graph to be initialized');
390
+ }
391
+
392
+ run.Graph.overrideModel = new InvokeOnlyMessageModel(
393
+ new AIMessageChunk({
394
+ content: '',
395
+ additional_kwargs: {
396
+ reasoning: {
397
+ summary: [{ text: 'First summary. ' }, { text: 'Second summary.' }],
398
+ },
399
+ },
400
+ })
401
+ );
402
+
403
+ const finalContentParts = await run.processStream(
404
+ { messages: [new HumanMessage('return multi summary reasoning')] },
405
+ {
406
+ ...config,
407
+ configurable: {
408
+ thread_id: 'reasoning-fallback-openai-multi-summary',
409
+ },
410
+ }
411
+ );
412
+
413
+ expect(reasoningDeltas).toHaveLength(1);
414
+ expect(reasoningDeltas[0].delta.content?.[0]).toEqual({
415
+ type: ContentTypes.THINK,
416
+ think: reasoningText,
417
+ });
418
+ expect(finalContentParts).toEqual([
419
+ { type: ContentTypes.THINK, think: reasoningText },
420
+ ]);
421
+ expect(contentParts).toEqual([
422
+ { type: ContentTypes.THINK, think: reasoningText },
423
+ ]);
424
+ });
425
+
426
+ it('emits OpenRouter reasoning_details in invoke-only fallback', async () => {
427
+ const reasoningText = 'OpenRouter detail reasoning.';
428
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
429
+ const { contentParts, aggregateContent } = createContentAggregator();
430
+ const run = await Run.create<t.IState>({
431
+ runId: 'reasoning-fallback-openrouter-details',
432
+ graphConfig: {
433
+ type: 'standard',
434
+ llmConfig: {
435
+ provider: Providers.OPENROUTER,
436
+ disableStreaming: true,
437
+ streamUsage: false,
438
+ },
439
+ reasoningKey: 'reasoning',
440
+ },
441
+ returnContent: true,
442
+ skipCleanup: true,
443
+ customHandlers: createReasoningHandlers(
444
+ aggregateContent,
445
+ reasoningDeltas
446
+ ),
447
+ });
448
+
449
+ if (!run.Graph) {
450
+ throw new Error('Expected graph to be initialized');
451
+ }
452
+
453
+ run.Graph.overrideModel = new InvokeOnlyMessageModel(
454
+ new AIMessageChunk({
455
+ content: '',
456
+ additional_kwargs: {
457
+ reasoning_details: [
458
+ { type: 'reasoning.text', text: 'OpenRouter detail ' },
459
+ { type: 'reasoning.encrypted', id: 'encrypted' },
460
+ { type: 'reasoning.text', text: 'reasoning.' },
461
+ ],
462
+ },
463
+ })
464
+ );
465
+
466
+ const finalContentParts = await run.processStream(
467
+ { messages: [new HumanMessage('return OpenRouter reasoning details')] },
468
+ {
469
+ ...config,
470
+ configurable: {
471
+ thread_id: 'reasoning-fallback-openrouter-details',
472
+ },
473
+ }
474
+ );
475
+
476
+ expect(reasoningDeltas).toHaveLength(1);
477
+ expect(reasoningDeltas[0].delta.content?.[0]).toEqual({
478
+ type: ContentTypes.THINK,
479
+ think: reasoningText,
480
+ });
481
+ expect(finalContentParts).toEqual([
482
+ { type: ContentTypes.THINK, think: reasoningText },
483
+ ]);
484
+ expect(contentParts).toEqual([
485
+ { type: ContentTypes.THINK, think: reasoningText },
486
+ ]);
487
+ });
488
+
489
+ it.each([
490
+ {
491
+ providerName: 'DeepSeek',
492
+ provider: Providers.DEEPSEEK,
493
+ reasoningKey: 'reasoning_content' as const,
494
+ },
495
+ {
496
+ providerName: 'OpenRouter',
497
+ provider: Providers.OPENROUTER,
498
+ reasoningKey: 'reasoning' as const,
499
+ },
500
+ ])(
501
+ 'does not replay streamed $providerName reasoning from the final fallback',
502
+ async ({ provider, providerName, reasoningKey }) => {
503
+ const text = 'Done.';
504
+ const reasoningText = 'Check the provider reasoning stream first.';
505
+ const firstReasoningChunk = reasoningText.slice(0, 19);
506
+ const secondReasoningChunk = reasoningText.slice(19);
507
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
508
+ const messageDeltas: t.MessageDeltaEvent[] = [];
509
+ const { contentParts, aggregateContent } = createContentAggregator();
510
+ const run = await Run.create<t.IState>({
511
+ runId: `reasoning-fallback-${providerName.toLowerCase()}-stream`,
512
+ graphConfig: {
513
+ type: 'standard',
514
+ llmConfig: {
515
+ provider,
516
+ streamUsage: false,
517
+ },
518
+ reasoningKey,
519
+ },
520
+ returnContent: true,
521
+ skipCleanup: true,
522
+ customHandlers: createReasoningHandlers(
523
+ aggregateContent,
524
+ reasoningDeltas,
525
+ messageDeltas
526
+ ),
527
+ });
528
+
529
+ if (!run.Graph) {
530
+ throw new Error('Expected graph to be initialized');
531
+ }
532
+
533
+ run.Graph.overrideModel = new StreamingReasoningModel([
534
+ createReasoningChunk(reasoningKey, firstReasoningChunk),
535
+ createReasoningChunk(reasoningKey, secondReasoningChunk),
536
+ new AIMessageChunk({ content: text }),
537
+ ]);
538
+
539
+ await run.processStream(
540
+ { messages: [new HumanMessage('stream provider reasoning')] },
541
+ {
542
+ ...config,
543
+ configurable: {
544
+ thread_id: `reasoning-fallback-${providerName.toLowerCase()}-stream`,
545
+ },
546
+ }
547
+ );
548
+
549
+ expect(reasoningDeltas).toHaveLength(2);
550
+ expect(messageDeltas).toHaveLength(1);
551
+ expect(contentParts).toEqual([
552
+ { type: ContentTypes.THINK, think: reasoningText },
553
+ { type: ContentTypes.TEXT, text },
554
+ ]);
555
+ }
556
+ );
557
+
558
+ it.each([
559
+ {
560
+ providerName: 'DeepSeek',
561
+ provider: Providers.DEEPSEEK,
562
+ reasoningKey: 'reasoning_content' as const,
563
+ },
564
+ {
565
+ providerName: 'OpenRouter',
566
+ provider: Providers.OPENROUTER,
567
+ reasoningKey: 'reasoning' as const,
568
+ },
569
+ ])(
570
+ 'does not replay streamed reasoning-only $providerName output',
571
+ async ({ provider, providerName, reasoningKey }) => {
572
+ const reasoningText = 'The answer is still being considered.';
573
+ const firstReasoningChunk = reasoningText.slice(0, 14);
574
+ const secondReasoningChunk = reasoningText.slice(14);
575
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
576
+ const messageDeltas: t.MessageDeltaEvent[] = [];
577
+ const { contentParts, aggregateContent } = createContentAggregator();
578
+ const run = await Run.create<t.IState>({
579
+ runId: `reasoning-only-${providerName.toLowerCase()}-stream`,
580
+ graphConfig: {
581
+ type: 'standard',
582
+ llmConfig: {
583
+ provider,
584
+ streamUsage: false,
585
+ },
586
+ reasoningKey,
587
+ },
588
+ returnContent: true,
589
+ skipCleanup: true,
590
+ customHandlers: createReasoningHandlers(
591
+ aggregateContent,
592
+ reasoningDeltas,
593
+ messageDeltas
594
+ ),
595
+ });
596
+
597
+ if (!run.Graph) {
598
+ throw new Error('Expected graph to be initialized');
599
+ }
600
+
601
+ run.Graph.overrideModel = new StreamingReasoningModel([
602
+ createReasoningChunk(reasoningKey, firstReasoningChunk),
603
+ createReasoningChunk(reasoningKey, secondReasoningChunk),
604
+ ]);
605
+
606
+ await run.processStream(
607
+ { messages: [new HumanMessage('stream provider reasoning only')] },
608
+ {
609
+ ...config,
610
+ configurable: {
611
+ thread_id: `reasoning-only-${providerName.toLowerCase()}-stream`,
612
+ },
613
+ }
614
+ );
615
+
616
+ expect(reasoningDeltas).toHaveLength(2);
617
+ expect(messageDeltas).toHaveLength(0);
618
+ expect(contentParts).toEqual([
619
+ { type: ContentTypes.THINK, think: reasoningText },
620
+ ]);
621
+ }
622
+ );
623
+
624
+ it('does not replay streamed OpenAI reasoning summaries from the final fallback', async () => {
625
+ const text = 'Done.';
626
+ const reasoningText = 'Use the summary reasoning channel.';
627
+ const firstReasoningChunk = reasoningText.slice(0, 15);
628
+ const secondReasoningChunk = reasoningText.slice(15);
629
+ const reasoningDeltas: t.ReasoningDeltaEvent[] = [];
630
+ const messageDeltas: t.MessageDeltaEvent[] = [];
631
+ const { contentParts, aggregateContent } = createContentAggregator();
632
+ const run = await Run.create<t.IState>({
633
+ runId: 'reasoning-fallback-openai-summary-stream',
634
+ graphConfig: {
635
+ type: 'standard',
636
+ llmConfig: {
637
+ provider: Providers.OPENAI,
638
+ streamUsage: false,
639
+ },
640
+ reasoningKey: 'reasoning',
641
+ },
642
+ returnContent: true,
643
+ skipCleanup: true,
644
+ customHandlers: createReasoningHandlers(
645
+ aggregateContent,
646
+ reasoningDeltas,
647
+ messageDeltas
648
+ ),
649
+ });
650
+
651
+ if (!run.Graph) {
652
+ throw new Error('Expected graph to be initialized');
653
+ }
654
+
655
+ run.Graph.overrideModel = new StreamingReasoningModel([
656
+ createOpenAIReasoningSummaryChunk(firstReasoningChunk),
657
+ createOpenAIReasoningSummaryChunk(secondReasoningChunk),
658
+ new AIMessageChunk({ content: text }),
659
+ ]);
660
+
661
+ await run.processStream(
662
+ { messages: [new HumanMessage('stream OpenAI summary reasoning')] },
663
+ {
664
+ ...config,
665
+ configurable: {
666
+ thread_id: 'reasoning-fallback-openai-summary-stream',
667
+ },
668
+ }
669
+ );
670
+
671
+ expect(reasoningDeltas).toHaveLength(2);
672
+ expect(messageDeltas).toHaveLength(1);
673
+ expect(contentParts).toEqual([
674
+ { type: ContentTypes.THINK, think: reasoningText },
675
+ { type: ContentTypes.TEXT, text },
676
+ ]);
677
+ });
678
+
679
+ it('preserves LibreChat-like callbacks including model_end usage collection', async () => {
680
+ const text = 'Visible answer.';
681
+ const reasoningText = 'Visible reasoning.';
682
+ const usage: UsageMetadata = {
683
+ input_tokens: 7,
684
+ output_tokens: 5,
685
+ total_tokens: 12,
686
+ output_token_details: {
687
+ reasoning: 3,
688
+ },
689
+ };
690
+ const collectedUsage: UsageMetadata[] = [];
691
+ const emittedEvents: Array<{ event: string; data: unknown }> = [];
692
+ const { contentParts, aggregateContent } = createContentAggregator();
693
+ const run = await Run.create<t.IState>({
694
+ runId: 'reasoning-fallback-librechat-callbacks',
695
+ graphConfig: {
696
+ type: 'standard',
697
+ llmConfig: {
698
+ provider: Providers.DEEPSEEK,
699
+ streamUsage: false,
700
+ },
701
+ },
702
+ returnContent: true,
703
+ skipCleanup: true,
704
+ customHandlers: createLibreChatLikeHandlers({
705
+ aggregateContent,
706
+ collectedUsage,
707
+ emittedEvents,
708
+ }),
709
+ });
710
+
711
+ if (!run.Graph) {
712
+ throw new Error('Expected graph to be initialized');
713
+ }
714
+
715
+ run.Graph.overrideModel = new CallbackStreamingReasoningModel([
716
+ createReasoningChunk('reasoning_content', reasoningText.slice(0, 8)),
717
+ createReasoningChunk('reasoning_content', reasoningText.slice(8)),
718
+ new AIMessageChunk({
719
+ content: text,
720
+ usage_metadata: usage,
721
+ }),
722
+ ]);
723
+
724
+ await run.processStream(
725
+ { messages: [new HumanMessage('stream with LibreChat handlers')] },
726
+ {
727
+ ...config,
728
+ configurable: {
729
+ thread_id: 'reasoning-fallback-librechat-callbacks',
730
+ },
731
+ }
732
+ );
733
+
734
+ const countEvents = (event: GraphEvents): number =>
735
+ emittedEvents.filter((entry) => entry.event === event).length;
736
+
737
+ expect(countEvents(GraphEvents.ON_REASONING_DELTA)).toBe(2);
738
+ expect(countEvents(GraphEvents.ON_MESSAGE_DELTA)).toBe(1);
739
+ expect(countEvents(GraphEvents.CHAT_MODEL_END)).toBe(1);
740
+ expect(collectedUsage).toHaveLength(1);
741
+ expect(collectedUsage[0]).toMatchObject(usage);
742
+ expect(contentParts).toEqual([
743
+ { type: ContentTypes.THINK, think: reasoningText },
744
+ { type: ContentTypes.TEXT, text },
745
+ ]);
746
+ });
747
+ });