fix(core): emit OpenRouter's reasoning disable when thinking is off (#9758)

* 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
This commit is contained in:
易良 2026-08-25 07:43:08 +00:00 committed by GitHub
parent 923eb12a78
commit bd42e67137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 404 additions and 0 deletions

View file

@ -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]

View file

@ -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

View file

@ -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) {

View file

@ -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,
);
});
});

View file

@ -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;
}
}