Skip to content

Commit 2050862

Browse files
authored
fix: prioritize reasoning_details over reasoning during streaming to avoid duplicate emission (#123)
1 parent 10af3a4 commit 2050862

File tree

2 files changed

+122
-4
lines changed

2 files changed

+122
-4
lines changed

src/chat/index.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,48 @@ describe('doGenerate', () => {
331331
]);
332332
});
333333

334+
it('should prioritize reasoning_details over reasoning when both are present', async () => {
335+
prepareJsonResponse({
336+
content: 'Hello!',
337+
reasoning: 'This should be ignored when reasoning_details is present',
338+
reasoning_details: [
339+
{
340+
type: ReasoningDetailType.Text,
341+
text: 'Processing from reasoning_details...',
342+
},
343+
{
344+
type: ReasoningDetailType.Summary,
345+
summary: 'Summary from reasoning_details',
346+
},
347+
],
348+
});
349+
350+
const result = await model.doGenerate({
351+
prompt: TEST_PROMPT,
352+
});
353+
354+
expect(result.content).toStrictEqual([
355+
{
356+
type: 'reasoning',
357+
text: 'Processing from reasoning_details...',
358+
},
359+
{
360+
type: 'reasoning',
361+
text: 'Summary from reasoning_details',
362+
},
363+
{
364+
type: 'text',
365+
text: 'Hello!',
366+
},
367+
]);
368+
369+
// Verify that the reasoning field content is not included
370+
expect(result.content).not.toContainEqual({
371+
type: 'reasoning',
372+
text: 'This should be ignored when reasoning_details is present',
373+
});
374+
});
375+
334376
it('should pass the model and the messages', async () => {
335377
prepareJsonResponse({ content: '' });
336378

@@ -622,6 +664,81 @@ describe('doStream', () => {
622664
]);
623665
});
624666

667+
it('should prioritize reasoning_details over reasoning when both are present in streaming', async () => {
668+
// This test verifies that when the API returns both 'reasoning' and 'reasoning_details' fields,
669+
// we prioritize reasoning_details and ignore the reasoning field to avoid duplicates.
670+
server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = {
671+
type: 'stream-chunks',
672+
chunks: [
673+
// First chunk: both reasoning and reasoning_details with different content
674+
`data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` +
675+
`"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"",` +
676+
`"reasoning":"This should be ignored...",` +
677+
`"reasoning_details":[{"type":"${ReasoningDetailType.Text}","text":"Let me think about this..."}]},` +
678+
`"logprobs":null,"finish_reason":null}]}\n\n`,
679+
// Second chunk: reasoning_details with multiple types
680+
`data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` +
681+
`"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{` +
682+
`"reasoning":"Also ignored",` +
683+
`"reasoning_details":[{"type":"${ReasoningDetailType.Summary}","summary":"User wants a greeting"},{"type":"${ReasoningDetailType.Encrypted}","data":"secret"}]},` +
684+
`"logprobs":null,"finish_reason":null}]}\n\n`,
685+
// Third chunk: only reasoning field (should be processed)
686+
`data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` +
687+
`"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{` +
688+
`"reasoning":"This reasoning is used"},` +
689+
`"logprobs":null,"finish_reason":null}]}\n\n`,
690+
// Content chunk
691+
`data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` +
692+
`"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"Hello!"},` +
693+
`"logprobs":null,"finish_reason":null}]}\n\n`,
694+
// Finish chunk
695+
`data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` +
696+
`"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},` +
697+
`"logprobs":null,"finish_reason":"stop"}]}\n\n`,
698+
`data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` +
699+
`"system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":30,"total_tokens":47}}\n\n`,
700+
'data: [DONE]\n\n',
701+
],
702+
};
703+
704+
const { stream } = await model.doStream({
705+
prompt: TEST_PROMPT,
706+
});
707+
708+
const elements = await convertReadableStreamToArray(stream);
709+
710+
// Filter for reasoning-related elements
711+
const reasoningElements = elements.filter(el =>
712+
el.type === 'reasoning-start' ||
713+
el.type === 'reasoning-delta' ||
714+
el.type === 'reasoning-end'
715+
);
716+
717+
// Debug output to see what we're getting
718+
// console.log('Reasoning elements count:', reasoningElements.length);
719+
// console.log('Reasoning element types:', reasoningElements.map(el => el.type));
720+
721+
// We should get reasoning content from reasoning_details when present, not reasoning field
722+
// start + 4 deltas (text, summary, encrypted, reasoning-only) + end = 6
723+
expect(reasoningElements).toHaveLength(6);
724+
725+
// Verify the content comes from reasoning_details, not reasoning field
726+
const reasoningDeltas = reasoningElements
727+
.filter(el => el.type === 'reasoning-delta')
728+
.map(el => (el as { type: 'reasoning-delta'; delta: string; id: string }).delta);
729+
730+
expect(reasoningDeltas).toEqual([
731+
'Let me think about this...', // from reasoning_details text
732+
'User wants a greeting', // from reasoning_details summary
733+
'[REDACTED]', // from reasoning_details encrypted
734+
'This reasoning is used', // from reasoning field (no reasoning_details)
735+
]);
736+
737+
// Verify that "This should be ignored..." and "Also ignored" are NOT in the output
738+
expect(reasoningDeltas).not.toContain('This should be ignored...');
739+
expect(reasoningDeltas).not.toContain('Also ignored');
740+
});
741+
625742
it('should stream tool deltas', async () => {
626743
server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = {
627744
type: 'stream-chunks',

src/chat/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
539539
});
540540
};
541541

542-
if (delta.reasoning != null) {
543-
emitReasoningChunk(delta.reasoning);
544-
}
542+
545543
if (delta.reasoning_details && delta.reasoning_details.length > 0) {
546544
for (const detail of delta.reasoning_details) {
547545
switch (detail.type) {
@@ -570,7 +568,10 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
570568
}
571569
}
572570
}
573-
571+
else if (delta.reasoning != null) {
572+
emitReasoningChunk(delta.reasoning);
573+
}
574+
574575
if (delta.tool_calls != null) {
575576
for (const toolCallDelta of delta.tool_calls) {
576577
const index = toolCallDelta.index ?? toolCalls.length - 1;

0 commit comments

Comments
 (0)