From bd42e67137ae85e4a77402792b5eaaf79dc01888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Tue, 25 Aug 2026 07:43:08 +0000 Subject: [PATCH] fix(core): emit OpenRouter's reasoning disable when thinking is off (#9758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): emit OpenRouter's reasoning disable when thinking is off The AUTO-mode permission classifier's stage-1 side query forces a respond_in_schema tool call (tool_choice: 'required') with a 256-token budget and includeThoughts: false. On OpenRouter endpoints the thinking-disable rendered only into shapes the gateway ignores (chat_template_kwargs.enable_thinking for qwen-family models), and the pipeline's unconditional strip then removed the `reasoning` object — OpenRouter's native thinking knob. Thinking stayed on, the model spent the whole budget on reasoning, never emitted the tool call, and the classifier fail-closed with "Classifier stage 1 unavailable" (#9757). Mirror the isDeepSeekHostname precedent: hostname-gated detection (openrouter.ai / *.openrouter.ai) and emit `reasoning: { enabled: false }` in the reasoningDisabled branch after the strip — the provider buildRequest hook runs before the strip, so emitting earlier would be removed again. Applied endpoint-wide rather than qwen-family-gated: `reasoning` is an OpenRouter provider-level parameter, unlike `enable_thinking`, which is a qwen wire field that leaks upstream on non-qwen routings. thinkingMandatory models stay exempt; DashScope (both shapes), vLLM/SGLang, DeepSeek hostname, and the official OpenAI endpoint are untouched. Repro + regression coverage added in pipeline.test.ts (red before the fix, green after). * test(core): cover OpenRouter reasoning guard --- docs/users/configuration/model-providers.md | 2 + .../openaiContentGenerator/pipeline.test.ts | 313 ++++++++++++++++++ .../core/openaiContentGenerator/pipeline.ts | 25 ++ .../provider/openrouter.test.ts | 24 ++ .../provider/openrouter.ts | 40 +++ 5 files changed, 404 insertions(+) create mode 100644 packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts create mode 100644 packages/core/src/core/openaiContentGenerator/provider/openrouter.ts 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; + } +}