diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index bd80b5b025..651729541d 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -657,6 +657,8 @@ Setting `reasoning: false` (the literal boolean) explicitly disables thinking on On a `api.deepseek.com` baseURL, the OpenAI pipeline emits the explicit `thinking: { type: 'disabled' }` field that DeepSeek V4+ requires — the server-side default is `'enabled'`, so simply omitting `reasoning_effort` would still pay thinking latency/cost. Self-hosted DeepSeek backends (sglang/vllm) and other OpenAI-compatible servers do **not** receive this field; if you need to disable thinking on those, inject `thinking: { type: 'disabled' }` (or whatever knob your inference framework exposes) via `samplingParams`/`extra_body`. +On an `openrouter.ai` baseURL, the OpenAI pipeline emits OpenRouter's provider-level `reasoning: { enabled: false }` field when reasoning is disabled. Other OpenAI-compatible servers do not receive this OpenRouter-specific field; use `samplingParams`/`extra_body` for their native disable knob. + ### Interaction with `samplingParams` (OpenAI-compatible only) > [!warning] diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index 732be0c8ad..eaec7a7bdb 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -1838,6 +1838,319 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.thinking).toBeUndefined(); }); + it('emits reasoning.enabled=false on OpenRouter hostname when includeThoughts is false', async () => { + // Regression for #9757: OpenRouter's native thinking switch is the + // provider-level `reasoning` parameter. The disable path emits only + // shapes OpenRouter ignores (chat_template_kwargs for qwen-family + // models) and strips any `reasoning` object, so thinking-capable + // models routed through OpenRouter keep thinking enabled. The + // AUTO-mode classifier's stage-1 side query (256-token budget, + // forced respond_in_schema tool call, includeThoughts: false) then + // spends its whole budget on reasoning, never emits the tool call, + // and fail-closes with "Classifier stage 1 unavailable". Verify the + // OpenRouter-native disable shape is emitted. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://openrouter.ai/api/v1', + model: 'qwen/qwen3.8-27b', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'qwen/qwen3.8-27b', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'side-query:permission-classifier'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toEqual({ enabled: false }); + }); + + it('does NOT emit reasoning.enabled=false on OpenRouter when thinking is enabled', async () => { + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://openrouter.ai/api/v1', + model: 'qwen/qwen3.8-27b', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'qwen/qwen3.8-27b', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Hello' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'main'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toBeUndefined(); + }); + + it('emits reasoning.enabled=false on OpenRouter hostname when reasoning is configured to false', async () => { + // Config-level opt-out (`reasoning: false`) must also land OpenRouter's + // native disable shape, matching the DeepSeek hostname branch. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://openrouter.ai/api/v1', + model: 'qwen/qwen3.8-27b', + reasoning: false, + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'qwen/qwen3.8-27b', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Hello' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'main'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toEqual({ enabled: false }); + }); + + it('emits reasoning.enabled=false on OpenRouter for non-qwen models too', async () => { + // `reasoning` is an OpenRouter provider-level parameter the gateway + // routes to any model that supports it — not a qwen-family wire field + // like `enable_thinking`. Gating on the model family would leave every + // other thinking model on OpenRouter broken the same way. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://openrouter.ai/api/v1', + model: 'deepseek/deepseek-r1', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'deepseek/deepseek-r1', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'side-query:permission-classifier'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toEqual({ enabled: false }); + }); + + it('does NOT emit reasoning.enabled=false for thinking-mandatory models on OpenRouter', async () => { + // thinkingMandatory marks models that reject a thinking-disable shape + // with a 400; the exemption must hold on OpenRouter too. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://openrouter.ai/api/v1', + model: 'qwen/qwen3.8-27b', + thinkingMandatory: true, + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'qwen/qwen3.8-27b', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toBeUndefined(); + }); + + it('does NOT emit reasoning on a non-OpenRouter OpenAI-compatible endpoint', async () => { + // The disable shape is OpenRouter-specific wire shape; other + // OpenAI-compatible gateways (vLLM/SGLang/strict-compat) must not + // receive the extra `reasoning` field. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://my-vllm.example.com:8000/v1', + model: 'qwen/qwen3-32b', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'qwen/qwen3-32b', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toBeUndefined(); + }); + + it('does NOT treat lookalike hostnames as OpenRouter', async () => { + // Hostname match must be exact (openrouter.ai or *.openrouter.ai); a + // substring check would false-positive on hostile hosts. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://openrouter.ai.evil.com/v1', + model: 'qwen/qwen3.8-27b', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'qwen/qwen3.8-27b', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toBeUndefined(); + }); + + it('does NOT emit reasoning on the official OpenAI endpoint when includeThoughts is false', async () => { + // The new OpenRouter branch must not leak onto api.openai.com, which + // has its own reasoning shapes and rejects unknown fields. + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5', + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'gpt-5', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute(request, 'forked_query'); + + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toBeUndefined(); + }); + it('emits enable_thinking:false on DashScope hostname when includeThoughts is false', async () => { // Regression for #4501: qwen3 hybrid models (e.g. qwen3.5-flash) // default to thinking-on. Provider buildRequest never auto-injects diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index c996641016..f3f8d89146 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -20,6 +20,7 @@ import { isOfficialOpenAIEndpoint, } from './prefix-caching.js'; import { isDeepSeekHostname } from './provider/deepseek.js'; +import { isOpenRouterHostname } from './provider/openrouter.js'; import { openaiRequestCaptureContext } from './requestCaptureContext.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; import { TaggedThinkingParser } from './taggedThinkingParser.js'; @@ -1215,6 +1216,30 @@ export class ContentGenerationPipeline { if (isDeepSeekHostname(this.contentGeneratorConfig)) { typed['thinking'] = { type: 'disabled' }; } + // OpenRouter's thinking switch is the provider-level `reasoning` + // parameter (`reasoning: { enabled: false }`, see + // https://openrouter.ai/docs/features/reasoning-tokens). The shapes + // emitted above are ignored by the gateway, and the strip just above + // removes any `reasoning` object a provider hook injected — so + // thinking-capable models routed through OpenRouter keep thinking on. + // That breaks the AUTO-mode classifier's stage-1 side query (#9757): + // the 256-token budget is spent on reasoning, the forced + // respond_in_schema tool call never ships, and the classifier + // fail-closes. Must be emitted after the strip, which runs later + // than the provider buildRequest hook. + // + // Provider-level, not model-family-gated: unlike `enable_thinking` + // (a qwen-family wire field that leaks upstream on non-qwen + // routings), `reasoning` is an OpenRouter API parameter the gateway + // applies to whatever model supports it. `thinkingMandatory` models + // stay exempt: a disable shape they reject would be a guaranteed + // request failure. + if ( + !thinkingMandatory && + isOpenRouterHostname(this.contentGeneratorConfig) + ) { + typed['reasoning'] = { enabled: false }; + } } if (thinkingMandatory) { diff --git a/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts b/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts new file mode 100644 index 0000000000..5628a93e37 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import { isOpenRouterHostname } from './openrouter.js'; + +describe('isOpenRouterHostname', () => { + it.each([ + ['https://openrouter.ai/api/v1', true], + ['https://eu.openrouter.ai/api/v1', true], + ['https://openrouter.ai.evil.com/v1', false], + ['https://evilopenrouter.ai/v1', false], + ['not a url', false], + ['', false], + ])('classifies %s as %s', (baseUrl, expected) => { + expect(isOpenRouterHostname({ baseUrl } as ContentGeneratorConfig)).toBe( + expected, + ); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts b/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts new file mode 100644 index 0000000000..3b18e7c525 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; + +/** + * Hostname-only check used to decide whether a thinking-disable request + * should carry OpenRouter's provider-level `reasoning` parameter. Mirrors + * `isDeepSeekHostname`: hostname matching only — no model-name fallback. + * OpenRouter's `reasoning` parameter is a gateway-level extension of the + * chat-completions API; pushing it at strict OpenAI-compatible backends + * (self-hosted vLLM/SGLang, official OpenAI) could trip an unknown-key + * rejection, and those gateways have their own thinking switches anyway. + * + * Parses the baseUrl with `new URL(...)` and matches the hostname against + * `openrouter.ai` (and its subdomains) exactly — a naive substring check + * would false-positive on hostile hosts like + * `https://openrouter.ai.evil.com/v1`. Invalid URLs are treated as + * non-OpenRouter. The hostname shape mirrors `openRouterProvider.ownsModel` + * in `providers/presets/openrouter.ts`. + * + * Exposed as a free function so consumers (the pipeline post-processing + * hook, in particular) can run the check without a provider class — + * OpenRouter requests flow through `DefaultOpenAICompatibleProvider`. + */ +export function isOpenRouterHostname( + contentGeneratorConfig: ContentGeneratorConfig, +): boolean { + const baseUrl = contentGeneratorConfig.baseUrl ?? ''; + if (!baseUrl) return false; + try { + const hostname = new URL(baseUrl).hostname.toLowerCase(); + return hostname === 'openrouter.ai' || hostname.endsWith('.openrouter.ai'); + } catch { + return false; + } +}