Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/users/configuration/model-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,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]
Expand Down
313 changes: 313 additions & 0 deletions packages/core/src/core/openaiContentGenerator/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,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
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/core/openaiContentGenerator/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Comment thread
yiliang114 marked this conversation as resolved.
) {
typed['reasoning'] = { enabled: false };
Comment thread
yiliang114 marked this conversation as resolved.
}
}

if (thinkingMandatory) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading
Loading