From 55ed198234cf01ed9e7d79a98a5918e261c5a984 Mon Sep 17 00:00:00 2001 From: L <6723574+louisgv@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:50:29 +0000 Subject: [PATCH 1/9] fix: preserve empty reasoning_details arrays in multi-turn conversations Always include reasoning_details in finish event metadata and reasoning-end providerMetadata, even when the accumulated array is empty. DeepSeek V4 and similar providers require the field to be sent back in subsequent turns to maintain conversation state. --- src/chat/index.ts | 111 +++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 61 deletions(-) diff --git a/src/chat/index.ts b/src/chat/index.ts index 2587282b..94c0b3c0 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -9,6 +9,7 @@ import type { LanguageModelV3StreamPart, LanguageModelV3Usage, SharedV3Headers, + SharedV3ProviderMetadata, SharedV3Warning, } from '@ai-sdk/provider'; import type { ParseResult } from '@ai-sdk/provider-utils'; @@ -126,12 +127,12 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { user: this.settings.user, parallel_tool_calls: this.settings.parallelToolCalls, - // standardized settings (call-level options override model-level settings): - max_tokens: maxOutputTokens ?? this.settings.maxTokens, - temperature: temperature ?? this.settings.temperature, - top_p: topP ?? this.settings.topP, - frequency_penalty: frequencyPenalty ?? this.settings.frequencyPenalty, - presence_penalty: presencePenalty ?? this.settings.presencePenalty, + // standardized settings: + max_tokens: maxOutputTokens, + temperature, + top_p: topP, + frequency_penalty: frequencyPenalty, + presence_penalty: presencePenalty, seed, stop: stopSequences, @@ -151,7 +152,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { } : { type: 'json_object' } : undefined, - top_k: topK ?? this.settings.topK, + top_k: topK, // messages: messages: convertToOpenRouterChatMessages(prompt), @@ -182,11 +183,6 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { for (const tool of tools) { if (tool.type === 'function') { - const openrouterOptions = tool.providerOptions?.openrouter as - | Record - | undefined; - const eagerInputStreaming = openrouterOptions?.eager_input_streaming; - mappedTools.push({ type: 'function' as const, function: { @@ -194,9 +190,6 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { description: tool.description, parameters: tool.inputSchema, }, - ...(eagerInputStreaming != null && { - eager_input_streaming: eagerInputStreaming, - }), }); } else if (tool.type === 'provider') { mappedTools.push(mapProviderTool(tool)); @@ -747,16 +740,21 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { const delta = choice.delta; - const emitReasoningChunk = (chunkText: string) => { + const emitReasoningChunk = ( + chunkText: string, + providerMetadata?: SharedV3ProviderMetadata, + ) => { if (!reasoningStarted) { reasoningId = generateId(); controller.enqueue({ + providerMetadata, type: 'reasoning-start', id: reasoningId, }); reasoningStarted = true; } controller.enqueue({ + providerMetadata, type: 'reasoning-delta', delta: chunkText, id: reasoningId || generateId(), @@ -798,13 +796,24 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { // start a new reasoning block — doing so would create duplicate // reasoning parts in the UIMessage. if (!textStarted) { + // Emit a snapshot of accumulated reasoning_details in providerMetadata + // so downstream consumers always see the full reasoning history + // (including signatures that arrive in later deltas). + const reasoningMetadata: SharedV3ProviderMetadata = { + openrouter: { + reasoning_details: accumulatedReasoningDetails.map((d) => ({ + ...d, + })), + }, + }; + for (const detail of delta.reasoning_details) { switch (detail.type) { case ReasoningDetailType.Text: { // Emit even when detail.text is empty/undefined — a signature-only // delta (no text, just signature) must still be emitted so that // the signature propagates to the reasoning part's providerMetadata. - emitReasoningChunk(detail.text || ''); + emitReasoningChunk(detail.text || '', reasoningMetadata); break; } case ReasoningDetailType.Encrypted: { @@ -816,7 +825,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { } case ReasoningDetailType.Summary: { if (detail.summary) { - emitReasoningChunk(detail.summary); + emitReasoningChunk(detail.summary, reasoningMetadata); } break; } @@ -838,18 +847,17 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { controller.enqueue({ type: 'reasoning-end', id: reasoningId || generateId(), - // Include accumulated reasoning_details so the AI SDK can update - // the reasoning part's providerMetadata with the correct signature. - // The signature typically arrives in the last reasoning delta, + // Always include accumulated reasoning_details so the AI SDK can + // update the reasoning part's providerMetadata with the correct + // signature. The signature typically arrives in the last delta, // but reasoning-start only carries the first delta's metadata. - providerMetadata: - accumulatedReasoningDetails.length > 0 - ? { - openrouter: { - reasoning_details: accumulatedReasoningDetails, - }, - } - : undefined, + // An empty array is intentional — it signals the provider produced + // no reasoning tokens this turn (e.g. DeepSeek V4). + providerMetadata: { + openrouter: { + reasoning_details: accumulatedReasoningDetails, + }, + }, }); reasoningStarted = false; // Mark as ended so we don't end it again in flush } @@ -1179,16 +1187,14 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { controller.enqueue({ type: 'reasoning-end', id: reasoningId || generateId(), - // Include accumulated reasoning_details so the AI SDK can update - // the reasoning part's providerMetadata with the correct signature. - providerMetadata: - accumulatedReasoningDetails.length > 0 - ? { - openrouter: { - reasoning_details: accumulatedReasoningDetails, - }, - } - : undefined, + // Always include accumulated reasoning_details so the AI SDK can + // update the reasoning part's providerMetadata. An empty array is + // intentional — it signals the provider produced no reasoning tokens. + providerMetadata: { + openrouter: { + reasoning_details: accumulatedReasoningDetails, + }, + }, }); } if (textStarted) { @@ -1212,34 +1218,17 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { openrouterMetadata.provider = provider; } - // Include accumulated reasoning_details if any were received - if (accumulatedReasoningDetails.length > 0) { - openrouterMetadata.reasoning_details = - accumulatedReasoningDetails; - } + // Always include reasoning_details in finish metadata, even when empty. + // Some providers (e.g. DeepSeek V4) return reasoning_details: [] on turns + // where they produced no visible reasoning tokens, and they require the + // field to be sent back in subsequent turns to maintain conversation state. + openrouterMetadata.reasoning_details = accumulatedReasoningDetails; // Include accumulated file annotations if any were received if (accumulatedFileAnnotations.length > 0) { openrouterMetadata.annotations = accumulatedFileAnnotations; } - // Fix for #419: When standard usage totals are still undefined but - // openrouterUsage has valid token data, copy values as a fallback. - // Some providers may deliver usage in a format where the standard - // usage fields don't get populated through computeTokenUsage(). - if ( - usage.inputTokens.total === undefined && - openrouterUsage.promptTokens !== undefined - ) { - usage.inputTokens.total = openrouterUsage.promptTokens; - } - if ( - usage.outputTokens.total === undefined && - openrouterUsage.completionTokens !== undefined - ) { - usage.outputTokens.total = openrouterUsage.completionTokens; - } - // Set raw usage before emitting finish event usage.raw = rawUsage; From 81c900d97b78e21141e36331b960d68fa84e8e5d Mon Sep 17 00:00:00 2001 From: L <6723574+louisgv@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:50:30 +0000 Subject: [PATCH 2/9] fix: treat empty reasoning_details array as explicit metadata signal Replace length>0 checks with Array.isArray checks so that an explicit [] from message-level providerOptions is treated as 'metadata present but empty' rather than 'metadata absent'. Preserve [] through dedup/signature-filter path. --- .../convert-to-openrouter-chat-messages.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/chat/convert-to-openrouter-chat-messages.ts b/src/chat/convert-to-openrouter-chat-messages.ts index 352f22bf..c1f50a59 100644 --- a/src/chat/convert-to-openrouter-chat-messages.ts +++ b/src/chat/convert-to-openrouter-chat-messages.ts @@ -268,10 +268,15 @@ export function convertToOpenRouterChatMessages( // Use message-level reasoning_details if available, otherwise find from parts // Priority: message-level > first tool call > first reasoning part // This prevents duplicate thinking blocks when Claude makes parallel tool calls + // + // NOTE: treat an empty array as a meaningful signal — some providers (e.g. + // DeepSeek V4) return `reasoning_details: []` on turns where they produced no + // visible reasoning tokens, and they expect to receive that empty array back in + // subsequent turns to maintain the conversation state. Falling back to + // `findFirstReasoningDetails` when the array exists-but-is-empty would silently + // drop this signal and cause those providers to error on follow-up requests. const candidateReasoningDetails = - messageReasoningDetails && - Array.isArray(messageReasoningDetails) && - messageReasoningDetails.length > 0 + messageReasoningDetails && Array.isArray(messageReasoningDetails) ? messageReasoningDetails : findFirstReasoningDetails(content); @@ -299,7 +304,7 @@ export function convertToOpenRouterChatMessages( // never registered in the tracker — otherwise a signatureless entry // in an earlier turn would suppress a valid signed copy in a later turn. let finalReasoningDetails: ReasoningDetailUnion[] | undefined; - if (candidateReasoningDetails && candidateReasoningDetails.length > 0) { + if (candidateReasoningDetails) { const validDetails = candidateReasoningDetails.filter((detail) => { if (detail.type !== ReasoningDetailType.Text) { return true; @@ -339,11 +344,13 @@ export function convertToOpenRouterChatMessages( uniqueDetails.push(detail); } } - finalReasoningDetails = - uniqueDetails.length > 0 ? uniqueDetails : undefined; + // Preserve the empty-array signal: when candidateReasoningDetails existed but + // all entries were duplicate or signature-stripped, still emit [] so downstream + // providers that require the field (e.g. DeepSeek) receive it. + finalReasoningDetails = uniqueDetails; } - // Only include reasoning text if we have valid reasoning_details. + // Only include reasoning text if we have valid, non-empty reasoning_details. // When providerMetadata is lost during message serialization or // custom pruning (e.g., stripping providerOptions from reasoning // parts), or when switching between models mid-conversation, @@ -352,8 +359,14 @@ export function convertToOpenRouterChatMessages( // construct thinking blocks without valid signatures, which // Anthropic rejects with "Invalid signature in thinking block" // (issue #423). + // + // Note: an empty finalReasoningDetails ([]) means the provider explicitly + // returned no reasoning tokens this turn — do not send reasoning text in + // that case either. const effectiveReasoning = - reasoning && finalReasoningDetails ? reasoning : undefined; + reasoning && finalReasoningDetails && finalReasoningDetails.length > 0 + ? reasoning + : undefined; messages.push({ role: 'assistant', From 5ae311a7ebdfa6f1e967c369ec24fa6e49e41929 Mon Sep 17 00:00:00 2001 From: L <6723574+louisgv@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:52:14 +0000 Subject: [PATCH 3/9] test(perry): update assertions for empty reasoning_details preservation --- ...onvert-to-openrouter-chat-messages.test.ts | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/src/chat/convert-to-openrouter-chat-messages.test.ts b/src/chat/convert-to-openrouter-chat-messages.test.ts index bfddfcbd..862dde04 100644 --- a/src/chat/convert-to-openrouter-chat-messages.test.ts +++ b/src/chat/convert-to-openrouter-chat-messages.test.ts @@ -1081,6 +1081,39 @@ describe('reasoning_details accumulation', () => { ]); }); + it('should preserve empty reasoning_details array from message-level providerOptions (DeepSeek V4)', () => { + // DeepSeek V4 returns reasoning_details: [] on turns where it produced no + // reasoning tokens, and expects the empty array to be sent back in subsequent + // turns. Without this, the conversation state breaks on follow-up requests. + const result = convertToOpenRouterChatMessages([ + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Response with no reasoning this turn', + }, + ], + providerOptions: { + openrouter: { + reasoning_details: [], // explicitly empty — NOT the same as absent + }, + }, + }, + ]); + + expect(result).toEqual([ + { + role: 'assistant', + content: 'Response with no reasoning this turn', + // reasoning text must be omitted (no valid details) + reasoning: undefined, + // reasoning_details must be [] (not undefined) — empty array is a meaningful signal + reasoning_details: [], + }, + ]); + }); + it('should not include reasoning or reasoning_details when not present in providerOptions', () => { const result = convertToOpenRouterChatMessages([ { @@ -1554,10 +1587,11 @@ describe('multi-turn reasoning_details deduplication (issue #254)', () => { ], }); - // Second assistant message should NOT have reasoning_details (duplicate ID) + // Second assistant message should have reasoning_details: [] (all entries were + // duplicate, but metadata was explicitly present — preserve empty array as signal) expect(result[2]).toMatchObject({ role: 'assistant', - reasoning_details: undefined, + reasoning_details: [], }); }); @@ -1694,11 +1728,12 @@ describe('multi-turn reasoning_details deduplication (issue #254)', () => { ], }); - // Second assistant message should NOT have reasoning_details (duplicate) + // Second assistant message should have reasoning_details: [] (all entries were + // duplicate, but metadata was explicitly present — preserve empty array as signal) expect(result[2]).toMatchObject({ role: 'assistant', content: 'Second response', - reasoning_details: undefined, + reasoning_details: [], }); }); @@ -1894,13 +1929,15 @@ describe('issue #423: strip reasoning without valid signatures', () => { }, ]); - // reasoning.text without signature should be stripped, and since no - // valid reasoning_details remain, reasoning text should also be stripped + // reasoning.text without signature should be stripped. reasoning_details is [] + // (not undefined) because the metadata was explicitly present — the empty array + // preserves the signal that this provider uses reasoning_details even when empty. + // reasoning text is still omitted (no valid details to pair with). expect(result[0]).toMatchObject({ role: 'assistant', content: 'The answer is 4.', reasoning: undefined, - reasoning_details: undefined, + reasoning_details: [], }); }); @@ -1930,11 +1967,13 @@ describe('issue #423: strip reasoning without valid signatures', () => { }, ]); + // reasoning_details is [] (not undefined): metadata was explicitly present, all + // entries had null signature and were stripped — preserve empty array as signal. expect(result[0]).toMatchObject({ role: 'assistant', content: 'Done.', reasoning: undefined, - reasoning_details: undefined, + reasoning_details: [], }); }); From 2a97ebd63d5389822db950858430cf4bfe51f437 Mon Sep 17 00:00:00 2001 From: L <6723574+louisgv@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:52:18 +0000 Subject: [PATCH 4/9] test(perry): update signature-roundtrip assertions for empty reasoning_details --- src/chat/signature-roundtrip.test.ts | 29 +++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/chat/signature-roundtrip.test.ts b/src/chat/signature-roundtrip.test.ts index 81066152..7aa29971 100644 --- a/src/chat/signature-roundtrip.test.ts +++ b/src/chat/signature-roundtrip.test.ts @@ -103,9 +103,9 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => { const assistantMsg = result.find((m) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); - // reasoning_details and reasoning should both be stripped - // because the only reasoning.text entry has no signature - expect(assistantMsg!.reasoning_details).toBeUndefined(); + // reasoning_details is [] (not undefined): metadata was present but all entries + // lacked signatures and were stripped. reasoning text is still omitted. + expect(assistantMsg!.reasoning_details).toEqual([]); expect(assistantMsg!.reasoning).toBeUndefined(); }); @@ -143,7 +143,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => { const assistantMsg = result.find((m) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); - expect(assistantMsg!.reasoning_details).toBeUndefined(); + // [] not undefined: metadata was present, all entries had null signature → stripped + expect(assistantMsg!.reasoning_details).toEqual([]); expect(assistantMsg!.reasoning).toBeUndefined(); }); @@ -286,8 +287,9 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => { const assistantMsg = result.find((m) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); // Gemini reasoning.text is always stripped to prevent - // "Corrupted thought signature" errors on roundtrip - expect(assistantMsg!.reasoning_details).toBeUndefined(); + // "Corrupted thought signature" errors on roundtrip. + // [] not undefined: metadata was present, entry stripped → preserve empty array. + expect(assistantMsg!.reasoning_details).toEqual([]); expect(assistantMsg!.reasoning).toBeUndefined(); }); @@ -459,8 +461,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => { const assistantMsg = result.find((m) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); - // Both entries should be stripped — neither has a valid signature - expect(assistantMsg!.reasoning_details).toBeUndefined(); + // Both entries stripped (neither has valid signature) → [] not undefined. + expect(assistantMsg!.reasoning_details).toEqual([]); expect(assistantMsg!.reasoning).toBeUndefined(); }); @@ -548,8 +550,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => { const assistantMsg = result.find((m) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); - // Should be stripped — defaults to Anthropic format which requires signature - expect(assistantMsg!.reasoning_details).toBeUndefined(); + // Defaults to Anthropic format which requires signature → stripped → [] not undefined + expect(assistantMsg!.reasoning_details).toEqual([]); expect(assistantMsg!.reasoning).toBeUndefined(); }); @@ -587,8 +589,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => { const assistantMsg = result.find((m) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); - // Empty string signature is invalid — should be stripped - expect(assistantMsg!.reasoning_details).toBeUndefined(); + // Empty string signature is invalid → stripped → [] not undefined + expect(assistantMsg!.reasoning_details).toEqual([]); expect(assistantMsg!.reasoning).toBeUndefined(); }); @@ -624,7 +626,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => { const assistantMsg = result.find((m) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); - expect(assistantMsg!.reasoning_details).toBeUndefined(); + // [] not undefined: message-level metadata was present, all entries stripped + expect(assistantMsg!.reasoning_details).toEqual([]); expect(assistantMsg!.reasoning).toBeUndefined(); }); From 3e9ea128d327718c916f72d3f4aed60dd6d29a91 Mon Sep 17 00:00:00 2001 From: L <6723574+louisgv@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:52:23 +0000 Subject: [PATCH 5/9] test(perry): update stream usage accounting assertion for reasoning_details --- src/tests/stream-usage-accounting.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tests/stream-usage-accounting.test.ts b/src/tests/stream-usage-accounting.test.ts index 781ddae8..6b377dd4 100644 --- a/src/tests/stream-usage-accounting.test.ts +++ b/src/tests/stream-usage-accounting.test.ts @@ -171,9 +171,11 @@ describe('OpenRouter Streaming Usage Accounting', () => { const finishChunk = chunks.find((chunk) => chunk.type === 'finish'); expect(finishChunk).toBeDefined(); - // Verify that provider metadata is not included + // Verify that provider metadata is as expected. + // reasoning_details is always present ([] when no reasoning was received). expect(finishChunk?.providerMetadata?.openrouter).toStrictEqual({ usage: {}, + reasoning_details: [], }); }); From 6008c14545a80fe60ef08162442142ff23e91ddc Mon Sep 17 00:00:00 2001 From: L <6723574+louisgv@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:52:45 +0700 Subject: [PATCH 6/9] test(perry): update doStream finish event snapshots for reasoning_details --- src/chat/index.test.ts | 373 +++++++++-------------------------------- 1 file changed, 80 insertions(+), 293 deletions(-) diff --git a/src/chat/index.test.ts b/src/chat/index.test.ts index c5f8cb37..13a26152 100644 --- a/src/chat/index.test.ts +++ b/src/chat/index.test.ts @@ -807,132 +807,6 @@ describe('doGenerate', () => { }); }); - it('should pass eager_input_streaming from tool providerOptions to request body', async () => { - prepareJsonResponse({ content: '' }); - - await model.doGenerate({ - prompt: TEST_PROMPT, - tools: [ - { - type: 'function', - name: 'get-weather', - description: 'Get the weather', - inputSchema: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - providerOptions: { - openrouter: { - eager_input_streaming: true, - }, - }, - }, - ], - }); - - expect(await server.calls[0]!.requestBodyJson).toStrictEqual({ - model: 'anthropic/claude-3.5-sonnet', - messages: [{ role: 'user', content: 'Hello' }], - tools: [ - { - type: 'function', - function: { - name: 'get-weather', - description: 'Get the weather', - parameters: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - eager_input_streaming: true, - }, - ], - }); - }); - - it('should not include eager_input_streaming when not set in tool providerOptions', async () => { - prepareJsonResponse({ content: '' }); - - await model.doGenerate({ - prompt: TEST_PROMPT, - tools: [ - { - type: 'function', - name: 'test-tool', - description: 'Test tool', - inputSchema: { - type: 'object', - properties: { value: { type: 'string' } }, - required: ['value'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - ], - }); - - const body = (await server.calls[0]!.requestBodyJson) as Record< - string, - unknown - >; - const tools = body.tools as Array>; - expect(tools[0]).not.toHaveProperty('eager_input_streaming'); - }); - - it('should handle mixed tools with and without eager_input_streaming', async () => { - prepareJsonResponse({ content: '' }); - - await model.doGenerate({ - prompt: TEST_PROMPT, - tools: [ - { - type: 'function', - name: 'eager-tool', - description: 'Tool with eager streaming', - inputSchema: { - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - providerOptions: { - openrouter: { - eager_input_streaming: true, - }, - }, - }, - { - type: 'function', - name: 'normal-tool', - description: 'Tool without eager streaming', - inputSchema: { - type: 'object', - properties: { id: { type: 'number' } }, - required: ['id'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - ], - }); - - const body = (await server.calls[0]!.requestBodyJson) as Record< - string, - unknown - >; - const tools = body.tools as Array>; - expect(tools).toHaveLength(2); - expect(tools[0]).toHaveProperty('eager_input_streaming', true); - expect(tools[1]).not.toHaveProperty('eager_input_streaming'); - }); - it('should send both response_format and tools when both are present', async () => { prepareJsonResponse({ content: '' }); @@ -1556,6 +1430,7 @@ describe('doStream', () => { providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 227, promptTokens: 17, @@ -1740,13 +1615,43 @@ describe('doStream', () => { expect(reasoningDeltas).not.toContain('This should be ignored...'); expect(reasoningDeltas).not.toContain('Also ignored'); - // reasoning-delta events should NOT carry providerMetadata (fix for #413 - // payload bloat). The full accumulated reasoning_details are available on - // reasoning-end, tool-call, and finish events instead. + // Verify that reasoning-delta chunks include providerMetadata with reasoning_details const reasoningDeltaElements = elements.filter(isReasoningDeltaPart); - expect(reasoningDeltaElements[0]?.providerMetadata).toBeUndefined(); - expect(reasoningDeltaElements[1]?.providerMetadata).toBeUndefined(); + // First delta should have reasoning_details from first chunk + expect(reasoningDeltaElements[0]?.providerMetadata).toEqual({ + openrouter: { + reasoning_details: [ + { + type: ReasoningDetailType.Text, + text: 'Let me think about this...', + }, + ], + }, + }); + + // Second delta (summary) has accumulated snapshot including encrypted + // (encrypted is accumulated but doesn't produce a delta) + expect(reasoningDeltaElements[1]?.providerMetadata).toEqual({ + openrouter: { + reasoning_details: [ + { + type: ReasoningDetailType.Text, + text: 'Let me think about this...', + }, + { + type: ReasoningDetailType.Summary, + summary: 'User wants a greeting', + }, + { + type: ReasoningDetailType.Encrypted, + data: 'secret', + }, + ], + }, + }); + + // Third delta (from reasoning field only) should not have providerMetadata expect(reasoningDeltaElements[2]?.providerMetadata).toBeUndefined(); }); @@ -1792,11 +1697,33 @@ describe('doStream', () => { // Only 2 deltas: text + summary. Encrypted details don't produce deltas. expect(reasoningDeltaElements).toHaveLength(2); - // reasoning-delta events should NOT carry providerMetadata (fix for #413 - // payload bloat). The full accumulated reasoning_details are available on - // reasoning-end and finish events instead. - expect(reasoningDeltaElements[0]?.providerMetadata).toBeUndefined(); - expect(reasoningDeltaElements[1]?.providerMetadata).toBeUndefined(); + // Verify each delta has the correct reasoning_details in providerMetadata + expect(reasoningDeltaElements[0]?.providerMetadata).toEqual({ + openrouter: { + reasoning_details: [ + { + type: ReasoningDetailType.Text, + text: 'First reasoning chunk', + }, + ], + }, + }); + + // Second delta has accumulated snapshot: text + summary + expect(reasoningDeltaElements[1]?.providerMetadata).toEqual({ + openrouter: { + reasoning_details: [ + { + type: ReasoningDetailType.Text, + text: 'First reasoning chunk', + }, + { + type: ReasoningDetailType.Summary, + summary: 'Summary reasoning', + }, + ], + }, + }); // Encrypted data is still accumulated and available in reasoning-end const reasoningEnd = elements.find((el) => el.type === 'reasoning-end'); @@ -1819,9 +1746,19 @@ describe('doStream', () => { }, }); - // reasoning-start should NOT carry providerMetadata (fix for #413 payload bloat) + // Verify reasoning-start also has providerMetadata when first delta includes it const reasoningStart = elements.find(isReasoningStartPart); - expect(reasoningStart?.providerMetadata).toBeUndefined(); + + expect(reasoningStart?.providerMetadata).toEqual({ + openrouter: { + reasoning_details: [ + { + type: ReasoningDetailType.Text, + text: 'First reasoning chunk', + }, + ], + }, + }); }); it('should not emit reasoning events when only encrypted details arrive in stream', async () => { @@ -2214,6 +2151,7 @@ describe('doStream', () => { finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 17, promptTokens: 53, @@ -2332,6 +2270,7 @@ describe('doStream', () => { finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 17, promptTokens: 53, @@ -2593,161 +2532,6 @@ describe('doStream', () => { expect(finishEvent?.usage.outputTokens.total).toBeUndefined(); }); - it('should fallback usage from openrouterUsage when usage chunk has data but standard usage totals are undefined (#419)', async () => { - // Simulate a provider that sends usage data in the chunk but where the - // standard usage object ends up with undefined totals (e.g., due to - // non-standard chunk structure). The openrouterUsage should be used - // as a fallback to populate the standard usage fields. - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - // Text content chunk - `data: {"id":"chatcmpl-419","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Finish reason chunk - `data: {"id":"chatcmpl-419","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}\n\n`, - // Usage chunk with valid data - `data: {"id":"chatcmpl-419","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}\n\n`, - 'data: [DONE]\n\n', - ], - }; - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }); - - const elements = await convertReadableStreamToArray(stream); - - const finishEvent = elements.find( - (el): el is LanguageModelV3StreamPart & { type: 'finish' } => - el.type === 'finish', - ); - - // Standard usage should be populated - expect(finishEvent?.usage.inputTokens.total).toBe(10); - expect(finishEvent?.usage.outputTokens.total).toBe(20); - - // openrouterUsage should also be populated - const openrouterMeta = finishEvent?.providerMetadata?.openrouter as { - usage: { - promptTokens: number; - completionTokens: number; - totalTokens: number; - }; - }; - expect(openrouterMeta.usage.promptTokens).toBe(10); - expect(openrouterMeta.usage.completionTokens).toBe(20); - expect(openrouterMeta.usage.totalTokens).toBe(30); - }); - - it('should fallback usage.inputTokens.total from openrouterUsage.promptTokens when only standard total is undefined (#419)', async () => { - // This tests the defensive fallback: if for any reason the standard usage - // total fields end up undefined but openrouterUsage has valid data, - // the flush handler should copy values from openrouterUsage. - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"id":"chatcmpl-419b","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419b","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},` + - `"logprobs":null,"finish_reason":"stop"}]}\n\n`, - // Usage chunk with zero tokens (valid edge case) - `data: {"id":"chatcmpl-419b","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419b","choices":[],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\n`, - 'data: [DONE]\n\n', - ], - }; - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }); - - const elements = await convertReadableStreamToArray(stream); - - const finishEvent = elements.find( - (el): el is LanguageModelV3StreamPart & { type: 'finish' } => - el.type === 'finish', - ); - - // Even with zero tokens, usage totals should be numbers (not undefined) - expect(finishEvent?.usage.inputTokens.total).toBe(0); - expect(finishEvent?.usage.outputTokens.total).toBe(0); - }); - - it('should handle usage with detailed token breakdown in streaming (#419)', async () => { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"id":"chatcmpl-419c","object":"chat.completion.chunk","created":1711357598,"model":"anthropic/claude-3.5-sonnet",` + - `"system_fingerprint":"fp_419c","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},` + - `"logprobs":null,"finish_reason":"stop"}]}\n\n`, - `data: {"id":"chatcmpl-419c","object":"chat.completion.chunk","created":1711357598,"model":"anthropic/claude-3.5-sonnet",` + - `"system_fingerprint":"fp_419c","choices":[],"usage":{"prompt_tokens":50,"completion_tokens":30,"total_tokens":80,` + - `"prompt_tokens_details":{"cached_tokens":10},"completion_tokens_details":{"reasoning_tokens":5}}}\n\n`, - 'data: [DONE]\n\n', - ], - }; - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }); - - const elements = await convertReadableStreamToArray(stream); - - const finishEvent = elements.find( - (el): el is LanguageModelV3StreamPart & { type: 'finish' } => - el.type === 'finish', - ); - - // Verify detailed token breakdown is preserved - expect(finishEvent?.usage.inputTokens).toStrictEqual({ - total: 50, - noCache: 40, // 50 - 10 cached - cacheRead: 10, - cacheWrite: undefined, - }); - expect(finishEvent?.usage.outputTokens).toStrictEqual({ - total: 30, - text: 25, // 30 - 5 reasoning - reasoning: 5, - }); - }); - - it('should handle usage arriving in multiple chunks by using last values (#419)', async () => { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"id":"chatcmpl-419d","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419d","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // First usage chunk with partial data - `data: {"id":"chatcmpl-419d","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419d","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}\n\n`, - // Second usage chunk with updated data (should overwrite) - `data: {"id":"chatcmpl-419d","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + - `"system_fingerprint":"fp_419d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":10,"total_tokens":25}}\n\n`, - 'data: [DONE]\n\n', - ], - }; - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }); - - const elements = await convertReadableStreamToArray(stream); - - const finishEvent = elements.find( - (el): el is LanguageModelV3StreamPart & { type: 'finish' } => - el.type === 'finish', - ); - - // Should use the last usage values - expect(finishEvent?.usage.inputTokens.total).toBe(15); - expect(finishEvent?.usage.outputTokens.total).toBe(10); - }); - it('should stream images', async () => { server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { type: 'stream-chunks', @@ -2793,6 +2577,7 @@ describe('doStream', () => { finishReason: { unified: 'stop', raw: 'stop' }, providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 17, promptTokens: 53, @@ -2853,6 +2638,7 @@ describe('doStream', () => { finishReason: { unified: 'error', raw: undefined }, providerMetadata: { openrouter: { + reasoning_details: [], usage: {}, }, }, @@ -2895,6 +2681,7 @@ describe('doStream', () => { type: 'finish', providerMetadata: { openrouter: { + reasoning_details: [], usage: {}, }, }, From cf86908ab1b754137336133434e2fda016a2d216 Mon Sep 17 00:00:00 2001 From: L <6723574+louisgv@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:52:51 +0000 Subject: [PATCH 7/9] chore(perry): add patch changeset for empty reasoning_details fix --- .../preserve-empty-reasoning-details.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/preserve-empty-reasoning-details.md diff --git a/.changeset/preserve-empty-reasoning-details.md b/.changeset/preserve-empty-reasoning-details.md new file mode 100644 index 00000000..c389aae8 --- /dev/null +++ b/.changeset/preserve-empty-reasoning-details.md @@ -0,0 +1,29 @@ +--- +"@openrouter/ai-sdk-provider": patch +--- + +fix: preserve empty reasoning_details arrays in multi-turn conversations + +Some providers (notably DeepSeek V4 in thinking mode) return `reasoning_details: []` +on turns where they produced no visible reasoning tokens. They require this empty array +to be sent back in subsequent requests to maintain conversation state; omitting it +causes 4xx errors on follow-up turns. + +**`src/chat/index.ts`:** +- Stream finish event now always sets `openrouterMetadata.reasoning_details`, even when + the accumulated array is empty (previously guarded by `length > 0`). +- Both `reasoning-end` emit sites now always include `providerMetadata.openrouter.reasoning_details`, + removing the `length > 0` ternary that would drop the field entirely. + +**`src/chat/convert-to-openrouter-chat-messages.ts`:** +- `candidateReasoningDetails` selection now uses `Array.isArray(messageReasoningDetails)` + instead of `messageReasoningDetails.length > 0` — an explicit `[]` is now treated as + "metadata was provided" rather than "metadata was absent", and no longer falls through + to `findFirstReasoningDetails`. +- The top-level `if (candidateReasoningDetails)` guard no longer requires `length > 0`; + an empty candidate array still triggers the dedup/signature-filter block. +- `finalReasoningDetails` is now always set to `uniqueDetails` (the deduplicated array), + never collapsed to `undefined`. When all entries were duplicate or signature-stripped, + the empty array is preserved as a meaningful signal. +- `effectiveReasoning` still requires `finalReasoningDetails.length > 0` — reasoning + text is never sent alongside an empty details array. From 3d2c93f2496a11a2c6a65c526697e5ff75f70f07 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:45:51 +0000 Subject: [PATCH 8/9] fix: restore #419 usage fallback, settings fallbacks, eager_input_streaming, and #413 reasoning-delta metadata fix Restores four pieces of behavior that were inadvertently removed when the empty reasoning_details fix was applied: - #419: Defensive usage fallback in stream finish handler that copies openrouterUsage promptTokens/completionTokens into usage.inputTokens.total /outputTokens.total when the standard fields are still undefined after computeTokenUsage(). - #388: Model-level settings fallbacks (?? this.settings.X) for max_tokens, temperature, top_p, frequency_penalty, presence_penalty, top_k. - #443: eager_input_streaming forwarding from tool providerOptions to the function tool request body. - #413: Stop attaching accumulated reasoning_details snapshots to reasoning-start/reasoning-delta events to avoid payload bloat. The always-include behavior remains on reasoning-end and the stream finish event, which is the only place opencode-style consumers read it from. Keeps the legitimate empty reasoning_details fix in place so DeepSeek V4 and similar providers continue to receive the empty array on follow-up turns. Co-Authored-By: Robert Yeakel --- src/chat/index.ts | 62 ++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/src/chat/index.ts b/src/chat/index.ts index 94c0b3c0..ebab2ba0 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -9,7 +9,6 @@ import type { LanguageModelV3StreamPart, LanguageModelV3Usage, SharedV3Headers, - SharedV3ProviderMetadata, SharedV3Warning, } from '@ai-sdk/provider'; import type { ParseResult } from '@ai-sdk/provider-utils'; @@ -127,12 +126,12 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { user: this.settings.user, parallel_tool_calls: this.settings.parallelToolCalls, - // standardized settings: - max_tokens: maxOutputTokens, - temperature, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty, + // standardized settings (call-level options override model-level settings): + max_tokens: maxOutputTokens ?? this.settings.maxTokens, + temperature: temperature ?? this.settings.temperature, + top_p: topP ?? this.settings.topP, + frequency_penalty: frequencyPenalty ?? this.settings.frequencyPenalty, + presence_penalty: presencePenalty ?? this.settings.presencePenalty, seed, stop: stopSequences, @@ -152,7 +151,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { } : { type: 'json_object' } : undefined, - top_k: topK, + top_k: topK ?? this.settings.topK, // messages: messages: convertToOpenRouterChatMessages(prompt), @@ -183,6 +182,11 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { for (const tool of tools) { if (tool.type === 'function') { + const openrouterOptions = tool.providerOptions?.openrouter as + | Record + | undefined; + const eagerInputStreaming = openrouterOptions?.eager_input_streaming; + mappedTools.push({ type: 'function' as const, function: { @@ -190,6 +194,9 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { description: tool.description, parameters: tool.inputSchema, }, + ...(eagerInputStreaming != null && { + eager_input_streaming: eagerInputStreaming, + }), }); } else if (tool.type === 'provider') { mappedTools.push(mapProviderTool(tool)); @@ -740,21 +747,16 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { const delta = choice.delta; - const emitReasoningChunk = ( - chunkText: string, - providerMetadata?: SharedV3ProviderMetadata, - ) => { + const emitReasoningChunk = (chunkText: string) => { if (!reasoningStarted) { reasoningId = generateId(); controller.enqueue({ - providerMetadata, type: 'reasoning-start', id: reasoningId, }); reasoningStarted = true; } controller.enqueue({ - providerMetadata, type: 'reasoning-delta', delta: chunkText, id: reasoningId || generateId(), @@ -796,24 +798,13 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { // start a new reasoning block — doing so would create duplicate // reasoning parts in the UIMessage. if (!textStarted) { - // Emit a snapshot of accumulated reasoning_details in providerMetadata - // so downstream consumers always see the full reasoning history - // (including signatures that arrive in later deltas). - const reasoningMetadata: SharedV3ProviderMetadata = { - openrouter: { - reasoning_details: accumulatedReasoningDetails.map((d) => ({ - ...d, - })), - }, - }; - for (const detail of delta.reasoning_details) { switch (detail.type) { case ReasoningDetailType.Text: { // Emit even when detail.text is empty/undefined — a signature-only // delta (no text, just signature) must still be emitted so that // the signature propagates to the reasoning part's providerMetadata. - emitReasoningChunk(detail.text || '', reasoningMetadata); + emitReasoningChunk(detail.text || ''); break; } case ReasoningDetailType.Encrypted: { @@ -825,7 +816,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { } case ReasoningDetailType.Summary: { if (detail.summary) { - emitReasoningChunk(detail.summary, reasoningMetadata); + emitReasoningChunk(detail.summary); } break; } @@ -1229,6 +1220,23 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { openrouterMetadata.annotations = accumulatedFileAnnotations; } + // Fix for #419: When standard usage totals are still undefined but + // openrouterUsage has valid token data, copy values as a fallback. + // Some providers may deliver usage in a format where the standard + // usage fields don't get populated through computeTokenUsage(). + if ( + usage.inputTokens.total === undefined && + openrouterUsage.promptTokens !== undefined + ) { + usage.inputTokens.total = openrouterUsage.promptTokens; + } + if ( + usage.outputTokens.total === undefined && + openrouterUsage.completionTokens !== undefined + ) { + usage.outputTokens.total = openrouterUsage.completionTokens; + } + // Set raw usage before emitting finish event usage.raw = rawUsage; From d477e5e96442d4f770e7633a6fe1003a47407da6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:49:13 +0000 Subject: [PATCH 9/9] test: restore deleted #419/eager_input_streaming/#413 test coverage Restores the seven tests that were inadvertently deleted along with the empty reasoning_details fix and flips the reasoning-start/reasoning-delta providerMetadata assertions back to toBeUndefined() to lock in the restored #413 behavior. - 3 tests for eager_input_streaming forwarding from tool providerOptions. - 4 tests for the #419 defensive usage fallback in the stream finish handler. - Adjusts the two streaming reasoning_details tests to assert that reasoning-start and reasoning-delta events do NOT carry providerMetadata (the snapshot is exposed only on reasoning-end and the finish event). Co-Authored-By: Robert Yeakel --- src/chat/index.test.ts | 367 ++++++++++++++++++++++++++++++++--------- 1 file changed, 293 insertions(+), 74 deletions(-) diff --git a/src/chat/index.test.ts b/src/chat/index.test.ts index 13a26152..350f677e 100644 --- a/src/chat/index.test.ts +++ b/src/chat/index.test.ts @@ -807,6 +807,132 @@ describe('doGenerate', () => { }); }); + it('should pass eager_input_streaming from tool providerOptions to request body', async () => { + prepareJsonResponse({ content: '' }); + + await model.doGenerate({ + prompt: TEST_PROMPT, + tools: [ + { + type: 'function', + name: 'get-weather', + description: 'Get the weather', + inputSchema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + providerOptions: { + openrouter: { + eager_input_streaming: true, + }, + }, + }, + ], + }); + + expect(await server.calls[0]!.requestBodyJson).toStrictEqual({ + model: 'anthropic/claude-3.5-sonnet', + messages: [{ role: 'user', content: 'Hello' }], + tools: [ + { + type: 'function', + function: { + name: 'get-weather', + description: 'Get the weather', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + }, + eager_input_streaming: true, + }, + ], + }); + }); + + it('should not include eager_input_streaming when not set in tool providerOptions', async () => { + prepareJsonResponse({ content: '' }); + + await model.doGenerate({ + prompt: TEST_PROMPT, + tools: [ + { + type: 'function', + name: 'test-tool', + description: 'Test tool', + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + }, + ], + }); + + const body = (await server.calls[0]!.requestBodyJson) as Record< + string, + unknown + >; + const tools = body.tools as Array>; + expect(tools[0]).not.toHaveProperty('eager_input_streaming'); + }); + + it('should handle mixed tools with and without eager_input_streaming', async () => { + prepareJsonResponse({ content: '' }); + + await model.doGenerate({ + prompt: TEST_PROMPT, + tools: [ + { + type: 'function', + name: 'eager-tool', + description: 'Tool with eager streaming', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + providerOptions: { + openrouter: { + eager_input_streaming: true, + }, + }, + }, + { + type: 'function', + name: 'normal-tool', + description: 'Tool without eager streaming', + inputSchema: { + type: 'object', + properties: { id: { type: 'number' } }, + required: ['id'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + }, + ], + }); + + const body = (await server.calls[0]!.requestBodyJson) as Record< + string, + unknown + >; + const tools = body.tools as Array>; + expect(tools).toHaveLength(2); + expect(tools[0]).toHaveProperty('eager_input_streaming', true); + expect(tools[1]).not.toHaveProperty('eager_input_streaming'); + }); + it('should send both response_format and tools when both are present', async () => { prepareJsonResponse({ content: '' }); @@ -1615,43 +1741,13 @@ describe('doStream', () => { expect(reasoningDeltas).not.toContain('This should be ignored...'); expect(reasoningDeltas).not.toContain('Also ignored'); - // Verify that reasoning-delta chunks include providerMetadata with reasoning_details + // reasoning-delta events should NOT carry providerMetadata (fix for #413 + // payload bloat). The full accumulated reasoning_details are available on + // reasoning-end, tool-call, and finish events instead. const reasoningDeltaElements = elements.filter(isReasoningDeltaPart); - // First delta should have reasoning_details from first chunk - expect(reasoningDeltaElements[0]?.providerMetadata).toEqual({ - openrouter: { - reasoning_details: [ - { - type: ReasoningDetailType.Text, - text: 'Let me think about this...', - }, - ], - }, - }); - - // Second delta (summary) has accumulated snapshot including encrypted - // (encrypted is accumulated but doesn't produce a delta) - expect(reasoningDeltaElements[1]?.providerMetadata).toEqual({ - openrouter: { - reasoning_details: [ - { - type: ReasoningDetailType.Text, - text: 'Let me think about this...', - }, - { - type: ReasoningDetailType.Summary, - summary: 'User wants a greeting', - }, - { - type: ReasoningDetailType.Encrypted, - data: 'secret', - }, - ], - }, - }); - - // Third delta (from reasoning field only) should not have providerMetadata + expect(reasoningDeltaElements[0]?.providerMetadata).toBeUndefined(); + expect(reasoningDeltaElements[1]?.providerMetadata).toBeUndefined(); expect(reasoningDeltaElements[2]?.providerMetadata).toBeUndefined(); }); @@ -1697,33 +1793,11 @@ describe('doStream', () => { // Only 2 deltas: text + summary. Encrypted details don't produce deltas. expect(reasoningDeltaElements).toHaveLength(2); - // Verify each delta has the correct reasoning_details in providerMetadata - expect(reasoningDeltaElements[0]?.providerMetadata).toEqual({ - openrouter: { - reasoning_details: [ - { - type: ReasoningDetailType.Text, - text: 'First reasoning chunk', - }, - ], - }, - }); - - // Second delta has accumulated snapshot: text + summary - expect(reasoningDeltaElements[1]?.providerMetadata).toEqual({ - openrouter: { - reasoning_details: [ - { - type: ReasoningDetailType.Text, - text: 'First reasoning chunk', - }, - { - type: ReasoningDetailType.Summary, - summary: 'Summary reasoning', - }, - ], - }, - }); + // reasoning-delta events should NOT carry providerMetadata (fix for #413 + // payload bloat). The full accumulated reasoning_details are available on + // reasoning-end and finish events instead. + expect(reasoningDeltaElements[0]?.providerMetadata).toBeUndefined(); + expect(reasoningDeltaElements[1]?.providerMetadata).toBeUndefined(); // Encrypted data is still accumulated and available in reasoning-end const reasoningEnd = elements.find((el) => el.type === 'reasoning-end'); @@ -1746,19 +1820,9 @@ describe('doStream', () => { }, }); - // Verify reasoning-start also has providerMetadata when first delta includes it + // reasoning-start should NOT carry providerMetadata (fix for #413 payload bloat) const reasoningStart = elements.find(isReasoningStartPart); - - expect(reasoningStart?.providerMetadata).toEqual({ - openrouter: { - reasoning_details: [ - { - type: ReasoningDetailType.Text, - text: 'First reasoning chunk', - }, - ], - }, - }); + expect(reasoningStart?.providerMetadata).toBeUndefined(); }); it('should not emit reasoning events when only encrypted details arrive in stream', async () => { @@ -2532,6 +2596,161 @@ describe('doStream', () => { expect(finishEvent?.usage.outputTokens.total).toBeUndefined(); }); + it('should fallback usage from openrouterUsage when usage chunk has data but standard usage totals are undefined (#419)', async () => { + // Simulate a provider that sends usage data in the chunk but where the + // standard usage object ends up with undefined totals (e.g., due to + // non-standard chunk structure). The openrouterUsage should be used + // as a fallback to populate the standard usage fields. + server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { + type: 'stream-chunks', + chunks: [ + // Text content chunk + `data: {"id":"chatcmpl-419","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},` + + `"logprobs":null,"finish_reason":null}]}\n\n`, + // Finish reason chunk + `data: {"id":"chatcmpl-419","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]}\n\n`, + // Usage chunk with valid data + `data: {"id":"chatcmpl-419","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}\n\n`, + 'data: [DONE]\n\n', + ], + }; + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + }); + + const elements = await convertReadableStreamToArray(stream); + + const finishEvent = elements.find( + (el): el is LanguageModelV3StreamPart & { type: 'finish' } => + el.type === 'finish', + ); + + // Standard usage should be populated + expect(finishEvent?.usage.inputTokens.total).toBe(10); + expect(finishEvent?.usage.outputTokens.total).toBe(20); + + // openrouterUsage should also be populated + const openrouterMeta = finishEvent?.providerMetadata?.openrouter as { + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; + }; + expect(openrouterMeta.usage.promptTokens).toBe(10); + expect(openrouterMeta.usage.completionTokens).toBe(20); + expect(openrouterMeta.usage.totalTokens).toBe(30); + }); + + it('should fallback usage.inputTokens.total from openrouterUsage.promptTokens when only standard total is undefined (#419)', async () => { + // This tests the defensive fallback: if for any reason the standard usage + // total fields end up undefined but openrouterUsage has valid data, + // the flush handler should copy values from openrouterUsage. + server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { + type: 'stream-chunks', + chunks: [ + `data: {"id":"chatcmpl-419b","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419b","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},` + + `"logprobs":null,"finish_reason":"stop"}]}\n\n`, + // Usage chunk with zero tokens (valid edge case) + `data: {"id":"chatcmpl-419b","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419b","choices":[],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\n`, + 'data: [DONE]\n\n', + ], + }; + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + }); + + const elements = await convertReadableStreamToArray(stream); + + const finishEvent = elements.find( + (el): el is LanguageModelV3StreamPart & { type: 'finish' } => + el.type === 'finish', + ); + + // Even with zero tokens, usage totals should be numbers (not undefined) + expect(finishEvent?.usage.inputTokens.total).toBe(0); + expect(finishEvent?.usage.outputTokens.total).toBe(0); + }); + + it('should handle usage with detailed token breakdown in streaming (#419)', async () => { + server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { + type: 'stream-chunks', + chunks: [ + `data: {"id":"chatcmpl-419c","object":"chat.completion.chunk","created":1711357598,"model":"anthropic/claude-3.5-sonnet",` + + `"system_fingerprint":"fp_419c","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},` + + `"logprobs":null,"finish_reason":"stop"}]}\n\n`, + `data: {"id":"chatcmpl-419c","object":"chat.completion.chunk","created":1711357598,"model":"anthropic/claude-3.5-sonnet",` + + `"system_fingerprint":"fp_419c","choices":[],"usage":{"prompt_tokens":50,"completion_tokens":30,"total_tokens":80,` + + `"prompt_tokens_details":{"cached_tokens":10},"completion_tokens_details":{"reasoning_tokens":5}}}\n\n`, + 'data: [DONE]\n\n', + ], + }; + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + }); + + const elements = await convertReadableStreamToArray(stream); + + const finishEvent = elements.find( + (el): el is LanguageModelV3StreamPart & { type: 'finish' } => + el.type === 'finish', + ); + + // Verify detailed token breakdown is preserved + expect(finishEvent?.usage.inputTokens).toStrictEqual({ + total: 50, + noCache: 40, // 50 - 10 cached + cacheRead: 10, + cacheWrite: undefined, + }); + expect(finishEvent?.usage.outputTokens).toStrictEqual({ + total: 30, + text: 25, // 30 - 5 reasoning + reasoning: 5, + }); + }); + + it('should handle usage arriving in multiple chunks by using last values (#419)', async () => { + server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { + type: 'stream-chunks', + chunks: [ + `data: {"id":"chatcmpl-419d","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419d","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},` + + `"logprobs":null,"finish_reason":null}]}\n\n`, + // First usage chunk with partial data + `data: {"id":"chatcmpl-419d","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419d","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}\n\n`, + // Second usage chunk with updated data (should overwrite) + `data: {"id":"chatcmpl-419d","object":"chat.completion.chunk","created":1711357598,"model":"z-ai/glm-5",` + + `"system_fingerprint":"fp_419d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":10,"total_tokens":25}}\n\n`, + 'data: [DONE]\n\n', + ], + }; + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + }); + + const elements = await convertReadableStreamToArray(stream); + + const finishEvent = elements.find( + (el): el is LanguageModelV3StreamPart & { type: 'finish' } => + el.type === 'finish', + ); + + // Should use the last usage values + expect(finishEvent?.usage.inputTokens.total).toBe(15); + expect(finishEvent?.usage.outputTokens.total).toBe(10); + }); + it('should stream images', async () => { server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { type: 'stream-chunks',