@librechat/agents 3.2.61 → 3.2.63

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 (125) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +6 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/HookRegistry.cjs +32 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/executeHooks.cjs +6 -0
  8. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  9. package/dist/cjs/hooks/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -0
  11. package/dist/cjs/hooks/types.cjs.map +1 -1
  12. package/dist/cjs/langfuse.cjs +62 -1
  13. package/dist/cjs/langfuse.cjs.map +1 -1
  14. package/dist/cjs/llm/bedrock/index.cjs +266 -43
  15. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  16. package/dist/cjs/llm/openai/index.cjs +274 -7
  17. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  18. package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
  19. package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
  20. package/dist/cjs/llm/openrouter/index.cjs +5 -6
  21. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  22. package/dist/cjs/main.cjs +6 -1
  23. package/dist/cjs/messages/format.cjs +61 -0
  24. package/dist/cjs/messages/format.cjs.map +1 -1
  25. package/dist/cjs/messages/prune.cjs +31 -19
  26. package/dist/cjs/messages/prune.cjs.map +1 -1
  27. package/dist/cjs/stream.cjs +13 -3
  28. package/dist/cjs/stream.cjs.map +1 -1
  29. package/dist/cjs/tools/ToolNode.cjs +76 -11
  30. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +2 -0
  32. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  33. package/dist/cjs/utils/tokens.cjs +111 -0
  34. package/dist/cjs/utils/tokens.cjs.map +1 -1
  35. package/dist/esm/common/enum.mjs +2 -0
  36. package/dist/esm/common/enum.mjs.map +1 -1
  37. package/dist/esm/graphs/Graph.mjs +6 -2
  38. package/dist/esm/graphs/Graph.mjs.map +1 -1
  39. package/dist/esm/hooks/HookRegistry.mjs +32 -0
  40. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  41. package/dist/esm/hooks/executeHooks.mjs +6 -0
  42. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  43. package/dist/esm/hooks/index.mjs +12 -1
  44. package/dist/esm/hooks/index.mjs.map +1 -0
  45. package/dist/esm/hooks/types.mjs.map +1 -1
  46. package/dist/esm/langfuse.mjs +62 -1
  47. package/dist/esm/langfuse.mjs.map +1 -1
  48. package/dist/esm/llm/bedrock/index.mjs +266 -43
  49. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  50. package/dist/esm/llm/openai/index.mjs +274 -7
  51. package/dist/esm/llm/openai/index.mjs.map +1 -1
  52. package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
  53. package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
  54. package/dist/esm/llm/openrouter/index.mjs +5 -6
  55. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  56. package/dist/esm/main.mjs +3 -3
  57. package/dist/esm/messages/format.mjs +61 -0
  58. package/dist/esm/messages/format.mjs.map +1 -1
  59. package/dist/esm/messages/prune.mjs +31 -19
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/stream.mjs +13 -3
  62. package/dist/esm/stream.mjs.map +1 -1
  63. package/dist/esm/tools/ToolNode.mjs +76 -11
  64. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  65. package/dist/esm/tools/subagent/SubagentExecutor.mjs +2 -0
  66. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  67. package/dist/esm/utils/tokens.mjs +108 -1
  68. package/dist/esm/utils/tokens.mjs.map +1 -1
  69. package/dist/types/common/enum.d.ts +3 -1
  70. package/dist/types/graphs/Graph.d.ts +3 -1
  71. package/dist/types/hooks/HookRegistry.d.ts +10 -0
  72. package/dist/types/hooks/index.d.ts +7 -0
  73. package/dist/types/hooks/types.d.ts +18 -1
  74. package/dist/types/llm/bedrock/index.d.ts +5 -0
  75. package/dist/types/llm/openai/index.d.ts +19 -0
  76. package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
  77. package/dist/types/messages/prune.d.ts +4 -3
  78. package/dist/types/tools/ToolNode.d.ts +1 -0
  79. package/dist/types/types/graph.d.ts +10 -0
  80. package/dist/types/types/llm.d.ts +12 -2
  81. package/dist/types/types/tools.d.ts +1 -1
  82. package/dist/types/utils/tokens.d.ts +30 -0
  83. package/package.json +6 -6
  84. package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
  85. package/src/common/enum.ts +2 -0
  86. package/src/graphs/Graph.ts +12 -4
  87. package/src/hooks/HookRegistry.ts +45 -0
  88. package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
  89. package/src/hooks/__tests__/executeHooks.test.ts +85 -2
  90. package/src/hooks/executeHooks.ts +15 -0
  91. package/src/hooks/index.ts +7 -0
  92. package/src/hooks/types.ts +18 -1
  93. package/src/langfuse.ts +134 -1
  94. package/src/llm/bedrock/index.ts +434 -83
  95. package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
  96. package/src/llm/custom-chat-models.smoke.test.ts +7 -0
  97. package/src/llm/openai/index.ts +604 -6
  98. package/src/llm/openai/managedRequests.test.ts +182 -0
  99. package/src/llm/openai/streamMetadata.spec.ts +86 -0
  100. package/src/llm/openai/streamMetadata.ts +95 -0
  101. package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
  102. package/src/llm/openrouter/index.ts +9 -5
  103. package/src/messages/format.ts +96 -3
  104. package/src/messages/formatAgentMessages.steer.test.ts +326 -0
  105. package/src/messages/labelContentByAgent.test.ts +75 -0
  106. package/src/messages/prune.ts +56 -30
  107. package/src/specs/anthropic.simple.test.ts +4 -2
  108. package/src/specs/cache.simple.test.ts +17 -4
  109. package/src/specs/langfuse-callbacks.test.ts +61 -0
  110. package/src/specs/openai.simple.test.ts +4 -2
  111. package/src/specs/spec.utils.ts +12 -0
  112. package/src/specs/summarization.test.ts +9 -13
  113. package/src/specs/token-accounting-pipeline.test.ts +130 -4
  114. package/src/specs/tokens.test.ts +214 -0
  115. package/src/stream.ts +22 -3
  116. package/src/tools/ToolNode.ts +112 -13
  117. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
  118. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
  119. package/src/tools/__tests__/hitl.test.ts +112 -0
  120. package/src/tools/__tests__/subagentHooks.test.ts +124 -0
  121. package/src/tools/subagent/SubagentExecutor.ts +3 -0
  122. package/src/types/graph.ts +10 -0
  123. package/src/types/llm.ts +12 -2
  124. package/src/types/tools.ts +1 -1
  125. package/src/utils/tokens.ts +181 -3
@@ -0,0 +1,182 @@
1
+ import OpenAI from 'openai';
2
+
3
+ import {
4
+ addChatCacheBreakpoints,
5
+ addResponseCacheBreakpoints,
6
+ shouldIncludeEncryptedReasoning,
7
+ } from './index';
8
+
9
+ describe('managed GPT-5.6 request fields', () => {
10
+ it('places cache breakpoints after instructions and the prior history prefix', () => {
11
+ const messages = addChatCacheBreakpoints([
12
+ { role: 'system', content: 'Stable instructions.' },
13
+ { role: 'user', content: 'First question.' },
14
+ { role: 'assistant', content: 'First answer.' },
15
+ { role: 'user', content: 'Current question.' },
16
+ ]);
17
+
18
+ expect(messages[0]).toMatchObject({
19
+ content: [
20
+ {
21
+ type: 'text',
22
+ text: 'Stable instructions.',
23
+ prompt_cache_breakpoint: { mode: 'explicit' },
24
+ },
25
+ ],
26
+ });
27
+ expect(messages[2]).toMatchObject({
28
+ content: [
29
+ {
30
+ type: 'text',
31
+ text: 'First answer.',
32
+ prompt_cache_breakpoint: { mode: 'explicit' },
33
+ },
34
+ ],
35
+ });
36
+ expect(JSON.stringify(messages[1])).not.toContain(
37
+ 'prompt_cache_breakpoint'
38
+ );
39
+ expect(JSON.stringify(messages[3])).not.toContain(
40
+ 'prompt_cache_breakpoint'
41
+ );
42
+ });
43
+
44
+ it('uses supported Responses content blocks for the same stable prefixes', () => {
45
+ const input = [
46
+ {
47
+ type: 'message',
48
+ role: 'developer',
49
+ content: [{ type: 'input_text', text: 'Stable instructions.' }],
50
+ },
51
+ {
52
+ type: 'message',
53
+ role: 'user',
54
+ content: [{ type: 'input_text', text: 'Prior question.' }],
55
+ },
56
+ {
57
+ type: 'message',
58
+ role: 'user',
59
+ content: [{ type: 'input_text', text: 'Current question.' }],
60
+ },
61
+ ] as unknown as OpenAI.Responses.ResponseInput;
62
+ const result = addResponseCacheBreakpoints(input);
63
+
64
+ expect(result).toMatchObject([
65
+ {
66
+ content: [
67
+ {
68
+ prompt_cache_breakpoint: { mode: 'explicit' },
69
+ },
70
+ ],
71
+ },
72
+ {
73
+ content: [
74
+ {
75
+ prompt_cache_breakpoint: { mode: 'explicit' },
76
+ },
77
+ ],
78
+ },
79
+ {
80
+ content: [{ type: 'input_text', text: 'Current question.' }],
81
+ },
82
+ ]);
83
+ });
84
+
85
+ it('does not mark replayed assistant output blocks as breakpoints', () => {
86
+ const input = [
87
+ {
88
+ type: 'message',
89
+ role: 'developer',
90
+ content: [{ type: 'input_text', text: 'Stable instructions.' }],
91
+ },
92
+ {
93
+ type: 'message',
94
+ role: 'user',
95
+ content: [{ type: 'input_text', text: 'First question.' }],
96
+ },
97
+ {
98
+ type: 'message',
99
+ role: 'assistant',
100
+ content: [{ type: 'output_text', text: 'Prior answer.' }],
101
+ },
102
+ {
103
+ type: 'message',
104
+ role: 'user',
105
+ content: [{ type: 'input_text', text: 'Current question.' }],
106
+ },
107
+ ] as unknown as OpenAI.Responses.ResponseInput;
108
+ const result = addResponseCacheBreakpoints(input) as unknown as Array<{
109
+ content: Array<Record<string, unknown>>;
110
+ }>;
111
+
112
+ // output_text is rejected with a 400 by OpenAI, so it must stay unmarked;
113
+ // the stable prefix falls back to the prior user input message.
114
+ expect(result[2].content[0]).not.toHaveProperty('prompt_cache_breakpoint');
115
+ expect(result[1].content[0]).toHaveProperty('prompt_cache_breakpoint');
116
+ expect(result[0].content[0]).toHaveProperty('prompt_cache_breakpoint');
117
+ });
118
+
119
+ it('marks string-content Responses messages by wrapping them in input_text', () => {
120
+ const input = [
121
+ { type: 'message', role: 'system', content: 'Stable system prompt.' },
122
+ { type: 'message', role: 'user', content: 'Prior turn.' },
123
+ { type: 'message', role: 'user', content: 'Current question.' },
124
+ ] as unknown as OpenAI.Responses.ResponseInput;
125
+ const result = addResponseCacheBreakpoints(input) as unknown as Array<{
126
+ content: unknown;
127
+ }>;
128
+
129
+ expect(result[0].content).toEqual([
130
+ {
131
+ type: 'input_text',
132
+ text: 'Stable system prompt.',
133
+ prompt_cache_breakpoint: { mode: 'explicit' },
134
+ },
135
+ ]);
136
+ });
137
+
138
+ it('does not rewrite assistant string content as input_text', () => {
139
+ const input = [
140
+ { type: 'message', role: 'system', content: 'Stable system prompt.' },
141
+ { type: 'message', role: 'user', content: 'First question.' },
142
+ { type: 'message', role: 'assistant', content: 'Prior answer.' },
143
+ { type: 'message', role: 'user', content: 'Current question.' },
144
+ ] as unknown as OpenAI.Responses.ResponseInput;
145
+ const result = addResponseCacheBreakpoints(input) as unknown as Array<{
146
+ content: unknown;
147
+ }>;
148
+
149
+ // input_text under role:assistant is rejected with a 400, so assistant
150
+ // string content must stay a string and the breakpoint falls back to the
151
+ // prior user turn.
152
+ expect(result[2].content).toBe('Prior answer.');
153
+ expect(result[1].content).toEqual([
154
+ {
155
+ type: 'input_text',
156
+ text: 'First question.',
157
+ prompt_cache_breakpoint: { mode: 'explicit' },
158
+ },
159
+ ]);
160
+ });
161
+
162
+ it('requests encrypted reasoning whenever persisted or stateless replay may be needed', () => {
163
+ expect(shouldIncludeEncryptedReasoning('gpt-5.6', {})).toBe(true);
164
+ expect(
165
+ shouldIncludeEncryptedReasoning('gpt-5.6', {
166
+ reasoning: { context: 'all_turns' },
167
+ })
168
+ ).toBe(true);
169
+ expect(
170
+ shouldIncludeEncryptedReasoning('gpt-5.6', {
171
+ reasoning: { context: 'current_turn' },
172
+ })
173
+ ).toBe(false);
174
+ expect(
175
+ shouldIncludeEncryptedReasoning('gpt-5.6', {
176
+ store: false,
177
+ reasoning: { context: 'current_turn' },
178
+ })
179
+ ).toBe(true);
180
+ expect(shouldIncludeEncryptedReasoning('gpt-5.5', {})).toBe(false);
181
+ });
182
+ });
@@ -0,0 +1,86 @@
1
+ import { expect, test, describe } from '@jest/globals';
2
+ import { AIMessageChunk } from '@langchain/core/messages';
3
+ import { ChatGenerationChunk } from '@langchain/core/outputs';
4
+ import type { SeenScalarMetadata } from './streamMetadata';
5
+ import { dropRepeatedScalarMetadata } from './streamMetadata';
6
+
7
+ function chunkWithGenerationInfo(
8
+ generationInfo: Record<string, unknown>
9
+ ): ChatGenerationChunk {
10
+ return new ChatGenerationChunk({
11
+ text: '',
12
+ message: new AIMessageChunk({ content: '' }),
13
+ generationInfo,
14
+ });
15
+ }
16
+
17
+ describe('dropRepeatedScalarMetadata', () => {
18
+ test('drops a scalar repeated within the same completion (keep-first)', () => {
19
+ const seen: SeenScalarMetadata = new Map();
20
+ const first = chunkWithGenerationInfo({
21
+ completion: 0,
22
+ finish_reason: 'stop',
23
+ model_name: 'm',
24
+ });
25
+ const second = chunkWithGenerationInfo({
26
+ completion: 0,
27
+ finish_reason: 'stop',
28
+ model_name: 'm',
29
+ });
30
+
31
+ dropRepeatedScalarMetadata(first, seen);
32
+ dropRepeatedScalarMetadata(second, seen);
33
+
34
+ expect(first.generationInfo?.finish_reason).toBe('stop');
35
+ expect(second.generationInfo?.finish_reason).toBeUndefined();
36
+ expect(second.generationInfo?.model_name).toBeUndefined();
37
+ });
38
+
39
+ test('keeps a scalar that repeats across different completion indices', () => {
40
+ const seen: SeenScalarMetadata = new Map();
41
+ const choice0 = chunkWithGenerationInfo({
42
+ completion: 0,
43
+ finish_reason: 'stop',
44
+ model_name: 'm',
45
+ });
46
+ const choice1 = chunkWithGenerationInfo({
47
+ completion: 1,
48
+ finish_reason: 'stop',
49
+ model_name: 'm',
50
+ });
51
+
52
+ dropRepeatedScalarMetadata(choice0, seen);
53
+ dropRepeatedScalarMetadata(choice1, seen);
54
+
55
+ // each completion keeps its own finish metadata (n > 1 aggregates per choice)
56
+ expect(choice0.generationInfo?.finish_reason).toBe('stop');
57
+ expect(choice1.generationInfo?.finish_reason).toBe('stop');
58
+ });
59
+
60
+ test('clones before deleting so a shared metadata object is not mutated', () => {
61
+ const seen: SeenScalarMetadata = new Map();
62
+ // Simulate ChatDeepSeek splitting one raw chunk into pieces that share the
63
+ // same generationInfo object.
64
+ const shared = { completion: 0, finish_reason: 'stop', model_name: 'm' };
65
+ const first = new ChatGenerationChunk({
66
+ text: '',
67
+ message: new AIMessageChunk({ content: 'a' }),
68
+ generationInfo: shared,
69
+ });
70
+ const second = new ChatGenerationChunk({
71
+ text: '',
72
+ message: new AIMessageChunk({ content: 'b' }),
73
+ generationInfo: shared,
74
+ });
75
+
76
+ dropRepeatedScalarMetadata(first, seen);
77
+ dropRepeatedScalarMetadata(second, seen);
78
+
79
+ // the already-emitted first piece (and the shared object) keep finish_reason
80
+ expect(first.generationInfo?.finish_reason).toBe('stop');
81
+ expect(shared.finish_reason).toBe('stop');
82
+ // the repeat piece got its own cleaned copy
83
+ expect(second.generationInfo?.finish_reason).toBeUndefined();
84
+ expect(second.generationInfo).not.toBe(first.generationInfo);
85
+ });
86
+ });
@@ -0,0 +1,95 @@
1
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
2
+
3
+ /**
4
+ * `@langchain/openai` stamps these scalar fields together onto every chunk
5
+ * whose `choice.finish_reason` is set. Providers that emit `finish_reason` on
6
+ * more than one streamed chunk (e.g. OpenRouter) otherwise make core's
7
+ * `_mergeDicts` concatenate them into `stopstop` / duplicated model names — in
8
+ * both the aggregated graph message and the Langfuse trace. Core keeps
9
+ * `id`/`name`/`model_provider` last but not these, so keep the first occurrence
10
+ * and drop later repeats at the source, before either aggregation runs.
11
+ */
12
+ const REPEATED_SCALAR_METADATA_FIELDS = [
13
+ 'model_name',
14
+ 'finish_reason',
15
+ 'service_tier',
16
+ 'system_fingerprint',
17
+ ] as const;
18
+
19
+ /** Per-completion-index set of scalar fields already emitted this stream. */
20
+ export type SeenScalarMetadata = Map<number, Set<string>>;
21
+
22
+ function completionIndex(
23
+ generationInfo: Record<string, unknown> | undefined
24
+ ): number {
25
+ const completion = generationInfo?.completion;
26
+ return typeof completion === 'number' ? completion : 0;
27
+ }
28
+
29
+ /**
30
+ * Strip scalar `response_metadata`/`generationInfo` fields that have already
31
+ * appeared on an earlier chunk of the same completion (tracked in `seen`),
32
+ * keeping the first occurrence so the aggregated message still carries the
33
+ * value once. Scoped per completion index so multi-choice (`n > 1`) responses,
34
+ * which `_generate` aggregates separately, keep each choice's own metadata.
35
+ *
36
+ * Deletes from a shallow clone rather than in place: providers such as
37
+ * `ChatDeepSeek` split one raw chunk into several synthetic pieces that share
38
+ * the same `response_metadata`/`generationInfo` objects, so mutating in place
39
+ * would strip fields from sibling chunks that were already emitted.
40
+ */
41
+ export function dropRepeatedScalarMetadata(
42
+ chunk: ChatGenerationChunk,
43
+ seen: SeenScalarMetadata
44
+ ): void {
45
+ const generationInfo = chunk.generationInfo as
46
+ | Record<string, unknown>
47
+ | undefined;
48
+ const responseMetadata = chunk.message.response_metadata as Record<
49
+ string,
50
+ unknown
51
+ >;
52
+
53
+ const index = completionIndex(generationInfo);
54
+ let seenFields = seen.get(index);
55
+ if (seenFields == null) {
56
+ seenFields = new Set<string>();
57
+ seen.set(index, seenFields);
58
+ }
59
+
60
+ const dropFromGenerationInfo: string[] = [];
61
+ const dropFromResponseMetadata: string[] = [];
62
+ for (const field of REPEATED_SCALAR_METADATA_FIELDS) {
63
+ const inGenerationInfo =
64
+ generationInfo != null && generationInfo[field] != null;
65
+ const inResponseMetadata = responseMetadata[field] != null;
66
+ if (!inGenerationInfo && !inResponseMetadata) {
67
+ continue;
68
+ }
69
+ if (!seenFields.has(field)) {
70
+ seenFields.add(field);
71
+ continue;
72
+ }
73
+ if (inGenerationInfo) {
74
+ dropFromGenerationInfo.push(field);
75
+ }
76
+ if (inResponseMetadata) {
77
+ dropFromResponseMetadata.push(field);
78
+ }
79
+ }
80
+
81
+ if (dropFromGenerationInfo.length > 0 && generationInfo != null) {
82
+ const cloned = { ...generationInfo };
83
+ for (const field of dropFromGenerationInfo) {
84
+ delete cloned[field];
85
+ }
86
+ chunk.generationInfo = cloned;
87
+ }
88
+ if (dropFromResponseMetadata.length > 0) {
89
+ const cloned = { ...responseMetadata };
90
+ for (const field of dropFromResponseMetadata) {
91
+ delete cloned[field];
92
+ }
93
+ chunk.message.response_metadata = cloned;
94
+ }
95
+ }
@@ -0,0 +1,166 @@
1
+ import { concat } from '@langchain/core/utils/stream';
2
+ import { expect, test, describe } from '@jest/globals';
3
+ import { HumanMessage, AIMessageChunk } from '@langchain/core/messages';
4
+ import type {
5
+ NewTokenIndices,
6
+ HandleLLMNewTokenCallbackFields,
7
+ } from '@langchain/core/callbacks/base';
8
+ import type { LLMResult } from '@langchain/core/outputs';
9
+ import type { OpenAIClient } from '@langchain/openai';
10
+ import { ChatOpenAI } from './index';
11
+
12
+ /**
13
+ * Regression for duplicated stream `response_metadata` (issue #14086).
14
+ *
15
+ * Providers that emit `finish_reason` on more than one streamed chunk (e.g.
16
+ * OpenRouter / `openai/gpt-chat-latest`) made LangChain core's `_mergeDicts`
17
+ * concatenate the scalar fields it stamps alongside finish_reason — producing
18
+ * `finish_reason: "stopstop"` and duplicated `model_name`. That corruption
19
+ * appeared in BOTH aggregations: core's `_streamIterator` (feeds `handleLLMEnd`
20
+ * / the Langfuse GENERATION observation) and any host-side `concat` of the
21
+ * yielded chunks (the graph message / trace output). Both are exercised here.
22
+ */
23
+
24
+ type RawChunk = OpenAIClient.Chat.Completions.ChatCompletionChunk;
25
+
26
+ const MODEL = 'openai/gpt-chat-latest-20260505';
27
+
28
+ function stubStream(model: ChatOpenAI, chunks: RawChunk[]): void {
29
+ (
30
+ model as unknown as {
31
+ completions: {
32
+ completionWithRetry: (
33
+ request: unknown
34
+ ) => Promise<AsyncIterable<RawChunk>>;
35
+ };
36
+ }
37
+ ).completions.completionWithRetry = async (): Promise<
38
+ AsyncIterable<RawChunk>
39
+ > => ({
40
+ async *[Symbol.asyncIterator](): AsyncGenerator<RawChunk> {
41
+ for (const chunk of chunks) {
42
+ yield chunk;
43
+ }
44
+ },
45
+ });
46
+ }
47
+
48
+ function rawChunk(
49
+ delta: Record<string, unknown>,
50
+ finishReason: 'stop' | null,
51
+ extra: Partial<RawChunk> = {}
52
+ ): RawChunk {
53
+ return {
54
+ id: 'chatcmpl-1',
55
+ object: 'chat.completion.chunk',
56
+ created: 1,
57
+ model: MODEL,
58
+ choices: [
59
+ {
60
+ index: 0,
61
+ delta,
62
+ finish_reason: finishReason,
63
+ logprobs: null,
64
+ },
65
+ ],
66
+ ...extra,
67
+ } as unknown as RawChunk;
68
+ }
69
+
70
+ async function collect(model: ChatOpenAI): Promise<{
71
+ coreEnd?: AIMessageChunk;
72
+ hostConcat?: AIMessageChunk;
73
+ tokenFinishReasons: unknown[];
74
+ }> {
75
+ let coreEnd: AIMessageChunk | undefined;
76
+ const tokenFinishReasons: unknown[] = [];
77
+ const stream = await model.stream([new HumanMessage('hi')], {
78
+ callbacks: [
79
+ {
80
+ handleLLMEnd(output: LLMResult): void {
81
+ const generation = output.generations?.[0]?.[0] as
82
+ | { message?: AIMessageChunk }
83
+ | undefined;
84
+ coreEnd = generation?.message;
85
+ },
86
+ handleLLMNewToken(
87
+ _token: string,
88
+ _idx: NewTokenIndices,
89
+ _runId: string,
90
+ _parentRunId?: string,
91
+ _tags?: string[],
92
+ fields?: HandleLLMNewTokenCallbackFields
93
+ ): void {
94
+ // At emit time the scalars still live in `generationInfo` (core
95
+ // folds them into `response_metadata` only after it receives the
96
+ // chunk), so read the surface a token-callback consumer sees here.
97
+ const chunk = fields?.chunk as
98
+ | {
99
+ generationInfo?: Record<string, unknown>;
100
+ message?: AIMessageChunk;
101
+ }
102
+ | undefined;
103
+ const finishReason =
104
+ chunk?.generationInfo?.finish_reason ??
105
+ chunk?.message?.response_metadata?.finish_reason;
106
+ if (finishReason != null) {
107
+ tokenFinishReasons.push(finishReason);
108
+ }
109
+ },
110
+ },
111
+ ],
112
+ });
113
+ let hostConcat: AIMessageChunk | undefined;
114
+ for await (const chunk of stream) {
115
+ hostConcat = hostConcat == null ? chunk : concat(hostConcat, chunk);
116
+ }
117
+ return { coreEnd, hostConcat, tokenFinishReasons };
118
+ }
119
+
120
+ describe('streamed response_metadata scalar de-duplication', () => {
121
+ test('collapses repeated finish_reason / model_name across two finish chunks', async () => {
122
+ const model = new ChatOpenAI({ model: MODEL, apiKey: 'test-key' });
123
+ stubStream(model, [
124
+ rawChunk({ role: 'assistant', content: 'Hi' }, 'stop'),
125
+ rawChunk({ content: '' }, 'stop', {
126
+ service_tier: 'default',
127
+ } as Partial<RawChunk>),
128
+ ]);
129
+
130
+ const { coreEnd, hostConcat, tokenFinishReasons } = await collect(model);
131
+
132
+ for (const meta of [
133
+ coreEnd?.response_metadata,
134
+ hostConcat?.response_metadata,
135
+ ]) {
136
+ expect(meta?.finish_reason).toBe('stop');
137
+ expect(meta?.model_name).toBe(MODEL);
138
+ // keep-first still preserves a field that only appears on the 2nd chunk
139
+ expect(meta?.service_tier).toBe('default');
140
+ }
141
+ // token callbacks observe the same de-duplicated chunks (finish_reason
142
+ // stays on the first finish chunk only, never re-emitted on the repeat)
143
+ expect(tokenFinishReasons).toEqual(['stop']);
144
+ });
145
+
146
+ test('leaves a conformant single-finish stream untouched', async () => {
147
+ const model = new ChatOpenAI({ model: MODEL, apiKey: 'test-key' });
148
+ stubStream(model, [
149
+ rawChunk({ role: 'assistant', content: 'Hi' }, null),
150
+ rawChunk({ content: '' }, 'stop', {
151
+ system_fingerprint: 'fp_1',
152
+ } as Partial<RawChunk>),
153
+ ]);
154
+
155
+ const { coreEnd, hostConcat } = await collect(model);
156
+
157
+ for (const meta of [
158
+ coreEnd?.response_metadata,
159
+ hostConcat?.response_metadata,
160
+ ]) {
161
+ expect(meta?.finish_reason).toBe('stop');
162
+ expect(meta?.model_name).toBe(MODEL);
163
+ expect(meta?.system_fingerprint).toBe('fp_1');
164
+ }
165
+ });
166
+ });
@@ -6,7 +6,9 @@ import type {
6
6
  import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
7
7
  import type { ChatGenerationChunk } from '@langchain/core/outputs';
8
8
  import type { BaseMessage } from '@langchain/core/messages';
9
+ import type { SeenScalarMetadata } from '@/llm/openai/streamMetadata';
9
10
  import type { PromptCacheTtl } from '@/messages/cache';
11
+ import { dropRepeatedScalarMetadata } from '@/llm/openai/streamMetadata';
10
12
  import { ChatOpenAI, emitStreamChunkCallback } from '@/llm/openai';
11
13
 
12
14
  export type OpenRouterReasoningEffort =
@@ -240,8 +242,11 @@ export class ChatOpenRouter extends ChatOpenAI {
240
242
  string,
241
243
  OpenRouterReasoningEncryptedDetail
242
244
  >();
245
+ // Consume the raw (un-deduped) stream so `finish_reason` survives as the
246
+ // reasoning-flush signal below; de-duplicate after that, before emitting.
247
+ const seenScalarMetadata: SeenScalarMetadata = new Map();
243
248
 
244
- for await (const generationChunk of super._streamResponseChunks(
249
+ for await (const generationChunk of super._streamRawResponseChunks(
245
250
  messages,
246
251
  options,
247
252
  undefined
@@ -290,12 +295,11 @@ export class ChatOpenRouter extends ChatOpenAI {
290
295
  } else {
291
296
  delete generationChunk.message.additional_kwargs.reasoning_details;
292
297
  }
293
- await emitStreamChunkCallback(generationChunk, runManager);
294
- yield generationChunk;
295
- continue;
298
+ } else {
299
+ delete generationChunk.message.additional_kwargs.reasoning_details;
296
300
  }
297
301
 
298
- delete generationChunk.message.additional_kwargs.reasoning_details;
302
+ dropRepeatedScalarMetadata(generationChunk, seenScalarMetadata);
299
303
  await emitStreamChunkCallback(generationChunk, runManager);
300
304
  yield generationChunk;
301
305
  }