From 6f312795211fd28935d45af715491ef40ac4904e Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 22 Aug 2026 03:04:37 +0800 Subject: [PATCH 1/2] fix(ai-openrouter): rebase stream usage override fix --- packages/ai-openrouter/src/adapters/text.ts | 59 +++++----- .../tests/openrouter-adapter.test.ts | 105 ++++++++++-------- 2 files changed, 84 insertions(+), 80 deletions(-) diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index d92256f58..e74e8102d 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -42,7 +42,6 @@ import type { import type { ExternalTextProviderOptions, OpenRouterSystemPromptMetadata, - ReasoningOptions, } from '../text/text-provider-options' import type { OpenRouterImageMetadata, @@ -69,20 +68,22 @@ type ResolveToolCapabilities = ? NonNullable : readonly [] -function normalizeReasoningOptions( - reasoning: ReasoningOptions | undefined, -): ChatRequest['reasoning'] | undefined { - if (!reasoning) return undefined - - const { enabled, ...sdkReasoning } = reasoning - const normalized = - enabled === false - ? { ...sdkReasoning, effort: 'none' as const } - : sdkReasoning +function withUsageEnabled( + request: Omit, +): Omit { + const { streamOptions, ...requestWithoutStreamOptions } = request + if (streamOptions?.includeUsage === false) { + const { includeUsage: _includeUsage, ...callerStreamOptions } = + streamOptions + return Object.keys(callerStreamOptions).length > 0 + ? { ...requestWithoutStreamOptions, streamOptions: callerStreamOptions } + : requestWithoutStreamOptions + } - return Object.values(normalized).some((value) => value !== undefined) - ? normalized - : undefined + return { + ...request, + streamOptions: { ...streamOptions, includeUsage: true }, + } } /** @@ -152,6 +153,7 @@ export class OpenRouterTextAdapter< // other failure mode here — callers iterating chatStream then only need // one error-handling path. const chatRequest = this.mapOptionsToRequest(options) + const chatRequestWithUsage = withUsageEnabled(chatRequest) options.logger.request( `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, { provider: this.name, model: this.model }, @@ -160,12 +162,8 @@ export class OpenRouterTextAdapter< const stream = await this.orClient.chat.send( { chatRequest: { - ...chatRequest, + ...chatRequestWithUsage, stream: true, - streamOptions: { - ...(chatRequest.streamOptions ?? {}), - includeUsage: true, - }, }, }, { @@ -391,20 +389,21 @@ export class OpenRouterTextAdapter< }.bind(this) try { - // Strip streamOptions/tools/responseFormat from the base request before - // adding the resolved structured-output format. Structured output - // doesn't carry tools — keeping them can confuse strict-mode validation - // upstream. (`stream` is already absent — `mapOptionsToRequest` returns - // `Omit`; we set it explicitly below.) + // Strip tools/responseFormat from the base request before adding the + // resolved structured-output format. Structured output doesn't carry + // tools — keeping them can confuse strict-mode validation upstream. + // `withUsageEnabled` preserves caller stream options while omitting the + // whole option when usage is explicitly disabled. (`stream` is already + // absent — `mapOptionsToRequest` returns `Omit`; + // we set it explicitly below.) const { - streamOptions: _so, tools: _t, responseFormat: _responseFormat, ...cleanParams } = chatRequest - void _so void _t void _responseFormat + const cleanParamsWithUsage = withUsageEnabled(cleanParams) chatOptions.logger.request( `activity=structuredOutputStream provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, @@ -415,9 +414,8 @@ export class OpenRouterTextAdapter< const stream = await this.orClient.chat.send( { chatRequest: { - ...cleanParams, + ...cleanParamsWithUsage, stream: true, - streamOptions: { includeUsage: true }, responseFormat, }, }, @@ -1197,10 +1195,8 @@ export class OpenRouterTextAdapter< // `variant` is OpenRouter metadata used only to build the `:variant` model // suffix — it must NOT be spread into the request body. Destructure it out // so the remaining sampling/provider options flow through `...restModelOptions`. - const { variant, reasoning, ...restModelOptions } = (options.modelOptions ?? - {}) as ExternalTextProviderOptions + const { variant, ...restModelOptions } = options.modelOptions ?? {} const variantSuffix = variant ? `:${variant}` : '' - const normalizedReasoning = normalizeReasoningOptions(reasoning) const messages: Array = [] const systemPrompts = @@ -1263,7 +1259,6 @@ export class OpenRouterTextAdapter< // SDK validates `chatRequest.metadata` as `Record` (#735). const request: Omit = { ...restModelOptions, - ...(normalizedReasoning && { reasoning: normalizedReasoning }), model: options.model + variantSuffix, messages, ...(tools && tools.length > 0 && { tools }), diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 499006534..0cabc7385 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -1583,6 +1583,37 @@ describe('OpenRouter structured output', () => { expect(rawParams.chatRequest.stream).toBe(true) }) + it('respects includeUsage false for structured output streams', async () => { + setupMockSdkClient([ + { + id: 'c1', + model: 'anthropic/claude-sonnet-4.5', + choices: [{ delta: { content: '{"ok":true}' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + await chat({ + adapter, + messages: [{ role: 'user', content: 'Respond with ok' }], + modelOptions: { streamOptions: { includeUsage: false } }, + outputSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + }) + + const structuredCall = mockSend.mock.calls.find( + ([args]: Array) => args.chatRequest.responseFormat, + ) + expect(structuredCall).toBeDefined() + expect(structuredCall[0].chatRequest).not.toHaveProperty('streamOptions') + expect( + ChatRequest$outboundSchema.parse(structuredCall[0].chatRequest), + ).not.toHaveProperty('stream_options') + }) + it('parses JSON response content correctly', async () => { const nonStreamResponse = { choices: [ @@ -1769,54 +1800,6 @@ describe('OpenRouter modelOptions pass-through', () => { expect(params.maxCompletionTokens).toBe(64) }) - it('normalizes reasoning enabled false to effort none on the wire (#1006)', async () => { - setupMockSdkClient(minimalStreamChunks) - const adapter = createAdapter() - - const modelOptions: OpenRouterTextModelOptions = { - reasoning: { enabled: false }, - } - - for await (const _ of chat({ - adapter, - messages: [{ role: 'user', content: 'test' }], - modelOptions, - })) { - // consume - } - - const [rawParams] = mockSend.mock.calls[0]! - const params = rawParams.chatRequest - expect(params.reasoning).toEqual({ effort: 'none' }) - - const serialized = ChatRequest$outboundSchema.parse(params) - expect(serialized.reasoning).toEqual({ effort: 'none' }) - }) - - it('omits an empty reasoning object from the SDK request (#1006)', async () => { - setupMockSdkClient(minimalStreamChunks) - const adapter = createAdapter() - - const modelOptions: OpenRouterTextModelOptions = { - reasoning: {}, - } - - for await (const _ of chat({ - adapter, - messages: [{ role: 'user', content: 'test' }], - modelOptions, - })) { - // consume - } - - const [rawParams] = mockSend.mock.calls[0]! - const params = rawParams.chatRequest - expect(params).not.toHaveProperty('reasoning') - - const serialized = ChatRequest$outboundSchema.parse(params) - expect(serialized).not.toHaveProperty('reasoning') - }) - it('uses variant only for the model suffix and never sends it in the request body', async () => { setupMockSdkClient(minimalStreamChunks) const adapter = createAdapter() @@ -2453,6 +2436,32 @@ describe('OpenRouter stream_options conversion', () => { expect((serialized as any).stream_options).toEqual({ include_usage: true }) }) + it('respects an explicit includeUsage false override', async () => { + setupMockSdkClient([ + { + id: 'x', + model: 'anthropic/claude-sonnet-4.5', + choices: [{ delta: { content: 'hi' }, finishReason: 'stop' }], + }, + ]) + const adapter = createAdapter() + + for await (const _ of adapter.chatStream({ + model: 'anthropic/claude-sonnet-4.5', + messages: [{ role: 'user', content: 'hi' }], + modelOptions: { streamOptions: { includeUsage: false } }, + logger: testLogger, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + expect(rawParams.chatRequest).not.toHaveProperty('streamOptions') + expect( + ChatRequest$outboundSchema.parse(rawParams.chatRequest), + ).not.toHaveProperty('stream_options') + }) + it('propagates the abort signal to the SDK call', async () => { setupMockSdkClient([ { From 94ce32ce0cd3ce41205d169c9ed5d50131029e51 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 25 Aug 2026 00:20:01 +0800 Subject: [PATCH 2/2] fix(ai-openrouter): preserve reasoning normalization --- packages/ai-openrouter/src/adapters/text.ts | 22 ++++++++- .../tests/openrouter-adapter.test.ts | 48 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index e74e8102d..7de0d3d1e 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -42,6 +42,7 @@ import type { import type { ExternalTextProviderOptions, OpenRouterSystemPromptMetadata, + ReasoningOptions, } from '../text/text-provider-options' import type { OpenRouterImageMetadata, @@ -68,6 +69,22 @@ type ResolveToolCapabilities = ? NonNullable : readonly [] +function normalizeReasoningOptions( + reasoning: ReasoningOptions | undefined, +): ChatRequest['reasoning'] | undefined { + if (!reasoning) return undefined + + const { enabled, ...sdkReasoning } = reasoning + const normalized = + enabled === false + ? { ...sdkReasoning, effort: 'none' as const } + : sdkReasoning + + return Object.values(normalized).some((value) => value !== undefined) + ? normalized + : undefined +} + function withUsageEnabled( request: Omit, ): Omit { @@ -1195,8 +1212,10 @@ export class OpenRouterTextAdapter< // `variant` is OpenRouter metadata used only to build the `:variant` model // suffix — it must NOT be spread into the request body. Destructure it out // so the remaining sampling/provider options flow through `...restModelOptions`. - const { variant, ...restModelOptions } = options.modelOptions ?? {} + const { variant, reasoning, ...restModelOptions } = (options.modelOptions ?? + {}) as ExternalTextProviderOptions const variantSuffix = variant ? `:${variant}` : '' + const normalizedReasoning = normalizeReasoningOptions(reasoning) const messages: Array = [] const systemPrompts = @@ -1259,6 +1278,7 @@ export class OpenRouterTextAdapter< // SDK validates `chatRequest.metadata` as `Record` (#735). const request: Omit = { ...restModelOptions, + ...(normalizedReasoning && { reasoning: normalizedReasoning }), model: options.model + variantSuffix, messages, ...(tools && tools.length > 0 && { tools }), diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 0cabc7385..a55d5dde6 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -1800,6 +1800,54 @@ describe('OpenRouter modelOptions pass-through', () => { expect(params.maxCompletionTokens).toBe(64) }) + it('normalizes reasoning enabled false to effort none on the wire (#1006)', async () => { + setupMockSdkClient(minimalStreamChunks) + const adapter = createAdapter() + + const modelOptions: OpenRouterTextModelOptions = { + reasoning: { enabled: false }, + } + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'test' }], + modelOptions, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + expect(params.reasoning).toEqual({ effort: 'none' }) + + const serialized = ChatRequest$outboundSchema.parse(params) + expect(serialized.reasoning).toEqual({ effort: 'none' }) + }) + + it('omits an empty reasoning object from the SDK request (#1006)', async () => { + setupMockSdkClient(minimalStreamChunks) + const adapter = createAdapter() + + const modelOptions: OpenRouterTextModelOptions = { + reasoning: {}, + } + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'test' }], + modelOptions, + })) { + // consume + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + expect(params).not.toHaveProperty('reasoning') + + const serialized = ChatRequest$outboundSchema.parse(params) + expect(serialized).not.toHaveProperty('reasoning') + }) + it('uses variant only for the model suffix and never sends it in the request body', async () => { setupMockSdkClient(minimalStreamChunks) const adapter = createAdapter()