Skip to content

Commit 8cc81c7

Browse files
test(core): add test for response_metadata in streamEvents (#9589)
Co-authored-by: Christian Bromann <[email protected]>
1 parent 5082921 commit 8cc81c7

File tree

3 files changed

+68
-4
lines changed

3 files changed

+68
-4
lines changed

.changeset/ninety-penguins-lie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@langchain/core": patch
3+
---
4+
5+
test(core): add test for response_metadata in streamEvents

libs/langchain-core/src/runnables/tests/runnable_stream_events_v2.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,42 @@ test("Runnable streamEvents method on a chat model", async () => {
136136
]);
137137
});
138138

139+
test("Runnable streamEvents should preserve response_metadata from generationInfo", async () => {
140+
// Test for issue #8470: streamEvents doesn't return response_metadata
141+
// This verifies that generationInfo (which contains finish_reason, usage, etc.)
142+
// is properly merged into response_metadata and surfaced in stream events
143+
const model = new FakeListChatModel({
144+
responses: ["abc"],
145+
generationInfo: {
146+
finish_reason: "stop",
147+
model_name: "test-model",
148+
usage: { prompt_tokens: 10, completion_tokens: 5 },
149+
},
150+
});
151+
152+
const events = [];
153+
const eventStream = await model.streamEvents("hello", { version: "v2" });
154+
for await (const event of eventStream) {
155+
events.push(event);
156+
}
157+
158+
// Find the on_chat_model_end event
159+
const endEvent = events.find(
160+
(e: { event: string }) => e.event === "on_chat_model_end"
161+
);
162+
expect(endEvent).toBeDefined();
163+
164+
// Verify response_metadata contains the generationInfo data
165+
const output = (endEvent as { data: { output: AIMessageChunk } }).data.output;
166+
expect(output.response_metadata).toBeDefined();
167+
expect(output.response_metadata.finish_reason).toBe("stop");
168+
expect(output.response_metadata.model_name).toBe("test-model");
169+
expect(output.response_metadata.usage).toEqual({
170+
prompt_tokens: 10,
171+
completion_tokens: 5,
172+
});
173+
});
174+
139175
test("Runnable streamEvents call nested in another runnable + passed callbacks should still work", async () => {
140176
AsyncLocalStorageProviderSingleton.initializeGlobalInstance(
141177
new AsyncLocalStorage()

libs/langchain-core/src/utils/testing/chat_models.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,13 @@ export interface FakeChatInput extends BaseChatModelParams {
284284
sleep?: number;
285285

286286
emitCustomEvent?: boolean;
287+
288+
/**
289+
* Generation info to include on the last chunk during streaming.
290+
* This gets merged into response_metadata by the base chat model.
291+
* Useful for testing response_metadata propagation (e.g., finish_reason).
292+
*/
293+
generationInfo?: Record<string, unknown>;
287294
}
288295

289296
export interface FakeListChatModelCallOptions extends BaseChatModelCallOptions {
@@ -325,12 +332,15 @@ export class FakeListChatModel extends BaseChatModel<FakeListChatModelCallOption
325332

326333
emitCustomEvent = false;
327334

335+
generationInfo?: Record<string, unknown>;
336+
328337
constructor(params: FakeChatInput) {
329338
super(params);
330-
const { responses, sleep, emitCustomEvent } = params;
339+
const { responses, sleep, emitCustomEvent, generationInfo } = params;
331340
this.responses = responses;
332341
this.sleep = sleep;
333342
this.emitCustomEvent = emitCustomEvent ?? this.emitCustomEvent;
343+
this.generationInfo = generationInfo;
334344
}
335345

336346
_combineLLMOutput() {
@@ -391,12 +401,20 @@ export class FakeListChatModel extends BaseChatModel<FakeListChatModelCallOption
391401
});
392402
}
393403

394-
for await (const text of response) {
404+
const responseChars = [...response];
405+
for (let i = 0; i < responseChars.length; i++) {
406+
const text = responseChars[i];
407+
const isLastChunk = i === responseChars.length - 1;
395408
await this._sleepIfRequested();
396409
if (options?.thrownErrorString) {
397410
throw new Error(options.thrownErrorString);
398411
}
399-
const chunk = this._createResponseChunk(text);
412+
// Include generationInfo on the last chunk (like real providers do)
413+
// This gets merged into response_metadata by the base chat model
414+
const chunk = this._createResponseChunk(
415+
text,
416+
isLastChunk ? this.generationInfo : undefined
417+
);
400418
yield chunk;
401419
// eslint-disable-next-line no-void
402420
void runManager?.handleLLMNewToken(text);
@@ -415,10 +433,15 @@ export class FakeListChatModel extends BaseChatModel<FakeListChatModelCallOption
415433
});
416434
}
417435

418-
_createResponseChunk(text: string): ChatGenerationChunk {
436+
_createResponseChunk(
437+
text: string,
438+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
439+
generationInfo?: Record<string, any>
440+
): ChatGenerationChunk {
419441
return new ChatGenerationChunk({
420442
message: new AIMessageChunk({ content: text }),
421443
text,
444+
generationInfo,
422445
});
423446
}
424447

0 commit comments

Comments
 (0)