feat(core): support external reasoning profiles and defaults (#11856)

* feat(core): support external reasoning profiles and defaults

* fix(core): contain invalid reasoning overrides and inherit provider capabilities

* fix(core): preserve reasoning state and request overrides

* fix(core): resolve reasoning against the owning model and scope

* fix(core): surface rejected reasoning settings during reload
This commit is contained in:
callmeYe authored and GitHub committed 2026-09-15 11:44:17 +00:00
1 parent 12b8cbc4de
commit 26fba7cd45
36 files changed
+3190 -290

No files matched your search

@@ -0,0 +1,213 @@
# External reasoning overrides, bounded redesign
[English](external-reasoning-overrides-v2.md) | [简体中文](external-reasoning-overrides-v2.zh-CN.md)
Status: implemented; local validation passed. Supersedes the implementation
approach in PR #11521. The new PR must stay within 3,500 changed lines, counting
additions and deletions across production code, tests, generated files and docs.
## 1. Goal and boundary
Let consumers override a model's reasoning format, supported effort tiers and
default through settings. The default must affect real requests and the UI.
Support new names and gateway aliases that reuse an existing provider protocol.
Apply reasoning changes before the next user prompt; preserve the active prompt,
including its tool continuations, retries and derived agents.
Reuse `modelProviders[].capabilities.reasoning`. Do not introduce a parallel
`generationConfig.reasoningConfig`, per-effort budget mapping, request template
language or new authentication protocol. Do not redesign the model registry,
image-model lifecycle, credential refresh, scheduling, runtime snapshot restore
or generic provider error handling. Endpoint and credential changes are outside
this feature's next-prompt guarantee; keep their existing lifecycle.
Main already exposes reasoning capabilities to model discovery and controls,
and some adapters read them. The gaps are consistent default application,
explicit format selection for aliases, and a stable reasoning view during a
prompt. Fill those gaps instead of adding a second configuration system.
## 2. Public configuration
Known model, changing only its default:
```json
{
"id": "qwen3.8-max",
"capabilities": {
"reasoning": { "defaultEffort": "medium" }
}
}
```
New model on an existing OpenAI-compatible provider:
```json
{
"id": "company-model-v2",
"baseUrl": "https://gateway.example.com/v1",
"envKey": "COMPANY_MODEL_API_KEY",
"capabilities": {
"reasoning": {
"profile": "openai-effort",
"efforts": ["low", "medium", "high"],
"defaultEffort": "medium"
}
}
}
```
Use existing `efforts` and `defaultEffort`; add only optional `profile`.
Accept partial declarations and resolve them before the existing strict
capability parser. Separate the partial input type from the complete resolved
`ModelReasoningCapabilities`; downstream consumers never receive partial values.
Preserve the existing complete declarations, including
`thinking`, `toggleOnly`, `canDisable` and `disableField`, without migration.
Do not add new on/off policy fields in this PR.
| Field | Responsibility |
| --------------- | ---------------------------------------------------------------------------------------- |
| `profile` | Select an existing reasoning wire strategy; never select an SDK, endpoint or credential. |
| `efforts` | Replace the supported subset of `low/medium/high/xhigh/max`. |
| `defaultEffort` | Supply the effort when no explicit selection or existing raw override takes precedence. |
Omitted fields inherit capabilities from the existing provider catalog at the
selected endpoint; exact model IDs take precedence over normalized aliases.
Unknown gateway routes require an explicit declaration. Replacing `efforts` replaces
the array rather than merging it. An inherited default is clamped using the
existing helper; an explicitly supplied default must belong to the effective
set. Unknown tiered models must declare `profile`, `efforts` and `defaultEffort`;
do not invent a default or add a new model-name table. Toggle-only profiles need
no effort fields and reject them. Keep the supported canonical tier vocabulary.
Profiles are a closed set of existing formats:
| Protocol | Profiles and existing wire shapes |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `openai-effort`: flat effort; `openai-reasoning`: nested reasoning; `deepseek-openai`: DeepSeek thinking and reasoning-history format. |
| Qwen over OpenAI Chat | `dashscope-effort`: tiered effort; `dashscope-thinking`: top-level toggle; `qwen-chat-template`: nested template toggle. |
| OpenAI Responses | `openai-reasoning`: existing Responses reasoning format. |
| Anthropic | `anthropic-manual`: enabled thinking and fixed/derived budget; `anthropic-adaptive`: adaptive thinking and output effort; `deepseek-anthropic`: existing compatible format. |
| Gemini/Vertex | `gemini`: existing thinking-level mapping, limited to low/medium/high. |
An explicit adaptive profile always selects adaptive thinking. A consumer that
needs manual budgets selects the manual profile. Do not add an adaptive-only
synonym. Omitted profile preserves current inference. Explicit profiles replace
reasoning-format inference, not endpoint-specific non-reasoning restrictions.
With an explicit profile, derive wire controls from it; `disableField` remains a
legacy format hint only when profile is omitted. Do not carry two competing
format selectors into adapter execution.
## 3. Resolution and request flow
One pure resolver produces the effective capability, default and wire strategy
from the exact route, its declaration and existing built-ins. UI and adapters
consume that result. Resolve against the actual selected route, not a registry
placeholder endpoint. An explicit profile eliminates hostname inference for
reasoning format. Never obtain another endpoint's declaration by model ID alone.
For an entry without `baseUrl`, preserve its existing registry selection identity
and resolve inference against the selected runtime endpoint. An explicit endpoint
override that selects a different route must resolve its own declaration.
Keep model defaults separate from user choices. Determine the effective effort
as existing explicit session/request selection, then the resolved model default.
Use the existing supported-tier reconciliation once; do not add a second
name-based clamp after an explicit capability has resolved the tier.
`generationConfig.reasoning`, `samplingParams` and `extra_body` keep their current
provider precedence and escape-hatch behavior. Profile shaping substitutes for
the existing inferred reasoning stage; it is not a final whole-body rewrite.
Apply defaults below existing raw overrides and retain request opt-out and
existing mandatory-thinking handling. Reuse each adapter's current projection
for recognized raw overrides. Where raw input prevents determining an effective
tier, use the existing unspecified/blocked control state instead of falsely
claiming the configured default was sent. Do not reconcile arbitrary conflicting
raw fields, expand their accepted shapes, or add a new override UI.
Small provider-specific changes select existing serialization and required
thinking-history preparation together. Keep tool choice, sampling, authentication,
cache behavior and non-reasoning history rules in their existing owners.
Unsupported future protocols require adapter work, not another configurable DSL.
## 4. Next-user-prompt lifecycle
Maintain one immutable reasoning-only table per admitted prompt. Each entry uses
the existing exact route identity and contains reasoning metadata only. Capture
all configured reasoning routes so a side query or subagent created after a disk
reload still sees the parent prompt's version. Reuse route identity helpers.
Settings observation prepares the latest valid reasoning table. At the existing
user-prompt admission boundary, replace the active table once. A changed table
does not replace the current generator, call `refreshAuth`, switch models, update tools or
replace a registry. Cached side-model views are invalidated on adoption; existing
views retain their captured table. Normal authentication refresh carries the current active
reasoning table forward; it cannot promote pending reasoning changes.
Adapters read the captured table, not live registry reasoning. A derived agent
inherits the captured table but resolves its own route. An unknown route uses its
own existing built-ins; it never inherits the parent's capability. Existing
model-switch flows retain their route and preference rules and resolve reasoning
from the active table. The next user prompt promotes the latest table.
Automatic retries, tool continuations and Goal/background continuations do not
promote pending reasoning. Newly created sessions use the latest configuration.
Cold model previews may read the latest validated table; an existing session's
controls display its active table and update when that session adopts the change.
Runtime resolution of invalid declarations returns no override, preserving
existing behavior even in a fresh session. Strict staging validation rejects
invalid reasoning updates and keeps the previous valid table. The CLI prints
a warning; ACP reload sends a discrete message naming the model/field.
They must not reject unrelated
settings updates or erase healthy model rows. Validate static fields independently
from endpoint inference; no global boot failure based on placeholder URLs.
## 5. Implementation and size budget
Start from a fresh main-based branch; do not cherry-pick the old implementation.
Implement in four bounded groups: capability resolution; existing adapter hooks;
prompt reasoning snapshot propagation; controls and configuration documentation.
Use direct core-module imports in CLI production code. No unrelated cleanup.
| Group | Changed-line budget |
| ----------------------------------------------------- | ------------------: |
| Production implementation | 1,410 |
| Focused unit tests | 1,390 |
| Local E2E harness and scenarios | 270 |
| Both design languages, user docs and generated schema | 430 |
| Review reserve | 0 |
| Total hard limit | 3,500 |
Count additions plus deletions in the final PR diff against its main merge base,
including renames, generated schemas and every test/document shipped. Track
uncommitted/new files too. Do not hide implementation in ignored files or omit
necessary tests to meet the limit. Exceeding a group budget requires simplifying
within this scope; exceeding 3,500 blocks submission rather than silently growing
the limit. Merge-only upstream changes do not count, but conflict-resolution
changes that remain in the PR do.
## 6. Validation and acceptance
1. Known Qwen 3.8 Max with only default medium: controls select medium and the
actual request sends medium; no "Use model default" menu item.
2. Unknown aliases for each listed format: declared tiers/default are used and
final request shape matches the selected existing strategy. Cover toggle-only,
manual/adaptive thinking, supported-tier reconciliation and explicit off.
3. No declaration: request bodies and existing controls remain unchanged. Existing
complete capability declarations still work; raw override behavior is preserved.
4. Exact same-name endpoints and model switching: no capability leakage, model
default is not persisted as user preference, explicit selections follow the
existing reconciliation policy. Include Qwen, DeepSeek and Kimi switching.
5. Reload twice during a tool-using prompt: requests, retries and newly created
child agents retain the original table; next user prompt uses the latest valid
one. Include a later image/endpoint update to prove this mechanism does not
introduce image/credential staging or transactions.
6. Invalid profiles, tiers, defaults and endpoint-dependent inference: preserve
the previous reasoning configuration and healthy model discovery with a useful
error; never send a fabricated fallback profile.
Record the installed CLI baseline, then test a local built bundle against
controlled endpoints using dummy credentials. Run package-local focused tests,
full build/typecheck, lint and actual local E2E; perform two clean diff audits.
Publish the E2E report and line-count breakdown with the new PR. Passing tests do
not authorize scope expansion. Local wire and browser validation uses controlled
endpoints; it does not establish acceptance by real provider services.
@@ -0,0 +1,174 @@
# 外部思考配置覆盖:限定范围的重新设计
[English](external-reasoning-overrides-v2.md) | [简体中文](external-reasoning-overrides-v2.zh-CN.md)
状态:已实现,本地验证通过。替代 PR #11521 的实现方案。新 PR 的总变更
不得超过 3500 行,按生产代码、测试、生成文件和文档的新增行与删除行之和计算。
## 1. 目标与边界
允许使用方通过 settings 覆盖模型的思考格式、支持档位及默认档位。默认值必须
同时作用于实际请求和界面。支持复用现有 provider 协议的新模型名与网关别名。
思考配置在下一次用户发送前生效;当前轮及其工具续跑、重试、派生代理保持原配置。
复用 `modelProviders[].capabilities.reasoning`,不再引入平行的
`generationConfig.reasoningConfig`、分档预算映射、请求模板语言或新鉴权协议。
不重做模型注册表、图片模型生命周期、凭证刷新、调度、运行时快照恢复或通用
provider 错误处理。端点和凭证变更不属于本功能“下一次发送生效”的保证,
继续沿用它们已有的生命周期。
主分支已经把思考能力提供给模型发现与控件,部分适配器也读取这些能力。
真正需要补齐的是默认值一致生效、别名显式选择格式,以及当前轮稳定的思考视图。
填补这些缺口,不建立第二套配置系统。
## 2. 公开配置
已知模型只修改默认档位:
```json
{
"id": "qwen3.8-max",
"capabilities": {
"reasoning": { "defaultEffort": "medium" }
}
}
```
在已有 OpenAI 兼容 provider 上声明新模型:
```json
{
"id": "company-model-v2",
"baseUrl": "https://gateway.example.com/v1",
"envKey": "COMPANY_MODEL_API_KEY",
"capabilities": {
"reasoning": {
"profile": "openai-effort",
"efforts": ["low", "medium", "high"],
"defaultEffort": "medium"
}
}
}
```
继续使用已有的 `efforts` 和 `defaultEffort`,只新增可选 `profile`。
允许部分声明,先补齐再交给已有严格能力解析器。部分输入类型与完整解析结果
`ModelReasoningCapabilities` 分开,下游消费方不接收半成品。已有完整声明及其中的
`thinking`、`toggleOnly`、`canDisable`、`disableField` 无须迁移。
本 PR 不新增思考开关策略字段。
| 字段 | 职责 |
| --------------- | -------------------------------------------------------- |
| `profile` | 选择已有思考请求策略,不选择 SDK、端点或凭证。 |
| `efforts` | 替换 `low/medium/high/xhigh/max` 中受支持的档位子集。 |
| `defaultEffort` | 在没有显式选择或优先级更高的已有原始覆盖时提供默认档位。 |
省略字段根据实际端点从已有 provider 列表继承能力,精确模型 ID 优先于归一后的别名。
未知网关路由需要显式声明。`efforts` 整体替换,不合并数组。继承的默认值用已有
函数归一到有效集合;显式指定的默认值必须属于有效集合。未知分档模型必须声明
`profile`、`efforts` 和 `defaultEffort`,不替使用方猜默认值,也不增加新模型名表。
仅开关 profile 不需要档位字段,并拒绝这些字段。保留现有规范档位词表。
Profile 是已有格式的封闭集合:
| 协议 | Profile 与已有请求格式 |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `openai-effort`:扁平 effort;`openai-reasoning`:嵌套 reasoning;`deepseek-openai`:DeepSeek 思考及推理历史格式。 |
| OpenAI Chat 上的 Qwen | `dashscope-effort`:分档 effort;`dashscope-thinking`:顶层开关;`qwen-chat-template`:嵌套模板开关。 |
| OpenAI Responses | `openai-reasoning`:已有 Responses reasoning 格式。 |
| Anthropic | `anthropic-manual`:enabled thinking 与固定或派生预算;`anthropic-adaptive`:adaptive thinking 与输出 effort;`deepseek-anthropic`:已有兼容格式。 |
| Gemini/Vertex | `gemini`:已有 thinking-level 映射,只支持 low/medium/high。 |
显式 adaptive profile 始终选择 adaptive thinking。需要手动预算的使用方选择
manual profile,不增加 adaptive-only 同义配置。省略 profile 保留已有推断。
显式 profile 替代思考格式推断,不覆盖端点专属的非思考限制。
显式 profile 决定请求控制字段;只有省略 profile 时,才把 `disableField` 作为
旧格式提示,不能让两种格式选择器同时进入适配器竞争。
## 3. 解析与请求链路
一个纯函数根据精确路由、外部声明和已有内置规则,得到有效能力、默认值与请求
策略,供界面和适配器共同使用。依照实际选中路由解析,不能使用注册表占位端点
做最终判断。显式 profile 不再依赖主机名推断思考格式。不能仅凭模型 ID 取用
另一个端点的声明。
未声明 `baseUrl` 的条目保留已有注册表选择身份,以选中的运行时端点完成格式推断。
显式覆盖端点并选择不同路由时,必须解析目标路由自己的声明。
区分模型默认值与用户选择。先使用已有会话或请求显式选择,再使用解析后的模型
默认值。只执行一次已有档位归一;声明已确定有效档位后,不能再按模型名二次截断。
`generationConfig.reasoning`、`samplingParams`、`extra_body` 保持各 provider
已有优先级与直接配置行为。Profile 替代已有的思考推断环节,不做最终整份请求体
重写。默认值低于已有原始覆盖;保留请求级关闭和已有强制思考处理。复用适配器对
可识别原始覆盖的已有状态映射。无法由原始输入确定实际档位时,使用已有的未指定
或不可调整控件状态,不能声称实际发送了模型默认档位。不调和任意互相矛盾的原始
字段,不扩展其接受形态,不新增覆盖状态界面。
各 provider 只增加小范围策略选择,同时选择已有序列化及必要的思考历史处理。
工具选择、采样、鉴权、缓存和非思考历史规则保留在原来的归属位置。未来不受支持
的协议通过适配器开发支持,不增加另一套可配置语言。
## 4. 下一次用户发送生效
每次用户发送进入执行时,持有一份不可变的思考配置表。表项使用现有精确路由身份,
只包含思考元数据。捕获全部已配置思考路由,确保磁盘重载后才创建的旁路请求或
子代理仍能使用父轮次版本。复用路由身份辅助函数。
配置观察链路准备最近一次有效思考配置表。在已有用户发送入口一次性替换当前表。
更新配置表不替换当前 generator、不调用 `refreshAuth`、不切模型、不刷新工具,
也不替换注册表。采用更新时清除旁路模型缓存,已经持有的视图仍保留原表。正常鉴权刷新继续携带当前思考配置表,不能提前采用待更新内容。
适配器读取捕获的表,不读取实时注册表中的思考配置。派生代理继承表,但解析自身
路由。未知路由使用自身已有内置规则,不继承父模型能力。已有切换模型流程保留
路由与偏好规则,从当前表解析思考配置;下一次用户发送再采用最近一次配置表。
自动重试、工具续跑及 Goal/后台续跑不采用待更新配置。新建会话使用最新配置。
冷启动模型预览可以读取最新有效表;已有会话控件展示当前表,在该会话采用更新
时同步变化。
运行时遇到无效声明时返回无覆盖,新建会话也能继续沿用原有行为。严格的暂存校验
拒绝无效更新,保留上一份有效思考表;CLI显示警告,ACP重载发送离散消息,
说明模型及字段。不能阻断
无关设置更新或清空健康模型行。静态字段校验与端点推断分离,不允许占位 URL
造成全局启动失败。
## 5. 实现与行数预算
从 main 新建干净分支,不 cherry-pick 旧实现。按四个有界部分实现:能力解析、
已有适配器接入、轮次思考快照传递、控件和配置文档。CLI 生产代码直接从 core
定义模块导入,不夹带无关清理。
| 部分 | 变更行数预算 |
| ------------------------------- | -----------: |
| 生产实现 | 1410 |
| 定向单测 | 1390 |
| 本地 E2E 脚本与场景 | 270 |
| 双语设计、用户文档、生成 schema | 430 |
| 评审预留 | 0 |
| 总硬上限 | 3500 |
按最终 PR 相对 main 合并基点的新增行与删除行之和计数,计入重命名、生成 schema
和全部提交的测试与文档,同时跟踪尚未提交及新文件。不能把实现藏进忽略文件,
也不能为满足限额删除必要测试。某组超预算时须在原范围内简化;超过 3500 行
阻止提交 PR,不能静默提高上限。单纯合并上游的内容不计入,最终留在 PR 差异
中的冲突处理计入。
## 6. 验证与验收
1. 已知 Qwen 3.8 Max 只配置默认 medium:控件选中中,真实请求发送 medium,
不出现“使用模型默认值”菜单项。
2. 每种列出的格式使用未知别名:声明档位和默认值生效,最终请求符合已有策略。
覆盖仅开关、manual/adaptive thinking、档位归一和显式关闭。
3. 无声明时请求内容和已有控件不变;已有完整能力声明仍可用,原始参数覆盖行为
保持兼容。
4. 同名不同端点及模型切换:能力不泄漏,默认值不保存成用户偏好,显式选择沿用
已有归一规则。包含 Qwen、DeepSeek、Kimi 切换。
5. 当前轮调用工具期间重载两次:请求、重试和新建子代理保留原表,下一次用户发送
使用最近一次有效表。加入后续图片或端点更新,证明没有新增图片/凭证暂存事务。
6. 无效 profile、档位、默认值及依赖端点的推断:保留原思考配置及健康模型发现,
给出有效错误信息,不发送编造的兜底格式。
先记录已安装 CLI 基线,再用虚拟凭证和受控端点测试本地 bundle。执行包内定向
测试、完整 build/typecheck、lint 和真实本地 E2E,完成两次无新问题的差异自审。
新 PR 附 E2E 报告和行数分类统计。测试通过不能授权范围扩张。本地请求与浏览器
验收使用受控端点,不代表已验证真实提供方接受所有配置。
@@ -69,6 +69,49 @@ The selected route must declare an explicit HTTPS `baseUrl` and a non-empty
if chat and image generation require different endpoints or credentials,
configure two routes instead.
## Override reasoning capabilities
Set `capabilities.reasoning` on a model entry to override its reasoning format,
offered effort tiers and default. Known models inherit omitted fields from the
provider catalog at the selected endpoint; for example,
`"capabilities": { "reasoning": { "defaultEffort": "medium" } }` makes a
DashScope `qwen3.8-max` route use medium when no explicit effort is selected.
For an unknown alias, declare all three fields:
```json
{
"id": "company-model-v2",
"baseUrl": "https://gateway.example.com/v1",
"envKey": "COMPANY_MODEL_API_KEY",
"capabilities": {
"reasoning": {
"profile": "openai-effort",
"efforts": ["low", "medium", "high"],
"defaultEffort": "medium"
}
}
}
```
`efforts` replaces the supported subset of `low/medium/high/xhigh/max`.
An explicit default must belong to that subset. Profiles reuse existing formats:
Chat accepts `openai-effort`, `openai-reasoning`, `deepseek-openai`,
`dashscope-effort`, `dashscope-thinking` and `qwen-chat-template`;
Responses accepts `openai-reasoning`; Anthropic accepts `anthropic-manual`,
`anthropic-adaptive` and `deepseek-anthropic`; Gemini/Vertex uses `gemini`.
The two toggle-only profiles omit `efforts` and `defaultEffort`. Gemini uses
low/medium/high. An adaptive profile selects adaptive thinking even when a
legacy manual budget is configured. Existing complete capability declarations
continue to work.
Reasoning changes apply before the next user prompt. Its requests, retries and
child agents share the captured reasoning configuration. Invalid updates retain
the previous configuration and log the model/field error. Invalid declarations
in fresh sessions safely fall back to the existing model behavior. Explicit user choices
and provider-native overrides in `reasoning`, `samplingParams` and `extra_body`
retain their existing precedence; a model default is not saved as a user choice.
This mechanism does not change endpoint, credential or image-model lifecycles.
## Configuration Examples by Auth Type
Below are comprehensive configuration examples for different authentication types, showing the available parameters and their combinations.
+270
View File
@@ -0,0 +1,270 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* global setTimeout, clearTimeout, console, process */
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Readable, Writable } from 'node:stream';
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk';
import { setTimeout as delay } from 'node:timers/promises';
import { startFakeOpenAIServer, fakeToolCall } from './fake-openai-server.ts';
const bundle = resolve('dist/cli.js');
const deadline = setTimeout(() => {
for (const child of children) child.kill('SIGTERM');
throw new Error('E2E timed out');
}, 180000);
const root = await mkdtemp(join(tmpdir(), 'qwen-reasoning-v2-'));
const children = new Set();
const requests = [];
let held, release;
const chat = await startFakeOpenAIServer(async ({ body }) => {
requests.push(body);
if (JSON.stringify(body.messages).includes('HOLD_REASONING') && !held) {
held = true;
await new Promise((done) => (release = done));
return {
toolCalls: [
fakeToolCall('agent', {
description: 'Reasoning snapshot probe',
prompt: 'CHILD_REASONING',
run_in_background: false,
}),
],
};
}
return { content: 'REASONING_OK' };
});
const native = createServer(async (req, res) => {
let input = '';
for await (const chunk of req) input += chunk;
const body = JSON.parse(input || '{}');
if (req.url.includes('countTokens')) return res.end('{"totalTokens":10}');
requests.push(body);
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
const events = req.url.includes('GenerateContent')
? [
'{"candidates":[{"content":{"role":"model","parts":[{"text":"REASONING_OK"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":1,"totalTokenCount":11}}',
]
: req.url.includes('responses')
? [
'{"type":"response.output_text.delta","delta":"REASONING_OK"}',
'{"type":"response.completed","response":{"id":"r1","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":1}}}',
]
: [
'{"type":"message_start","message":{"id":"m1","type":"message","role":"assistant","model":"alias","content":[],"usage":{"input_tokens":10,"output_tokens":0}}}',
'{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
'{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"REASONING_OK"}}',
'{"type":"content_block_stop","index":0}',
'{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}',
'{"type":"message_stop"}',
];
for (const data of events) {
const type = JSON.parse(data).type;
res.write(`${type ? `event: ${type}\n` : ''}data: ${data}\n\n`);
}
res.end();
});
await new Promise((done) => native.listen(0, '127.0.0.1', done));
const nativeUrl = `http://127.0.0.1:${native.address().port}`;
const tiered = (profile, defaultEffort = 'medium') => ({
profile,
efforts: ['low', 'medium', 'high'],
defaultEffort,
});
const model = (id, reasoning, baseUrl = chat.baseUrl) => ({
id,
baseUrl,
envKey: 'REASONING_TEST_KEY',
capabilities: { reasoning },
});
async function fixture(name, auth, models) {
const cwd = join(root, name),
home = join(cwd, 'config');
await mkdir(home, { recursive: true });
const settings = {
$version: 4,
security: { auth: { selectedType: auth } },
model: { name: models[0].id },
modelProviders: { [auth]: models },
telemetry: { enabled: false },
};
const file = join(home, 'settings.json');
await writeFile(file, JSON.stringify(settings));
const env = {
...process.env,
QWEN_HOME: home,
QWEN_RUNTIME_DIR: join(cwd, 'runtime'),
REASONING_TEST_KEY: 'dummy',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
http_proxy: '',
https_proxy: '',
NO_PROXY: '127.0.0.1,localhost',
};
return { cwd, file, settings, env };
}
function start(f, args) {
const child = spawn(process.execPath, [bundle, ...args], {
cwd: f.cwd,
env: f.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => (stderr += chunk));
children.add(child);
return { child, error: () => stderr };
}
async function headless(f) {
const { child, error } = start(f, [
...'--approval-mode yolo --output-format json'.split(' '),
...'--max-wall-time 30s'.split(' '),
'-p',
'Say REASONING_OK',
]);
let output = '';
child.stdout.on('data', (chunk) => (output += chunk));
child.stdin.end();
const code = await new Promise((done) => child.on('close', done));
assert.equal(code, 0, error());
assert.match(output, /REASONING_OK/);
}
function acp(f) {
const { child } = start(f, ['--acp', '--no-chat-recording']);
const client = new ClientSideConnection(
() => ({ sessionUpdate() {} }),
ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)),
);
return { child, client };
}
const cases = [
['openai', 'openai-effort', 'reasoning_effort', 'medium'],
['openai', 'openai-reasoning', 'reasoning.effort', 'medium'],
['openai', 'deepseek-openai', 'thinking.type', 'enabled'],
['openai', 'dashscope-effort', 'reasoning_effort', 'medium'],
['openai', 'dashscope-thinking', 'enable_thinking', true],
[
'openai',
'qwen-chat-template',
'chat_template_kwargs.enable_thinking',
true,
],
['anthropic', 'anthropic-manual', 'thinking.type', 'enabled'],
['anthropic', 'anthropic-adaptive', 'thinking.type', 'adaptive'],
['anthropic', 'deepseek-anthropic', 'output_config.effort', 'medium'],
[
'gemini',
'gemini',
'generationConfig.thinkingConfig.thinkingLevel',
'MEDIUM',
],
['openai-responses', 'openai-reasoning', 'reasoning.effort', 'medium'],
];
try {
for (const [auth, profile, field, expected] of cases) {
if (process.argv[2] && !profile.includes(process.argv[2])) continue;
const declaration =
profile === 'dashscope-thinking' || profile === 'qwen-chat-template'
? { profile }
: tiered(profile);
const f = await fixture(profile + auth, auth, [
model(
'company-alias',
declaration,
auth === 'openai' ? chat.baseUrl : nativeUrl,
),
]);
const before = requests.length;
await headless(f);
const body = requests.slice(before)[0];
assert.equal(
field.split('.').reduce((value, key) => value?.[key], body),
expected,
);
if (profile === 'anthropic-manual')
assert.ok(body.thinking.budget_tokens > 0);
console.log('PASS wire', auth, profile);
}
const f = await fixture('acp', 'openai', [
model('qwen3.8-max', tiered('dashscope-effort')),
model('deepseek-v4-flash', tiered('deepseek-openai', 'high')),
model('kimi-k2.5', { profile: 'qwen-chat-template' }),
]);
const { client } = acp(f);
await client.initialize({ protocolVersion: 1, clientCapabilities: {} });
const session = await client.newSession({ cwd: f.cwd, mcpServers: [] });
const sessionId = session.sessionId;
const option = (result) =>
result.configOptions.find((opt) => opt.id === 'reasoning_effort');
assert.equal(option(session).currentValue, 'medium');
assert.ok(!option(session).options.some((opt) => opt.value === 'default'));
for (const name of ['deepseek-v4-flash', 'kimi-k2.5', 'qwen3.8-max']) {
const value = session.models.availableModels.find((m) =>
m.modelId.includes(name),
).modelId;
const switched = await client.setSessionConfigOption({
sessionId,
configId: 'model',
value,
});
if (name === 'deepseek-v4-flash')
assert.equal(option(switched).currentValue, 'high');
if (name === 'qwen3.8-max')
assert.equal(option(switched).currentValue, 'medium');
}
await client.setSessionConfigOption({
sessionId,
configId: 'mode',
value: 'yolo',
});
const before = requests.length;
const prompt = (text) =>
client.prompt({ sessionId, prompt: [{ type: 'text', text }] });
const running = prompt('HOLD_REASONING');
while (!release) await delay(50);
for (const effort of ['low', 'high']) {
const current = JSON.parse(await readFile(f.file, 'utf8'));
current.modelProviders.openai[0].capabilities.reasoning.defaultEffort =
effort;
await writeFile(f.file, JSON.stringify(current));
await client.extMethod('qwen/control/workspace/reload', {});
await delay(1000);
}
release();
await running;
const current = requests.slice(before).filter((b) => b.stream);
assert.ok(current.length >= 3, 'child and parent continuation must occur');
assert.ok(
current.some((b) =>
b.messages.some(
(m) =>
m.role === 'user' &&
JSON.stringify(m.content).includes('CHILD_REASONING'),
),
),
);
assert.ok(current.every((b) => b.reasoning_effort === 'medium'));
const after = requests.length;
await prompt('NEXT_REASONING');
assert.equal(
requests.slice(after).find((b) => b.stream).reasoning_effort,
'high',
);
console.log(
'PASS ACP concrete controls, model switches, two reloads, frozen continuation, next prompt',
);
} finally {
clearTimeout(deadline);
await writeFile(join(root, 'requests.json'), JSON.stringify(requests));
for (const child of children) child.kill('SIGTERM');
await chat.close();
native.closeAllConnections();
native.close();
console.log('ARTIFACT', root);
}
@@ -10719,6 +10719,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('keeps healthy model previews when another live reasoning declaration is invalid, ignoring bootstrap snapshots', async () => {
const models = ['healthy', 'broken'].map((id) => ({
id,
label: id,
authType: AuthType.USE_OPENAI,
baseUrl: `https://${id}.example/v1`,
capabilities: {
reasoning:
id === 'healthy'
? {
profile: 'openai-effort',
efforts: ['low', 'medium'],
defaultEffort: 'medium',
}
: { profile: 'invalid-profile' },
},
}));
mockConfig = {
...mockConfig,
getTargetDir: vi.fn(() => '/work/status'),
getAuthType: vi.fn(() => AuthType.USE_OPENAI),
getActiveRuntimeModelSnapshot: vi.fn(() => undefined),
getModel: vi.fn(() => 'healthy'),
getAllConfiguredModels: vi.fn(() => models),
getContentGeneratorConfig: vi.fn(() => ({
model: 'healthy',
authType: AuthType.USE_OPENAI,
reasoningSnapshot: [],
})),
getResolvedModelConfig: vi.fn((_auth: string, id: string) => ({
baseUrl: `https://${id}.example/v1`,
generationConfig: {},
})),
} as unknown as Config;
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
try {
const status = await agent.extMethod(
SERVE_STATUS_EXT_METHODS.workspaceProviders,
{},
);
expect(status['errors']).toBeUndefined();
expect(status).toMatchObject({
providers: [
{
models: [
{
baseModelId: 'healthy',
configOptions: [{ currentValue: 'medium' }],
},
{ baseModelId: 'broken' },
],
},
],
});
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});
it('uses the target provider raw override for cold reasoning preview', async () => {
mockConfig = {
...mockConfig,
+57 -35
View File
@@ -285,10 +285,11 @@ import {
startChildHeapProbe,
type ChildHeapProbe,
} from './child-heap-probe.js';
import { resolveReasoningCapabilities } from '@qwen-code/qwen-code-core/core/reasoning-overrides.js';
import {
applyReasoningSelection,
buildModelReasoningConfigOption,
buildModelReasoningConfigPreview,
buildModelReasoningRoutePreview,
clearReasoningRequestOverrides,
getConfiguredModelReasoning,
getDefaultReasoningConfig,
@@ -296,7 +297,6 @@ import {
isReasoningSelectionSupported,
PERSIST_REASONING_SELECTION_META_KEY,
parseReasoningSelection,
resolvePersistedReasoningConfigState,
REASONING_SELECTION_PERSISTED_META_KEY,
REASONING_EFFORT_DEFAULT,
REASONING_EFFORT_NAMES,
@@ -7878,34 +7878,31 @@ class QwenAgent implements Agent {
const isCurrent =
currentAuth === model.authType && currentAcpModelId === modelId;
const resolved =
!model.isRuntimeModel && !modelId.startsWith(ACP_ROUTE_ID_PREFIX)
? config.getResolvedModelConfig?.(
model.authType,
model.id,
model.registryBaseUrl ?? model.baseUrl,
)
: undefined;
const configOptions =
model.isRuntimeModel || modelId.startsWith(ACP_ROUTE_ID_PREFIX)
? undefined
: buildModelReasoningConfigPreview(
model.id,
resolvePersistedReasoningConfigState(
model.id,
settings.merged.model?.reasoningEffort,
resolved?.generationConfig.thinkingMandatory === true,
model.capabilities?.reasoning,
),
model.capabilities?.reasoning,
resolved
? {
...resolved.generationConfig,
model: model.id,
baseUrl: resolved.baseUrl,
}
: undefined,
);
const resolved = !model.isRuntimeModel
? config.getResolvedModelConfig?.(
model.authType,
model.id,
model.registryBaseUrl,
)
: undefined;
const generation: ContentGeneratorConfig = {
...resolved?.generationConfig,
model: model.id,
authType: model.authType,
baseUrl: resolved?.baseUrl,
};
const configOptions = model.isRuntimeModel
? undefined
: buildModelReasoningRoutePreview(
generation,
resolveReasoningCapabilities(
generation,
model.capabilities?.reasoning ??
resolved?.capabilities?.reasoning,
),
settings.merged.model?.reasoningEffort,
modelId.startsWith(ACP_ROUTE_ID_PREFIX),
);
const providerModel: ServeWorkspaceProviderModel = {
modelId,
baseModelId: parseAcpBaseModelId(effectiveModelId),
@@ -14070,6 +14067,22 @@ class QwenAgent implements Agent {
const results = await Promise.allSettled(
sessions.map(async ([id, session]) => {
const reasoningError = session
.getConfig()
.stageReasoningOverrides?.(
newMerged.modelProviders,
newMerged.providerProtocol ?? {},
);
if (reasoningError)
await session
.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: reasoningError },
_meta: { qwenDiscreteMessage: true },
})
.catch((error) =>
debugLogger.warn('Reasoning notice delivery failed', error),
);
if (!session.isIdle()) {
skipped.push(id);
return;
@@ -15476,7 +15489,8 @@ class QwenAgent implements Agent {
if (
activeRuntimeSnapshot ||
currentModelId.startsWith(ACP_ROUTE_ID_PREFIX) ||
(currentModelId.startsWith(ACP_ROUTE_ID_PREFIX) &&
!modelReasoning?.profile) ||
!isReasoningSelectionSupported(
rawCurrentModelId,
REASONING_EFFORT_DEFAULT,
@@ -15528,6 +15542,13 @@ class QwenAgent implements Agent {
generation,
modelReasoning,
);
if (
modelReasoning?.profile &&
gptOverride?.enabled &&
gptOverride.useDefaultEffort
) {
return [modeConfigOption, modelConfigOption];
}
const gptEnableOverride =
generation.reasoning === false
? getGptReasoningOverrideState(
@@ -15626,10 +15647,11 @@ class QwenAgent implements Agent {
config.getAuthType?.(),
config.getCurrentModelRegistryBaseUrl?.(),
);
if (completeModelId.startsWith(ACP_ROUTE_ID_PREFIX)) {
return undefined;
}
return getConfiguredModelReasoning(config);
const reasoning = getConfiguredModelReasoning(config);
return completeModelId.startsWith(ACP_ROUTE_ID_PREFIX) &&
!reasoning?.profile
? undefined
: reasoning;
}
private buildSelectableModelOptions(config: Config) {
@@ -9,8 +9,10 @@ import {
buildInstallPlan,
findProviderById,
REASONING_EFFORT_TIERS,
parseModelReasoningCapabilities,
resolveBaseUrl,
} from '@qwen-code/qwen-code-core';
import { resolveReasoningCapabilities } from '@qwen-code/qwen-code-core/core/reasoning-overrides.js';
import type { LoadedSettings } from '../config/settings.js';
import { describe, expect, it, vi } from 'vitest';
import {
@@ -19,6 +21,8 @@ import {
buildModelReasoningConfigPreview,
clearReasoningRequestOverrides,
getConfiguredModelReasoning,
getReasoningForDisplay,
buildModelReasoningRoutePreview,
getDefaultReasoningConfig,
getModelConfiguration,
getGptReasoningOverrideState,
@@ -123,7 +127,9 @@ describe('model configuration manifest', () => {
apiKey: 'test-key',
modelIds: [model],
}).modelProviders![0].models[0];
const reasoning = installed.capabilities?.reasoning;
const reasoning = parseModelReasoningCapabilities(
installed.capabilities?.reasoning,
);
expect(reasoning).toBeDefined();
const option = buildModelReasoningConfigOption(model, {}, reasoning);
expect(
@@ -768,3 +774,141 @@ describe('getReasoningEffortsForConfig', () => {
);
});
});
describe('external reasoning controls', () => {
const declaration = {
profile: 'openai-effort',
efforts: ['low', 'medium', 'high'],
defaultEffort: 'medium',
} as const;
const generation = {
model: 'company-alias',
authType: 'openai',
baseUrl: 'https://gateway.example/v1',
} as ContentGeneratorConfig;
it('shows concrete defaults without persisting them as a selection', () => {
const config = {
getModel: () => generation.model,
getContentGeneratorConfig: () => generation,
getResolvedModelConfig: () => ({
capabilities: { reasoning: declaration },
}),
} as unknown as Config;
const reasoning = getConfiguredModelReasoning(config);
expect(
buildModelReasoningRoutePreview(generation, reasoning, undefined)?.[0],
).toMatchObject({
currentValue: 'medium',
options: [
{ value: 'none' },
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
],
});
expect(getReasoningForDisplay(config, generation)).toEqual({
effort: 'medium',
});
expect(generation.reasoning).toBeUndefined();
const limited = resolveReasoningCapabilities(generation, {
...declaration,
efforts: ['low', 'high'],
defaultEffort: 'low',
});
expect(
buildModelReasoningRoutePreview(generation, limited, 'medium')?.[0]
.currentValue,
).toBe('high');
});
it('uses the captured exact route while live settings change', () => {
const active = {
...generation,
reasoningRouteBaseUrl: null,
reasoningSnapshot: [
{
id: generation.model,
authType: generation.authType!,
baseUrl: generation.baseUrl,
reasoning: declaration,
},
{
id: generation.model,
authType: generation.authType!,
baseUrl: generation.baseUrl,
registryBaseUrl: generation.baseUrl,
reasoning: { ...declaration, defaultEffort: 'high' as const },
},
],
};
const config = {
getModel: () => generation.model,
getContentGeneratorConfig: () => active,
getResolvedModelConfig: () => {
throw new Error('must not read live registry');
},
} as unknown as Config;
expect(getReasoningForDisplay(config, active)).toEqual({
effort: 'medium',
});
expect(
getReasoningForDisplay(config, {
...active,
reasoningRouteBaseUrl: generation.baseUrl,
}),
).toEqual({ effort: 'high' });
});
it.each(['openai-effort', 'openai-reasoning'] as const)(
'projects canonical raw overrides for %s aliases',
(profile) => {
const reasoning = resolveReasoningCapabilities(generation, {
...declaration,
profile,
});
expect(
getGptReasoningOverrideState(
{
...generation,
baseUrl: 'https://openrouter.ai/api/v1',
extra_body: { reasoning: { enabled: false } },
},
reasoning,
)?.enabled,
).toBe(false);
const extra_body =
profile === 'openai-effort'
? { reasoning_effort: 'high' }
: { reasoning: { effort: 'high' } };
const raw = {
...generation,
extra_body,
reasoningSnapshot: [
{
id: generation.model,
authType: generation.authType!,
baseUrl: generation.baseUrl,
reasoning: { ...declaration, profile },
},
],
};
clearReasoningRequestOverrides(raw);
expect(raw.extra_body).toEqual(extra_body);
expect(
buildModelReasoningRoutePreview(
{ ...generation, extra_body },
reasoning,
undefined,
)?.[0].currentValue,
).toBe('high');
expect(
buildModelReasoningRoutePreview(
{ ...generation, extra_body, reasoning: false },
reasoning,
undefined,
)?.[0].currentValue,
).toBe('none');
},
);
});
@@ -16,11 +16,16 @@ import {
type ContentGeneratorConfig,
type ReasoningEffort,
} from '@qwen-code/qwen-code-core';
import {
resolveReasoningForModel,
getEffectiveReasoning,
type ReasoningProfile,
} from '@qwen-code/qwen-code-core/core/reasoning-overrides.js';
import type { SessionConfigOption } from '@agentclientprotocol/sdk';
import type { LoadedSettings } from '../config/settings.js';
import { ACP_ROUTE_ID_PREFIX } from '../utils/acpModelUtils.js';
export type ModelReasoningConfiguration =
export type ModelReasoningConfiguration = (
| {
readonly thinking: true;
readonly toggleOnly: true;
@@ -33,7 +38,8 @@ export type ModelReasoningConfiguration =
readonly defaultEffort?: ReasoningEffort;
readonly defaultEnabled?: boolean;
readonly canDisable?: false;
};
}
) & { readonly profile?: ReasoningProfile };
const MODEL_CONFIGURATIONS: Readonly<
Record<string, { readonly reasoning?: ModelReasoningConfiguration }>
@@ -140,7 +146,17 @@ export function getGptReasoningOverrideState(
}
| undefined {
const capabilities = getGptReasoningCapabilities(generation.model);
if (!capabilities || generation.reasoning === false) return undefined;
const profile = configuredReasoning?.profile;
if ((!capabilities && !profile) || generation.reasoning === false) {
return undefined;
}
if (
profile &&
profile !== 'openai-effort' &&
profile !== 'openai-reasoning'
) {
return undefined;
}
const reasoning = getModelConfiguration(
generation.model,
configuredReasoning,
@@ -150,7 +166,7 @@ export function getGptReasoningOverrideState(
const efforts =
reasoning && !reasoning.toggleOnly
? reasoning.efforts
: capabilities.efforts;
: (capabilities?.efforts ?? REASONING_EFFORT_TIERS);
const raw = { ...generation.samplingParams, ...generation.extra_body };
const flat = raw['reasoning_effort'];
const nested = raw['reasoning'] as
@@ -158,11 +174,12 @@ export function getGptReasoningOverrideState(
| false
| null
| undefined;
const openRouter = isOpenRouterHostname(generation);
const openRouter =
profile === 'openai-reasoning' || isOpenRouterHostname(generation);
const removedFlatNone =
(generation.thinkingMandatory === true ||
reasoning?.canDisable === false ||
capabilities.thinkingMandatory) &&
capabilities?.thinkingMandatory) &&
flat === REASONING_EFFORT_NONE;
if (!openRouter && !isReasoningEffortPlaceholder(flat) && !removedFlatNone) {
const effort = efforts.find((tier) => tier === flat);
@@ -235,13 +252,18 @@ export function resolvePersistedReasoningConfigState(
getModelConfiguration(modelId, reasoning)?.reasoning?.canDisable === false;
let selection = parseReasoningSelection(value);
if (
gptReasoning &&
!parseModelReasoningCapabilities(reasoning) &&
((reasoning?.profile && !reasoning.toggleOnly) ||
(gptReasoning && !parseModelReasoningCapabilities(reasoning))) &&
selection &&
selection !== REASONING_EFFORT_NONE &&
selection !== REASONING_EFFORT_DEFAULT
) {
selection = clampReasoningEffort(selection, gptReasoning.efforts);
selection = clampReasoningEffort(
selection,
reasoning && !reasoning.toggleOnly
? reasoning.efforts
: gptReasoning!.efforts,
);
}
if (
!selection ||
@@ -272,9 +294,10 @@ export function getModelConfiguration(
const gpt = getGptReasoningCapabilities(modelId);
return configured
? {
reasoning: gpt?.thinkingMandatory
? { ...configured, canDisable: false }
: configured,
reasoning:
!reasoning?.profile && gpt?.thinkingMandatory
? { ...configured, canDisable: false }
: configured,
}
: gpt
? {
@@ -297,23 +320,21 @@ export function getConfiguredModelReasoning(
fallbackToManifest = true,
): ModelReasoningConfiguration | undefined {
if (
!modelId ||
config.getActiveRuntimeModelSnapshot?.() ||
config.getModel?.().startsWith(ACP_ROUTE_ID_PREFIX)
) {
return undefined;
}
const generation = config.getContentGeneratorConfig?.();
const authType = generation?.authType ?? config.getAuthType?.();
const baseUrl =
generation?.baseUrl ??
config.getCurrentModelRegistryBaseUrl?.() ??
undefined;
const configured = authType
? config.getResolvedModelConfig?.(authType, modelId, baseUrl)
: undefined;
const reasoning = parseModelReasoningCapabilities(
configured?.capabilities?.reasoning,
);
const active = config.getContentGeneratorConfig?.();
const generation = {
...active,
model: active?.model ?? modelId,
authType: active?.authType ?? config.getAuthType?.(),
baseUrl:
active?.baseUrl ?? config.getCurrentModelRegistryBaseUrl?.() ?? undefined,
};
const reasoning = resolveReasoningForModel(config, generation, modelId);
return (
reasoning ??
(fallbackToManifest ? getModelConfiguration(modelId)?.reasoning : undefined)
@@ -368,7 +389,14 @@ export function isReasoningSelectionSupported(
export function clearReasoningRequestOverrides(
generation: ContentGeneratorConfig,
): void {
if (getGptReasoningCapabilities(generation.model)) return;
if (
getGptReasoningCapabilities(generation.model) ||
getGptReasoningOverrideState(
generation,
resolveReasoningForModel(undefined, generation),
)?.blocksTierChange
)
return;
for (const source of ['extra_body', 'samplingParams'] as const) {
const layer = generation[source];
if (!layer) continue;
@@ -442,8 +470,9 @@ export function buildModelReasoningConfigOption(
const effort =
state.effort &&
!reasoning.toggleOnly &&
getGptReasoningCapabilities(modelId) &&
!parseModelReasoningCapabilities(configuredReasoning)
(configuredReasoning?.profile ||
(getGptReasoningCapabilities(modelId) &&
!parseModelReasoningCapabilities(configuredReasoning)))
? clampReasoningEffort(state.effort, reasoning.efforts)
: state.effort;
const currentValue =
@@ -542,6 +571,13 @@ export function buildModelReasoningConfigPreview(
},
configuredReasoning,
);
if (
configuredReasoning?.profile &&
override?.enabled &&
override.useDefaultEffort
) {
return undefined;
}
const enableOverride =
generation &&
getGptReasoningOverrideState(
@@ -571,3 +607,39 @@ export function buildModelReasoningConfigPreview(
);
return option ? [option] : undefined;
}
export function buildModelReasoningRoutePreview(
generation: ContentGeneratorConfig,
reasoning: ModelReasoningConfiguration | undefined,
selection: unknown,
opaqueRoute = false,
): SessionConfigOption[] | undefined {
if (opaqueRoute && !reasoning?.profile) return undefined;
return buildModelReasoningConfigPreview(
generation.model,
{
enabled: generation.reasoning === false ? false : undefined,
effort: generation.reasoning ? generation.reasoning.effort : undefined,
...resolvePersistedReasoningConfigState(
generation.model,
selection,
generation.thinkingMandatory === true,
reasoning,
),
},
reasoning,
generation,
);
}
export function getReasoningForDisplay(
config: Config,
generation: ContentGeneratorConfig,
) {
const resolved = resolveReasoningForModel(config, generation);
if (!resolved) return generation.reasoning;
const override = getGptReasoningOverrideState(generation, resolved);
if (override) return override.enabled ? { effort: override.effort } : false;
if (config.getReasoningEffortOverride?.()) return generation.reasoning;
return getEffectiveReasoning(generation, resolved);
}
@@ -5263,6 +5263,20 @@ export class Session implements SessionContext {
let promptFailureMessage: string | undefined;
if (turnRecording) turnRecording.startedAt = Date.now();
try {
const reasoningMeta = (params as { _meta?: Record<string, unknown> })
._meta;
if (
scheduledGoalTurn === undefined &&
!(params as { retry?: boolean }).retry &&
!reasoningMeta?.[DAEMON_RETRY_META_KEY] &&
!reasoningMeta?.[DAEMON_CONTINUE_META_KEY] &&
!reasoningMeta?.[DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY] &&
this.config.applyReasoningOverrides?.()
) {
this.reconcileReasoningSelection(this.config.getModel(), {
persist: false,
});
}
const result = await this.#executePrompt(
params,
pendingSend,
@@ -124,6 +124,65 @@ describe('createWorkspaceProvidersStatusProvider', () => {
}
});
it('previews partial defaults and exact alias routes without losing invalid rows', async () => {
const reasoning = {
profile: 'openai-effort',
efforts: ['low', 'medium', 'high'],
};
await writeUserSettings({
modelProviders: {
openai: [
{
id: 'qwen3.8-max',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
capabilities: { reasoning: { defaultEffort: 'medium' } },
},
{
id: 'alias',
name: 'Medium alias',
baseUrl: 'https://a.example/v1',
capabilities: {
reasoning: { ...reasoning, defaultEffort: 'medium' },
},
},
{
id: 'alias',
name: 'High alias',
baseUrl: 'https://b.example/v1',
capabilities: {
reasoning: { ...reasoning, defaultEffort: 'high' },
},
},
{
id: 'invalid-alias',
capabilities: { reasoning: { profile: 'not-a-profile' } },
},
],
},
});
const status = await createWorkspaceProvidersStatusProvider({ env: {} })(
workspace,
false,
);
const models = status.providers.flatMap((provider) => provider.models);
for (const [id, expected] of [
['qwen3.8-max', 'medium'],
['Medium alias', 'medium'],
['High alias', 'high'],
]) {
const model = models.find(
(model) => model.baseModelId === id || model.name === id,
);
expect(model?.configOptions).toMatchObject([{ currentValue: expected }]);
expect(JSON.stringify(model?.configOptions)).not.toContain(
'"value":"default"',
);
}
expect(models.some((model) => model.baseModelId === 'invalid-alias')).toBe(
true,
);
});
it('reads fresh default model settings on every request', async () => {
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
@@ -11,7 +11,11 @@ import {
ModelsConfig,
tokenLimit,
} from '@qwen-code/qwen-code-core';
import type { AuthType } from '@qwen-code/qwen-code-core';
import { resolveReasoningCapabilities } from '@qwen-code/qwen-code-core/core/reasoning-overrides.js';
import type {
AuthType,
ContentGeneratorConfig,
} from '@qwen-code/qwen-code-core';
import type {
ServeWorkspaceProviderCurrent,
ServeWorkspaceProviderModel,
@@ -33,10 +37,7 @@ import {
parseAcpBaseModelId,
sanitizeProviderBaseUrl,
} from '../utils/acpModelUtils.js';
import {
buildModelReasoningConfigPreview,
resolvePersistedReasoningConfigState,
} from '../acp-integration/model-configuration.js';
import { buildModelReasoningRoutePreview } from '../acp-integration/model-configuration.js';
import { snapshotProcessEnv } from './env-snapshot.js';
import { getModelConfigurationKey } from './model-configuration.js';
@@ -167,32 +168,23 @@ function buildWorkspaceProvidersStatus(
const isCurrent =
currentAuth === model.authType && currentAcpModelId === modelId;
const resolved = modelId.startsWith(ACP_ROUTE_ID_PREFIX)
? undefined
: modelsConfig.getResolvedModel(
model.authType,
model.id,
model.registryBaseUrl ?? model.baseUrl,
);
const configOptions = modelId.startsWith(ACP_ROUTE_ID_PREFIX)
? undefined
: buildModelReasoningConfigPreview(
model.id,
resolvePersistedReasoningConfigState(
model.id,
settings.model?.reasoningEffort,
resolved?.generationConfig.thinkingMandatory === true,
model.capabilities?.reasoning,
),
model.capabilities?.reasoning,
resolved
? {
...resolved.generationConfig,
model: model.id,
baseUrl: resolved.baseUrl,
}
: undefined,
);
const resolved = modelsConfig.getResolvedModel(
model.authType,
model.id,
model.registryBaseUrl,
);
const generation: ContentGeneratorConfig = {
...resolved?.generationConfig,
model: model.id,
authType: model.authType,
baseUrl: resolved?.baseUrl,
};
const configOptions = buildModelReasoningRoutePreview(
generation,
resolveReasoningCapabilities(generation, model.capabilities?.reasoning),
settings.model?.reasoningEffort,
modelId.startsWith(ACP_ROUTE_ID_PREFIX),
);
const configurationKey = getModelConfigurationKey(
loaded,
authType,
+7 -2
View File
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { exec, type ChildProcess } from 'child_process';
import wrapAnsi from 'wrap-ansi';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { getReasoningForDisplay } from '../../acp-integration/model-configuration.js';
import { SettingScope } from '../../config/settings.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -314,7 +315,9 @@ export function useStatusLine(
// Reasoning effort lives on the content-generator config, not uiState, so it
// isn't a natural render trigger. Track it as a string key so the status line
// recomputes immediately when `/effort` changes it mid-session.
const reasoningConfig = config.getContentGeneratorConfig()?.reasoning;
const generationConfig = config.getContentGeneratorConfig();
const reasoningConfig =
generationConfig && getReasoningForDisplay(config, generationConfig);
const reasoningEffortKey =
reasoningConfig === false ? 'off' : (reasoningConfig?.effort ?? '');
const prevStateRef = useRef<{
@@ -461,7 +464,9 @@ export function useStatusLine(
sessionId: stats.sessionId,
version: cfg.getCliVersion(),
modelDisplayName,
reasoning: contentGeneratorConfig?.reasoning,
reasoning:
contentGeneratorConfig &&
getReasoningForDisplay(cfg, contentGeneratorConfig),
currentDir,
branch: ui.branchName,
pullRequestNumber: pullRequestNumberRef.current,
+110 -4
View File
@@ -6,6 +6,14 @@
import type { SessionSourceService } from '../services/session-sources.js';
import { resolveProviderProtocol } from '../models/modelRegistry.js';
import {
captureReasoningSnapshot,
validateReasoningCapabilities,
resolveReasoningForModel,
type ReasoningSnapshot,
} from '../core/reasoning-overrides.js';
// Node built-ins
import type { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
@@ -2913,6 +2921,8 @@ export class Config {
private readonly webSearchSessionUsage = { calls: 0 };
private visionModel?: string;
private compactionModel?: string;
private reasoningSnapshot?: ReasoningSnapshot;
private latestReasoningSnapshot?: ReasoningSnapshot;
private imageModel?: string;
private readonly visionBridgeTimeoutMs: number | undefined;
private readonly modelFallbacks: string[];
@@ -4820,9 +4830,106 @@ export class Config {
modelProvidersConfig,
providerProtocolConfig,
);
this.captureLatestReasoning();
this.baseLlmClient?.clearPerModelGeneratorCache();
}
getReasoningSnapshot(): ReasoningSnapshot {
return (this.reasoningSnapshot ??= captureReasoningSnapshot(
this.getAllConfiguredModels(),
));
}
stageReasoningOverrides(
providers: ModelProvidersConfig | undefined,
protocols: ProviderProtocolConfig = {},
): string | undefined {
const previous =
this.latestReasoningSnapshot ?? this.getReasoningSnapshot();
const models = this.getAllConfiguredModels().map((model) => {
const prior = previous.find(
(row) =>
row.id === model.id &&
row.authType === model.authType &&
row.registryBaseUrl === model.registryBaseUrl,
);
const configured = Object.entries(providers ?? {})
.flatMap(([provider, entries]) =>
model.authType !== AuthType.QWEN_OAUTH &&
resolveProviderProtocol(provider, protocols) === model.authType &&
Array.isArray(entries)
? entries
: [],
)
.find(
(entry) =>
entry?.id === model.id && entry.baseUrl === model.registryBaseUrl,
);
return {
...model,
capabilities: {
...model.capabilities,
reasoning: configured
? configured.capabilities?.reasoning
: prior
? prior.reasoning
: model.capabilities?.reasoning,
},
};
});
return this.captureLatestReasoning(models);
}
private captureLatestReasoning(
models = this.getAllConfiguredModels(),
): string | undefined {
try {
const next = captureReasoningSnapshot(models);
for (const row of next) {
if (row.authType !== AuthType.QWEN_OAUTH || row.reasoning?.profile) {
validateReasoningCapabilities(
{ model: row.id, authType: row.authType, baseUrl: row.baseUrl },
row.reasoning,
);
}
}
const generation = this.getContentGeneratorConfig();
if (generation)
resolveReasoningForModel(
this,
{
...generation,
reasoningSnapshot: next,
},
generation.model,
true,
);
this.latestReasoningSnapshot = next;
return undefined;
} catch (error) {
const message = `Reasoning settings not applied; keeping the previous configuration. ${getErrorMessage(error)}`;
// eslint-disable-next-line no-console -- configuration rejection must be visible without debug logging
console.warn(message);
return message;
}
}
applyReasoningOverrides(): boolean {
if (!this.latestReasoningSnapshot) this.captureLatestReasoning();
const next = this.latestReasoningSnapshot;
if (
!next ||
JSON.stringify(next) === JSON.stringify(this.reasoningSnapshot)
)
return false;
this.reasoningSnapshot = next;
const generation = this.getContentGeneratorConfig();
if (generation) generation.reasoningSnapshot = next;
this.baseLlmClient?.clearPerModelGeneratorCache();
this.notifyModelChangeListeners();
return true;
}
/**
* The raw modelProviders config the model registry was last built from.
* Lets hot-reload listeners diff against the APPLIED registry state instead
@@ -5826,10 +5933,7 @@ export class Config {
return undefined;
}
const configuredReasoning = cfg.authType
? this.getResolvedModelConfig(cfg.authType, cfg.model, cfg.baseUrl)
?.capabilities.reasoning
: undefined;
const configuredReasoning = resolveReasoningForModel(this, cfg);
const tieredModel = isTieredEffortWireModel(cfg.model, configuredReasoning);
if (!tieredModel) return undefined;
@@ -6100,6 +6204,8 @@ export class Config {
// setReasoningEffort() below. Do not add `reasoning` here — that would
// overwrite the live tier with the new model's default and make the
// restore a no-op.
this.contentGeneratorConfig.reasoningRouteBaseUrl =
config.reasoningRouteBaseUrl;
this.contentGeneratorConfig.model = config.model;
this.contentGeneratorConfig.samplingParams = config.samplingParams;
this.contentGeneratorConfig.contextWindowSize = config.contextWindowSize;
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReasoningEffort } from './reasoning-effort.js';
// Single source of truth for the Claude family list. Both the `ClaudeModelFamily`
// union and the model-id regex are derived from this array, so adding a family
// updates the type and the parser together — a maintainer can't update one and
// silently leave the other (and the `as ClaudeModelFamily` cast) stale.
const CLAUDE_MODEL_FAMILIES = [
'opus',
'sonnet',
'haiku',
'fable',
'mythos',
] as const;
type ClaudeModelFamily = (typeof CLAUDE_MODEL_FAMILIES)[number];
interface ParsedClaudeModelVersion {
family: ClaudeModelFamily;
major: number;
minor: number;
}
/**
* Parse a Claude model id into `{ family, major, minor }`, or `null` for
* non-Claude / unversioned ids. The single source of truth for the capability
* gating below — both `anthropicSupportedEffortTiers` and
* `modelSupportsAdaptiveThinking` consume this so the family list and the
* version-parsing rules can't drift apart when Anthropic ships a new family.
*
* The regex is unanchored so reseller-prefixed ids (`bedrock/…`, `vertex_ai/…`,
* `idealab:…`) match the same Anthropic models on the wire. The minor-version
* group is capped at one or two digits with a trailing `(?!\d)` so an 8-digit
* date suffix (`claude-opus-4-20250514` = Opus 4.0) is not mis-parsed as a giant
* minor version. The `{1,2}` cap alone is not enough — `\d{1,2}` is greedy and
* still matches `20` from `20250514`; it's the trailing `(?!\d)` negative
* lookahead that does the real work, forcing the engine to backtrack past any
* digit-followed match so the optional minor group fails to match entirely.
* Both together make dated ids with no real minor resolve to `minor = 0`
* (otherwise `minor` would wrongly clear `atLeast(4, 6)` / `atLeast(4, 7)` gates
* the model doesn't support — a server 400). Dated ids that do carry a minor,
* like `claude-opus-4-7-20251101`, still resolve to minor `7`; a bare major
* (`claude-opus-5`) resolves to minor `0`.
*/
export function parseClaudeModelVersion(
model: string,
): ParsedClaudeModelVersion | null {
// The minor separator accepts both `-` (Anthropic canonical, e.g.
// `claude-opus-4-8`) and `.` (LiteLLM/Vertex/Bedrock alias convention, e.g.
// `claude-opus-4.8`). Without the `.` branch a dotted alias parses as
// `{major, minor:0}`, silently disabling adaptive thinking, the
// temperature-rejection gate, and the version-gated effort tiers for 4.6+
// models — which surfaces as a server 400 the first time the harness sends
// `thinking.type.enabled` to an Opus 4.7+ / 5.x model group.
const match = model
.toLowerCase()
.match(
new RegExp(
`claude-(${CLAUDE_MODEL_FAMILIES.join(
'|',
)})-(\\d+)(?:[-.](\\d{1,2})(?!\\d))?`,
),
);
if (!match) {
return null;
}
return {
family: match[1] as ClaudeModelFamily,
major: Number.parseInt(match[2], 10),
minor: match[3] ? Number.parseInt(match[3], 10) : 0,
};
}
/**
* The reasoning-effort tiers a real Anthropic model accepts on
* `output_config.effort`. Every effort-capable model takes low/medium/high; the
* extra-strong tiers are gated by model version per the Anthropic docs
* (https://platform.claude.com/docs/en/build-with-claude/effort):
* - `max`: Opus/Sonnet 4.6+ and every 5.x family (Fable 5, Mythos 5, …).
* - `xhigh`: Opus 4.7+ and every 5.x family (NOT Sonnet 4.6 / Opus 4.6).
*
* Unknown/unversioned ids fall back to low/medium/high so we never send a tier
* the server might 400 on. Effort levels above what the model supports are
* clamped by the caller via clampReasoningEffort.
*/
export function anthropicSupportedEffortTiers(
model: string,
): ReasoningEffort[] {
const tiers: ReasoningEffort[] = ['low', 'medium', 'high'];
const parsed = parseClaudeModelVersion(model);
if (!parsed) {
return tiers;
}
const { family, major, minor } = parsed;
const atLeast = (maj: number, min: number) =>
major > maj || (major === maj && minor >= min);
// xhigh: Opus 4.7+ and all 5.x families.
if (major >= 5 || (family === 'opus' && atLeast(4, 7))) {
tiers.push('xhigh');
}
// max: 4.6+ (opus/sonnet only) and all 5.x families. The 4.x branch is
// family-guarded to match the documented support above — haiku 4.x never
// gains `max` (a server 400), while every 5.x family still does via major>=5.
if (
major >= 5 ||
((family === 'opus' || family === 'sonnet') && atLeast(4, 6))
) {
tiers.push('max');
}
return tiers;
}
@@ -125,6 +125,112 @@ describe('AnthropicContentGenerator', () => {
vi.restoreAllMocks();
});
it.each([
['anthropic-manual', { type: 'enabled', budget_tokens: 31999 }],
['anthropic-adaptive', { type: 'adaptive', display: 'summarized' }],
['deepseek-anthropic', { type: 'enabled', budget_tokens: 32000 }],
[
'deepseek-anthropic',
{ type: 'enabled' },
'https://api.deepseek.com/anthropic',
],
[
'anthropic-manual',
{ type: 'enabled' },
'https://api.deepseek.com/anthropic',
],
])(
'uses %s for an unknown alias with its default effort',
async (profile, thinking, baseUrl = 'https://example.test') => {
const { AnthropicContentGenerator } = await importGenerator();
mockConfig.getResolvedModelConfig = vi.fn().mockReturnValue({
capabilities: {
reasoning: {
profile,
efforts: ['low', 'medium', 'high', 'max'],
defaultEffort: 'medium',
},
},
});
anthropicState.createImpl.mockResolvedValue({
id: 'reply',
model: 'company-alias',
content: [{ type: 'text', text: 'ok' }],
});
const generator = new AnthropicContentGenerator(
{
model: 'company-alias',
authType: 'anthropic' as ContentGeneratorConfig['authType'],
apiKey: 'dummy',
baseUrl,
samplingParams: { max_tokens: 32000, temperature: 0 },
},
mockConfig,
);
await generator.generateContent({
model: 'company-alias',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
});
expect(anthropicState.lastCreateArgs?.[0]).toMatchObject({
thinking,
output_config: { effort: 'medium' },
});
if (profile === 'anthropic-manual') {
expect(anthropicState.lastCreateArgs?.[0]).toMatchObject({
temperature: 1,
});
await generator.generateContent({
model: 'company-alias',
contents: 'Hello',
config: { maxOutputTokens: 500 },
});
expect(anthropicState.lastCreateArgs?.[0]).not.toHaveProperty(
'thinking',
);
}
},
);
it('uses adaptive for a declared alias even with an explicit manual budget', async () => {
const { AnthropicContentGenerator } = await importGenerator();
mockConfig.getResolvedModelConfig = vi.fn().mockReturnValue({
capabilities: {
reasoning: {
profile: 'anthropic-adaptive',
efforts: ['medium', 'max'],
defaultEffort: 'max',
},
},
});
anthropicState.createImpl.mockResolvedValue({
id: 'reply',
model: 'company-alias',
content: [{ type: 'text', text: 'ok' }],
});
const generator = new AnthropicContentGenerator(
{
model: 'company-alias',
authType: 'anthropic' as ContentGeneratorConfig['authType'],
apiKey: 'dummy',
reasoning: { budget_tokens: 2048 },
samplingParams: { max_tokens: 64000 },
},
mockConfig,
);
await generator.generateContent({
model: 'company-alias',
contents: [{ role: 'model', parts: [{ text: 'Partial answer' }] }],
});
expect(anthropicState.lastCreateArgs?.[0]).toMatchObject({
thinking: { type: 'adaptive', display: 'summarized' },
output_config: { effort: 'max' },
});
const body = anthropicState.lastCreateArgs?.[0] as {
messages: Array<{ role: string }>;
};
expect(body.messages.at(-1)?.role).toBe('user');
});
it('uses claude-cli identity (User-Agent + x-app + Bearer auth) for non-Anthropic baseURLs', async () => {
// Non-Anthropic-native baseURL → IdeaLab-style proxy path:
// - User-Agent presents as `claude-cli/<version> (external, cli)`
@@ -13,7 +13,15 @@ import type {
Part,
} from '@google/genai';
import { GenerateContentResponse } from '@google/genai';
import {
parseClaudeModelVersion,
anthropicSupportedEffortTiers,
} from '../anthropic-reasoning.js';
import type { Config } from '../../config/config.js';
import {
getEffectiveReasoning,
resolveReasoningForModel,
} from '../reasoning-overrides.js';
import type {
ContentGenerator,
ContentGeneratorConfig,
@@ -141,113 +149,6 @@ function isDeepSeekAnthropicProvider(
return model.includes('deepseek');
}
// Single source of truth for the Claude family list. Both the `ClaudeModelFamily`
// union and the model-id regex are derived from this array, so adding a family
// updates the type and the parser together — a maintainer can't update one and
// silently leave the other (and the `as ClaudeModelFamily` cast) stale.
const CLAUDE_MODEL_FAMILIES = [
'opus',
'sonnet',
'haiku',
'fable',
'mythos',
] as const;
type ClaudeModelFamily = (typeof CLAUDE_MODEL_FAMILIES)[number];
interface ParsedClaudeModelVersion {
family: ClaudeModelFamily;
major: number;
minor: number;
}
/**
* Parse a Claude model id into `{ family, major, minor }`, or `null` for
* non-Claude / unversioned ids. The single source of truth for the capability
* gating below — both `anthropicSupportedEffortTiers` and
* `modelSupportsAdaptiveThinking` consume this so the family list and the
* version-parsing rules can't drift apart when Anthropic ships a new family.
*
* The regex is unanchored so reseller-prefixed ids (`bedrock/…`, `vertex_ai/…`,
* `idealab:…`) match the same Anthropic models on the wire. The minor-version
* group is capped at one or two digits with a trailing `(?!\d)` so an 8-digit
* date suffix (`claude-opus-4-20250514` = Opus 4.0) is not mis-parsed as a giant
* minor version. The `{1,2}` cap alone is not enough — `\d{1,2}` is greedy and
* still matches `20` from `20250514`; it's the trailing `(?!\d)` negative
* lookahead that does the real work, forcing the engine to backtrack past any
* digit-followed match so the optional minor group fails to match entirely.
* Both together make dated ids with no real minor resolve to `minor = 0`
* (otherwise `minor` would wrongly clear `atLeast(4, 6)` / `atLeast(4, 7)` gates
* the model doesn't support — a server 400). Dated ids that do carry a minor,
* like `claude-opus-4-7-20251101`, still resolve to minor `7`; a bare major
* (`claude-opus-5`) resolves to minor `0`.
*/
function parseClaudeModelVersion(
model: string,
): ParsedClaudeModelVersion | null {
// The minor separator accepts both `-` (Anthropic canonical, e.g.
// `claude-opus-4-8`) and `.` (LiteLLM/Vertex/Bedrock alias convention, e.g.
// `claude-opus-4.8`). Without the `.` branch a dotted alias parses as
// `{major, minor:0}`, silently disabling adaptive thinking, the
// temperature-rejection gate, and the version-gated effort tiers for 4.6+
// models — which surfaces as a server 400 the first time the harness sends
// `thinking.type.enabled` to an Opus 4.7+ / 5.x model group.
const match = model
.toLowerCase()
.match(
new RegExp(
`claude-(${CLAUDE_MODEL_FAMILIES.join(
'|',
)})-(\\d+)(?:[-.](\\d{1,2})(?!\\d))?`,
),
);
if (!match) {
return null;
}
return {
family: match[1] as ClaudeModelFamily,
major: Number.parseInt(match[2], 10),
minor: match[3] ? Number.parseInt(match[3], 10) : 0,
};
}
/**
* The reasoning-effort tiers a real Anthropic model accepts on
* `output_config.effort`. Every effort-capable model takes low/medium/high; the
* extra-strong tiers are gated by model version per the Anthropic docs
* (https://platform.claude.com/docs/en/build-with-claude/effort):
* - `max`: Opus/Sonnet 4.6+ and every 5.x family (Fable 5, Mythos 5, …).
* - `xhigh`: Opus 4.7+ and every 5.x family (NOT Sonnet 4.6 / Opus 4.6).
*
* Unknown/unversioned ids fall back to low/medium/high so we never send a tier
* the server might 400 on. Effort levels above what the model supports are
* clamped by the caller via clampReasoningEffort.
*/
function anthropicSupportedEffortTiers(model: string): ReasoningEffort[] {
const tiers: ReasoningEffort[] = ['low', 'medium', 'high'];
const parsed = parseClaudeModelVersion(model);
if (!parsed) {
return tiers;
}
const { family, major, minor } = parsed;
const atLeast = (maj: number, min: number) =>
major > maj || (major === maj && minor >= min);
// xhigh: Opus 4.7+ and all 5.x families.
if (major >= 5 || (family === 'opus' && atLeast(4, 7))) {
tiers.push('xhigh');
}
// max: 4.6+ (opus/sonnet only) and all 5.x families. The 4.x branch is
// family-guarded to match the documented support above — haiku 4.x never
// gains `max` (a server 400), while every 5.x family still does via major>=5.
if (
major >= 5 ||
((family === 'opus' || family === 'sonnet') && atLeast(4, 6))
) {
tiers.push('max');
}
return tiers;
}
/**
* Resolve the baseURL the Anthropic SDK will actually use, mirroring the
* SDK's own destructuring-default order: explicit config first, then
@@ -772,13 +673,25 @@ export class AnthropicContentGenerator implements ContentGenerator {
// downgrade once). Both the thinking budget ladder and output_config
// consume the result so the wire shape stays internally consistent.
const effectiveEffort = this.resolveEffectiveEffort(request);
const thinking = this.buildThinkingConfig(request, effectiveEffort);
let thinking = this.buildThinkingConfig(request, effectiveEffort);
const outputConfig = this.buildOutputConfig(request, effectiveEffort);
// Compute per-request: `Config.setModel()` mutates contentGeneratorConfig
// in place, so a constructor-time cache could go stale on a runtime
// model switch. The detector is cheap (URL parse + string compare).
const isDeepSeek = isDeepSeekAnthropicProvider(this.contentGeneratorConfig);
const profile = this.getReasoningCapabilities()?.profile;
if (profile === 'anthropic-manual' && thinking?.type === 'enabled') {
const budget = Math.min(thinking.budget_tokens, sampling.max_tokens - 1);
thinking =
budget >= 1024 ? { ...thinking, budget_tokens: budget } : undefined;
if (thinking && sampling.temperature !== undefined)
sampling.temperature = 1;
}
const isDeepSeek =
isDeepSeekAnthropicHostname(this.contentGeneratorConfig) ||
(profile
? profile === 'deepseek-anthropic'
: isDeepSeekAnthropicProvider(this.contentGeneratorConfig));
// On DeepSeek the converter must keep history aligned with the top-level
// `thinking` parameter to avoid HTTP 400:
@@ -796,7 +709,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
const dropUnsignedAssistantThinking =
!isDeepSeek &&
!!thinking &&
this.modelSupportsAdaptiveThinking() &&
this.modelSupportsAdaptiveThinking(true) &&
!isAnthropicNativeBaseUrl(this.contentGeneratorConfig);
// Opus/Sonnet 4.6+ and every 5.x family reject a request whose final
// message has role 'assistant' ("assistant message prefill") with a
@@ -804,7 +717,8 @@ export class AnthropicContentGenerator implements ContentGenerator {
// model-generation behavior change, identical on the native API,
// Vertex AI, and Bedrock, so (unlike the signature workaround above)
// this is NOT gated on baseURL.
const stripTrailingAssistantPrefill = this.modelSupportsAdaptiveThinking();
const stripTrailingAssistantPrefill =
this.modelSupportsAdaptiveThinking(true);
// Sample the live cache-control flags once per request and forward
// them to the converter (body-side `cache_control`). The converter's
@@ -995,7 +909,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
if (request.config?.thinkingConfig?.includeThoughts === false) {
return undefined;
}
const reasoning = this.contentGeneratorConfig.reasoning;
const reasoning = getEffectiveReasoning(
this.contentGeneratorConfig,
this.getReasoningCapabilities(),
);
if (reasoning === false || reasoning === undefined) {
return undefined;
}
@@ -1003,6 +920,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
if (effort === undefined) {
return undefined;
}
if (this.getReasoningCapabilities()?.profile) return effort;
if (isDeepSeekAnthropicHostname(this.contentGeneratorConfig)) {
// DeepSeek's anthropic-compatible output_config.effort accepts only
// high/max. Mirror the DeepSeek OpenAI adapter (deepseek.ts): low/medium
@@ -1041,6 +959,13 @@ export class AnthropicContentGenerator implements ContentGenerator {
return clamped;
}
private getReasoningCapabilities() {
return resolveReasoningForModel(
this.cliConfig,
this.contentGeneratorConfig,
);
}
/**
* Check if the current model supports adaptive thinking (type: 'adaptive').
* Claude 4.6+ models require adaptive thinking; older models use the
@@ -1049,7 +974,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
* stay in lockstep — a model parsed for effort gating is parsed identically
* here for the thinking shape.
*/
private modelSupportsAdaptiveThinking(): boolean {
private modelSupportsAdaptiveThinking(history = false): boolean {
const profile = this.getReasoningCapabilities()?.profile;
if (profile === 'anthropic-adaptive') return true;
if (profile && !history) return false;
const parsed = parseClaudeModelVersion(
this.contentGeneratorConfig.model || '',
);
@@ -1071,6 +999,8 @@ export class AnthropicContentGenerator implements ContentGenerator {
* gates so the version rules can't drift.
*/
private modelRejectsManualThinking(): boolean {
const profile = this.getReasoningCapabilities()?.profile;
if (profile) return profile === 'anthropic-adaptive';
const parsed = parseClaudeModelVersion(
this.contentGeneratorConfig.model || '',
);
@@ -1104,7 +1034,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
return undefined;
}
const reasoning = this.contentGeneratorConfig.reasoning;
const reasoning = getEffectiveReasoning(
this.contentGeneratorConfig,
this.getReasoningCapabilities(),
);
const requestBudgetCap = request.config?.thinkingConfig?.thinkingBudget;
const applyRequestBudgetCap = (budgetTokens: number): number =>
typeof requestBudgetCap === 'number' && requestBudgetCap > 0
+5
View File
@@ -2976,6 +2976,11 @@ export class LlmClient {
) {
await this.config.assertCanStartTurn();
}
if (
messageType === SendMessageType.UserQuery &&
!options?.isConcurrentSideQuery
)
this.config.applyReasoningOverrides?.();
const signal = options?.goalSignal
? AbortSignal.any([callerSignal, options.goalSignal])
: callerSignal;
@@ -150,6 +150,8 @@ export type ContentGeneratorConfig = {
// (e.g. `max_completion_tokens` for GPT-5 / o-series, `reasoning_effort`).
[key: string]: unknown;
};
reasoningSnapshot?: import('./reasoning-overrides.js').ReasoningSnapshot;
reasoningRouteBaseUrl?: string | null;
reasoning?:
| false
| {
@@ -255,6 +257,12 @@ export function resolveContentGeneratorConfigWithSources(
const newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
...(generationConfig || {}),
authType,
reasoningSnapshot:
generationConfig?.reasoningSnapshot ?? config?.getReasoningSnapshot?.(),
reasoningRouteBaseUrl:
generationConfig && 'reasoningRouteBaseUrl' in generationConfig
? generationConfig.reasoningRouteBaseUrl
: config?.getCurrentModelRegistryBaseUrl?.(),
proxy: config?.getProxy(),
};
@@ -9,6 +9,7 @@ import { LlmContentGenerator } from './llm-content-generator.js';
import { GoogleGenAI } from '@google/genai';
import type { Part } from '@google/genai';
import type { Config } from '../../config/config.js';
import type { AuthType } from '../contentGenerator.js';
const mockReportLlmRequest = vi.hoisted(() => vi.fn());
const mockReportLlmResponse = vi.hoisted(() => vi.fn());
@@ -48,6 +49,51 @@ describe('LlmContentGenerator', () => {
mockGoogleGenAI = vi.mocked(GoogleGenAI).mock.results[0].value;
});
it.each([false, true])(
'uses the declared Gemini default while respecting request opt-out=%s',
async (off) => {
const config = {
getResolvedModelConfig: vi.fn().mockReturnValue({
capabilities: {
reasoning: {
profile: 'gemini',
efforts: ['low', 'medium', 'high'],
defaultEffort: 'medium',
},
},
}),
} as unknown as Config;
const configured = new LlmContentGenerator(
{ apiKey: 'dummy' },
{ model: 'company-alias', authType: 'gemini' as AuthType },
config,
);
await configured.generateContent(
{
model: 'company-alias',
contents: [],
...(off
? {
config: {
thinkingConfig: { includeThoughts: false, thinkingBudget: 0 },
},
}
: {}),
},
'prompt',
);
expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
thinkingConfig: off
? { includeThoughts: false, thinkingBudget: 0 }
: { includeThoughts: true, thinkingLevel: 'MEDIUM' },
}),
}),
);
},
);
it('should merge customHeaders into existing httpOptions.headers', async () => {
vi.mocked(GoogleGenAI).mockClear();
@@ -28,6 +28,10 @@ import {
type GenAiAttemptHandle,
} from '../../telemetry/gen-ai-request.js';
import type { Config } from '../../config/config.js';
import {
getEffectiveReasoning,
resolveReasoningForModel,
} from '../reasoning-overrides.js';
import { buildSessionIdHeaders } from '../outbound-session-id.js';
import {
expandDynamicHeaders,
@@ -203,7 +207,7 @@ export class LlmContentGenerator implements ContentGenerator {
'frequencyPenalty',
),
thinkingConfig: getParameterValue(
this.buildThinkingConfig(),
this.buildThinkingConfig(request.model, requestConfig.thinkingConfig),
'thinkingConfig',
{
includeThoughts: true,
@@ -213,10 +217,20 @@ export class LlmContentGenerator implements ContentGenerator {
};
}
private buildThinkingConfig():
| { includeThoughts: boolean; thinkingLevel?: ThinkingLevel }
| undefined {
const reasoning = this.contentGeneratorConfig?.reasoning;
private buildThinkingConfig(
model?: string,
requestThinking?: GenerateContentConfig['thinkingConfig'],
): GenerateContentConfig['thinkingConfig'] {
const generation = this.contentGeneratorConfig;
const resolved =
generation && this.cliConfig
? resolveReasoningForModel(this.cliConfig, generation, model)
: undefined;
if (resolved && requestThinking?.includeThoughts === false)
return requestThinking;
const reasoning = generation
? getEffectiveReasoning(generation, resolved)
: undefined;
if (reasoning === false) {
return { includeThoughts: false };
@@ -32,6 +32,7 @@ import {
type PromptCacheSharingParameters,
} from '../contentGenerator.js';
import type { OpenAICompatibleProvider } from './provider/index.js';
import { determineProvider } from './index.js';
import { DefaultOpenAICompatibleProvider } from './provider/default.js';
import { DashScopeOpenAICompatibleProvider } from './provider/dashscope.js';
import {
@@ -2125,7 +2126,12 @@ describe('ContentGenerationPipeline', () => {
}).modelProviders![0].models[0];
expect(installed.capabilities?.reasoning).toBeDefined();
for (const mode of ['enabled', 'disabled', 'side-query'] as const) {
for (const [mode, partial] of (
['enabled', 'disabled', 'side-query'] as const
).flatMap((mode) => [
[mode, false] as const,
...(effort ? [[mode, true] as const] : []),
])) {
mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
...installed.generationConfig,
@@ -2138,7 +2144,14 @@ describe('ContentGenerationPipeline', () => {
};
mockCliConfig = {
...mockCliConfig,
getResolvedModelConfig: vi.fn().mockReturnValue(installed),
getResolvedModelConfig: vi.fn().mockReturnValue(
partial
? {
...installed,
capabilities: { reasoning: { defaultEffort: effort } },
}
: installed,
),
getContentGeneratorConfig: () => mockContentGeneratorConfig,
getCliVersion: () => 'test',
} as unknown as Config;
@@ -2183,6 +2196,8 @@ describe('ContentGenerationPipeline', () => {
mockClient.chat.completions.create as Mock
).mock.calls.at(-1)![0];
expect(wire.reasoning).toBeUndefined();
if (partial && mandatory && mode === 'side-query')
expect(wire.reasoning_effort).toBeUndefined();
if (mode === 'enabled') {
expect(wire.reasoning_effort).toBe(effort);
if (
@@ -2260,6 +2275,264 @@ describe('ContentGenerationPipeline', () => {
return (mockClient.chat.completions.create as Mock).mock.calls.at(-1)![0];
}
it.each([
[
'openai-effort',
{ reasoning_effort: 'medium' },
{ reasoning_effort: 'none' },
],
[
'openai-reasoning',
{ reasoning: { effort: 'medium' } },
{ reasoning: { enabled: false } },
],
[
'deepseek-openai',
{ thinking: { type: 'enabled' }, reasoning_effort: 'medium' },
{ thinking: { type: 'disabled' } },
],
[
'dashscope-effort',
{ reasoning_effort: 'medium' },
{ reasoning_effort: 'none' },
],
[
'dashscope-thinking',
{ enable_thinking: true },
{ enable_thinking: false },
],
[
'qwen-chat-template',
{ chat_template_kwargs: { enable_thinking: true } },
{ chat_template_kwargs: { enable_thinking: false } },
],
])(
'uses declared %s defaults and disable wire for an unknown alias',
async (profile, enabled, disabled) => {
(mockProvider.buildRequest as Mock).mockImplementation((request) =>
new DefaultOpenAICompatibleProvider(
mockContentGeneratorConfig,
mockCliConfig,
).buildRequest(request, 'test'),
);
const capability = {
profile,
...(profile === 'dashscope-thinking' ||
profile === 'qwen-chat-template'
? {}
: { efforts: ['low', 'medium', 'high'], defaultEffort: 'medium' }),
};
for (const [reasoning, expected] of [
[undefined, enabled],
[false, disabled],
] as const) {
const wire = await executeWithCapability(
capability,
{ reasoning },
'company-alias',
);
const actual = Object.fromEntries(
Object.entries(wire).filter(([key]) =>
[
'reasoning',
'reasoning_effort',
'thinking',
'enable_thinking',
'chat_template_kwargs',
].includes(key),
),
);
expect(actual).toEqual(expected);
}
},
);
it('preserves template siblings and clears incompatible budget on declared off requests', async () => {
(mockProvider.buildRequest as Mock).mockImplementation((request) =>
new DefaultOpenAICompatibleProvider(
mockContentGeneratorConfig,
mockCliConfig,
).buildRequest(request, 'test'),
);
const template = await executeWithCapability(
{ profile: 'qwen-chat-template' },
{
reasoning: false,
extra_body: {
enable_thinking: true,
chat_template_kwargs: { foo: 1 },
},
},
'company-alias',
);
expect(template['chat_template_kwargs']).toEqual({
foo: 1,
enable_thinking: false,
});
expect(template['enable_thinking']).toBeUndefined();
const effort = await executeWithCapability(
{ defaultEffort: 'medium' },
{
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
reasoning: false,
extra_body: { thinking_budget: 4096, enable_thinking: true },
},
'qwen3.8-max',
);
expect(effort['reasoning_effort']).toBe('none');
expect(effort['thinking_budget']).toBeUndefined();
expect(effort['enable_thinking']).toBeUndefined();
});
it.each(['samplingParams', 'extra_body'] as const)(
'keeps native DeepSeek %s reasoning projection with a profile',
async (source) => {
(mockProvider.buildRequest as Mock).mockImplementation((request) =>
new DeepSeekOpenAICompatibleProvider(
mockContentGeneratorConfig,
mockCliConfig,
).buildRequest(request, 'test'),
);
const wire = await executeWithCapability(
{
profile: 'deepseek-openai',
efforts: ['high', 'max'],
defaultEffort: 'high',
},
{
baseUrl: 'https://api.deepseek.com/v1',
[source]: { reasoning: { effort: 'max' } },
},
);
expect(wire['reasoning_effort']).toBe('max');
expect(wire['reasoning']).toBeUndefined();
},
);
it.each([
['https://api.deepseek.com/v1', 'openai-effort', 'reasoning_content', ''],
[
'https://api.cerebras.ai/v1',
'deepseek-openai',
'reasoning_content',
undefined,
],
[
'https://api.mistral.ai/v1',
'deepseek-openai',
'reasoning_content',
undefined,
],
[
'https://api.fireworks.ai/inference/v1',
'qwen-chat-template',
'reasoning',
undefined,
],
[
'https://api.xiaomimimo.com/v1',
'openai-effort',
'reasoning_content',
'',
],
] as const)(
'preserves native history constraints on %s with %s',
(baseUrl, profile, field, expected) => {
const config = {
...mockContentGeneratorConfig,
authType: AuthType.USE_OPENAI,
model: 'alias',
baseUrl,
};
const cli = {
...mockCliConfig,
getResolvedModelConfig: vi.fn().mockReturnValue({
capabilities: {
reasoning: {
profile,
...(profile === 'qwen-chat-template'
? {}
: {
efforts: ['low', 'medium', 'high'],
defaultEffort: 'medium',
}),
},
},
}),
};
const provider = determineProvider(config, cli as unknown as Config);
const result = provider.buildRequest(
{
model: 'alias',
messages: [
{
role: 'assistant',
content: 'answer',
...(expected === undefined
? { reasoning_content: 'trace' }
: {}),
},
],
} as OpenAI.Chat.ChatCompletionCreateParams,
'test',
);
if (expected === undefined)
expect(result.messages[0]).not.toHaveProperty(field);
else expect(result.messages[0]).toHaveProperty(field, expected);
},
);
it('keeps explicit sampling reasoning above a configured default', async () => {
const capability = {
profile: 'openai-effort',
efforts: ['low', 'medium', 'high'],
defaultEffort: 'medium',
};
const wire = await executeWithCapability(
capability,
{ samplingParams: { reasoning: { effort: 'minimal' } } },
'company-alias',
);
expect(wire['reasoning']).toEqual({ effort: 'minimal' });
const budget = await executeWithCapability(
capability,
{
samplingParams: undefined,
reasoning: { effort: 'high', budget_tokens: 8192 },
},
'company-alias',
);
expect(budget).toMatchObject({
reasoning_effort: 'high',
reasoning: { budget_tokens: 8192 },
});
});
it.each([
['qwen3.8-max', 'https://dashscope.aliyuncs.com/compatible-mode/v1'],
['openai/gpt-5.4', 'https://openrouter.ai/api/v1'],
])(
'resolves %s default before shaping its request',
async (model, baseUrl) => {
const wire = await executeWithCapability(
{ defaultEffort: 'medium' },
{
baseUrl,
extra_body: model.startsWith('openai/')
? undefined
: { reasoning_effort: null },
},
model,
);
if (model.startsWith('openai/'))
expect(wire['reasoning']).toEqual({ effort: 'medium' });
else {
expect(wire['reasoning_effort']).toBe('medium');
expect(wire['reasoning']).toBeUndefined();
}
},
);
it.each([
['enable_thinking', false, undefined, undefined],
['reasoning_effort', undefined, 'none', undefined],
@@ -61,8 +61,12 @@ import {
import { getCurrentAgentId } from '../../agents/runtime/agent-context.js';
import { isInForkExecution } from '../../tools/agent/fork-subagent.js';
import { trailingReattachPartCount } from '../../services/image-payload-references.js';
import type { ModelReasoningCapabilities } from '../../models/types.js';
import { parseModelReasoningCapabilities } from '../reasoning-effort.js';
import type { ResolvedReasoning } from '../reasoning-overrides.js';
import { ensureReasoningContentOnAssistantMessage } from './provider/utils.js';
import {
getEffectiveReasoning,
resolveReasoningForModel,
} from '../reasoning-overrides.js';
const debugLogger = createDebugLogger('OPENAI_PIPELINE');
const OPENAI_STRICT_SCHEMA_KEYS = new Set([
@@ -89,10 +93,52 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown>;
}
function profileReasoning(
profile: NonNullable<ResolvedReasoning['profile']>,
reasoning: ContentGeneratorConfig['reasoning'],
): Record<string, unknown> {
if (reasoning === undefined) return {};
const enabled = reasoning !== false;
const effort = reasoning && reasoning.effort;
switch (profile) {
case 'openai-reasoning':
return { reasoning: enabled ? reasoning : { enabled: false } };
case 'openai-effort':
case 'dashscope-effort':
return effort || !enabled
? { reasoning_effort: enabled ? effort : 'none' }
: {};
case 'deepseek-openai':
return {
thinking: { type: enabled ? 'enabled' : 'disabled' },
...(effort ? { reasoning_effort: effort } : {}),
};
case 'dashscope-thinking':
return { enable_thinking: enabled };
case 'qwen-chat-template':
return { chat_template_kwargs: { enable_thinking: enabled } };
default:
return {};
}
}
function applyConfiguredReasoningEffort(
request: OpenAI.Chat.ChatCompletionCreateParams,
capabilities: ModelReasoningCapabilities | undefined,
capabilities: ResolvedReasoning | undefined,
): OpenAI.Chat.ChatCompletionCreateParams {
if (capabilities?.profile) {
const loose = request as unknown as Record<string, unknown>;
const { reasoning, ...rest } = loose;
const { effort: _effort, ...siblings } = asObject(reasoning) ?? {};
return {
...(Object.keys(siblings).length ? { reasoning: siblings } : {}),
...profileReasoning(
capabilities.profile,
reasoning as ContentGeneratorConfig['reasoning'],
),
...rest,
} as unknown as OpenAI.Chat.ChatCompletionCreateParams;
}
if (
!capabilities ||
capabilities.toggleOnly ||
@@ -970,10 +1016,19 @@ export class ContentGenerationPipeline {
context: RequestContext,
isStreaming: boolean,
): Promise<OpenAI.Chat.ChatCompletionCreateParams> {
const messages = OpenAIContentConverter.convertLlmRequestToOpenAI(
const reasoningCapabilities = resolveReasoningForModel(
this.config.cliConfig,
this.contentGeneratorConfig,
context.model,
);
const convertedMessages = OpenAIContentConverter.convertLlmRequestToOpenAI(
request,
context,
);
const messages =
reasoningCapabilities?.profile === 'deepseek-openai'
? convertedMessages.map(ensureReasoningContentOnAssistantMessage)
: convertedMessages;
// Apply provider-specific enhancements
let baseRequest: OpenAI.Chat.ChatCompletionCreateParams = {
@@ -995,24 +1050,19 @@ export class ContentGenerationPipeline {
).stream = false;
}
const authType = this.contentGeneratorConfig.authType;
const reasoningCapabilities = authType
? parseModelReasoningCapabilities(
this.config.cliConfig.getResolvedModelConfig?.(
authType,
context.model,
this.contentGeneratorConfig.baseUrl,
)?.capabilities.reasoning,
)
: undefined;
const effectiveReasoning = getEffectiveReasoning(
this.contentGeneratorConfig,
reasoningCapabilities,
);
if (
reasoningCapabilities &&
!('reasoning' in baseRequest) &&
this.contentGeneratorConfig.reasoning
effectiveReasoning &&
request.config?.thinkingConfig?.includeThoughts !== false
) {
baseRequest = {
...baseRequest,
reasoning: this.contentGeneratorConfig.reasoning,
reasoning: effectiveReasoning,
} as unknown as OpenAI.Chat.ChatCompletionCreateParams;
}
// A `reasoning` object the user put in `samplingParams` ships verbatim (the
@@ -1020,7 +1070,10 @@ export class ContentGenerationPipeline {
// mapping must leave it for the provider hook to translate.
if (
this.contentGeneratorConfig.samplingParams?.['reasoning'] === undefined &&
!isOpenRouterHostname(this.contentGeneratorConfig)
(!reasoningCapabilities?.profile ||
this.contentGeneratorConfig.extra_body?.['reasoning'] === undefined) &&
(reasoningCapabilities?.profile ||
!isOpenRouterHostname(this.contentGeneratorConfig))
) {
baseRequest = applyConfiguredReasoningEffort(
baseRequest,
@@ -1090,13 +1143,51 @@ export class ContentGenerationPipeline {
const explicitThinkingMandatory =
reasoningCapabilities?.canDisable === false ||
this.requiresThinking(model);
const profile = reasoningCapabilities?.profile;
const thinkingMandatory =
explicitThinkingMandatory ||
getGptReasoningCapabilities(model)?.thinkingMandatory === true;
(!profile &&
getGptReasoningCapabilities(model)?.thinkingMandatory === true);
const reasoningDisabled =
request.config?.thinkingConfig?.includeThoughts === false ||
this.contentGeneratorConfig.reasoning === false;
if (reasoningDisabled) {
if (
(profile === 'openai-effort' || profile === 'dashscope-effort') &&
isReasoningEffortPlaceholder(providerRequest.reasoning_effort) &&
effectiveReasoning &&
this.contentGeneratorConfig.samplingParams?.['reasoning'] === undefined &&
this.contentGeneratorConfig.extra_body?.['reasoning'] === undefined
)
providerRequest.reasoning_effort =
effectiveReasoning.effort as typeof providerRequest.reasoning_effort;
if (reasoningDisabled && profile) {
const typed = providerRequest as unknown as Record<string, unknown>;
if (
!thinkingMandatory ||
request.config?.thinkingConfig?.includeThoughts === false
) {
delete typed['reasoning'];
delete typed['reasoning_effort'];
}
if (!thinkingMandatory) {
if (isDashScope && profile === 'dashscope-effort') {
delete typed['thinking_budget'];
delete typed['enable_thinking'];
}
const template = asObject(typed['chat_template_kwargs']);
Object.assign(typed, profileReasoning(profile, false));
if (profile === 'qwen-chat-template') {
delete typed['enable_thinking'];
typed['chat_template_kwargs'] = {
...template,
enable_thinking: false,
};
} else if (reasoningCapabilities?.disableField === 'enable_thinking') {
delete typed['reasoning_effort'];
typed['enable_thinking'] = false;
}
}
} else if (reasoningDisabled) {
const typed = providerRequest as unknown as Record<string, unknown>;
// Provider buildRequest doesn't auto-inject `enable_thinking`, so a
// guarded `in typed` check would never fire for default qwen3 configs.
@@ -1461,7 +1552,14 @@ export class ContentGenerationPipeline {
return {};
}
const reasoning = this.contentGeneratorConfig.reasoning;
const reasoning = getEffectiveReasoning(
this.contentGeneratorConfig,
resolveReasoningForModel(
this.config.cliConfig,
this.contentGeneratorConfig,
request.model,
),
);
if (reasoning === false || reasoning === undefined) {
return {};
@@ -22,10 +22,8 @@ import {
isTieredEffortWireModel,
} from '../../modalityDefaults.js';
import type { ReasoningEffort } from '../../reasoning-effort.js';
import {
clampReasoningEffort,
parseModelReasoningCapabilities,
} from '../../reasoning-effort.js';
import { getEffectiveReasoning } from '../../reasoning-overrides.js';
import { clampReasoningEffort } from '../../reasoning-effort.js';
import { DefaultOpenAICompatibleProvider } from './default.js';
import { buildSessionAwareFetch } from '../../outbound-session-id.js';
@@ -540,19 +538,15 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
}
private getConfiguredReasoning(model: string | undefined) {
const { authType, baseUrl } = this.contentGeneratorConfig;
const wireModel = model ?? this.contentGeneratorConfig.model;
const reasoning = authType
? this.cliConfig.getResolvedModelConfig?.(authType, wireModel, baseUrl)
?.capabilities.reasoning
: undefined;
return parseModelReasoningCapabilities(reasoning);
return this.getReasoningCapabilities(model);
}
private isTieredEffortModel(model: string | undefined): boolean {
const configured = this.getConfiguredReasoning(model);
if (configured?.profile) return configured.profile === 'dashscope-effort';
return isTieredEffortWireModel(
model ?? this.contentGeneratorConfig.model,
this.getConfiguredReasoning(model),
configured,
);
}
@@ -572,7 +566,12 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
private buildQwenEffortConfig(
model: string | undefined,
): Record<string, unknown> {
const reasoning = this.contentGeneratorConfig.reasoning;
const configured = this.getConfiguredReasoning(model);
if (configured?.profile) return {};
const reasoning = getEffectiveReasoning(
this.contentGeneratorConfig,
configured,
);
if (!reasoning || reasoning.effort === undefined) {
return {};
}
@@ -95,7 +95,8 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro
protected override supportedReasoningEffortsFor(
model: string | undefined,
): readonly ReasoningEffort[] {
return isDeepSeekHostname(this.contentGeneratorConfig)
return !this.getReasoningCapabilities(model)?.profile &&
isDeepSeekHostname(this.contentGeneratorConfig)
? REASONING_EFFORT_TIERS
: super.supportedReasoningEffortsFor(model);
}
@@ -119,20 +120,29 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro
): OpenAI.Chat.ChatCompletionCreateParams {
const baseRequest = super.buildRequest(request, userPromptId);
const reshaped = isDeepSeekHostname(this.contentGeneratorConfig)
? translateReasoningEffort(baseRequest)
: baseRequest;
const profile = this.getReasoningCapabilities(request.model)?.profile;
const reshaped =
(!profile ||
this.contentGeneratorConfig.samplingParams?.['reasoning'] !==
undefined ||
this.contentGeneratorConfig.extra_body?.['reasoning'] !== undefined) &&
isDeepSeekHostname(this.contentGeneratorConfig)
? translateReasoningEffort(baseRequest)
: baseRequest;
if (!reshaped.messages?.length) {
return reshaped;
}
const messages = reshaped.messages.map((message) =>
ensureReasoningContentOnAssistantMessage(
this.contentGeneratorConfig.modalities?.image
? message
: flattenContentParts(message),
),
);
const messages = reshaped.messages.map((message) => {
const content = this.contentGeneratorConfig.modalities?.image
? message
: flattenContentParts(message);
return isDeepSeekHostname(this.contentGeneratorConfig) ||
!profile ||
profile === 'deepseek-openai'
? ensureReasoningContentOnAssistantMessage(content)
: content;
});
return {
...reshaped,
@@ -536,6 +536,29 @@ describe('DefaultOpenAICompatibleProvider', () => {
expect(result['reasoning']).toBeUndefined();
});
it('preserves generic clamping for a legacy non-GPT declaration', () => {
mockContentGeneratorConfig.authType =
'openai' as ContentGeneratorConfig['authType'];
mockCliConfig.getResolvedModelConfig = vi.fn().mockReturnValue({
capabilities: {
reasoning: {
thinking: true,
efforts: ['high', 'max'],
defaultEffort: 'high',
disableField: 'thinking',
},
},
});
const request = {
model: 'deepseek-v4-pro',
messages: [],
reasoning: { effort: 'low' },
} as unknown as OpenAI.Chat.ChatCompletionCreateParams;
expect(provider.buildRequest(request, 'prompt-id')).toMatchObject({
reasoning: { effort: 'low' },
});
});
it('keeps an unrecognized effort string as-is rather than rewriting it', () => {
const originalRequest = {
model: 'gpt-5.4',
@@ -17,10 +17,10 @@ import {
REASONING_EFFORT_TIERS,
clampReasoningEffort,
getGptReasoningCapabilities,
parseModelReasoningCapabilities,
isReasoningEffortPlaceholder,
} from '../../reasoning-effort.js';
import { isOpenRouterHostname } from './openrouter.js';
import { resolveReasoningForModel } from '../../reasoning-overrides.js';
import { createDebugLogger } from '../../../utils/debugLogger.js';
import { buildSessionAwareFetch } from '../../outbound-session-id.js';
@@ -148,19 +148,21 @@ export class DefaultOpenAICompatibleProvider
protected supportedReasoningEffortsFor(
model: string | undefined,
): readonly ReasoningEffort[] {
const gpt = getGptReasoningCapabilities(model);
if (!gpt) return OPENAI_COMPATIBLE_EFFORTS;
const { authType, baseUrl } = this.contentGeneratorConfig;
const configured =
authType && model
? parseModelReasoningCapabilities(
this.cliConfig.getResolvedModelConfig?.(authType, model, baseUrl)
?.capabilities.reasoning,
)
: undefined;
return configured && !configured.toggleOnly
? configured.efforts
: gpt.efforts;
const configured = this.getReasoningCapabilities(model);
if (!configured?.profile && !getGptReasoningCapabilities(model))
return OPENAI_COMPATIBLE_EFFORTS;
if (configured && !configured.toggleOnly) return configured.efforts;
return (
getGptReasoningCapabilities(model)?.efforts ?? OPENAI_COMPATIBLE_EFFORTS
);
}
protected getReasoningCapabilities(model?: string) {
return resolveReasoningForModel(
this.cliConfig,
this.contentGeneratorConfig,
model,
);
}
/**
@@ -176,7 +178,9 @@ export class DefaultOpenAICompatibleProvider
*/
protected clampConfiguredReasoningEffort<T extends object>(request: T): T {
if (
this.contentGeneratorConfig.samplingParams?.['reasoning'] !== undefined
this.contentGeneratorConfig.samplingParams?.['reasoning'] !== undefined ||
this.getReasoningCapabilities((request as { model?: string }).model)
?.profile
) {
return request;
}
@@ -223,9 +227,12 @@ export class DefaultOpenAICompatibleProvider
const requestWithTokenLimits = this.clampConfiguredReasoningEffort(
this.applyOutputTokenLimit(request),
);
const messages = isQwen3Model(request.model)
? requestWithTokenLimits.messages.map(mirrorReasoningContentToReasoning)
: requestWithTokenLimits.messages;
const profile = this.getReasoningCapabilities(request.model)?.profile;
const messages =
(!profile && isQwen3Model(request.model)) ||
profile === 'qwen-chat-template'
? requestWithTokenLimits.messages.map(mirrorReasoningContentToReasoning)
: requestWithTokenLimits.messages;
const result = {
...requestWithTokenLimits,
@@ -238,6 +245,8 @@ export class DefaultOpenAICompatibleProvider
protected flattenGptReasoningEffort(body: Record<string, unknown>): void {
if (
this.getReasoningCapabilities(body['model'] as string | undefined)
?.profile ||
!getGptReasoningCapabilities(body['model'] as string | undefined) ||
isOpenRouterHostname(this.contentGeneratorConfig) ||
this.contentGeneratorConfig.samplingParams?.['reasoning'] !== undefined ||
@@ -48,7 +48,11 @@ export class MiMoOpenAICompatibleProvider extends DefaultOpenAICompatibleProvide
userPromptId: string,
): OpenAI.Chat.ChatCompletionCreateParams {
const baseRequest = super.buildRequest(request, userPromptId);
if (!baseRequest.messages?.length) {
if (
(this.getReasoningCapabilities(request.model)?.profile &&
!isMiMoProvider({ ...this.contentGeneratorConfig, model: '' })) ||
!baseRequest.messages?.length
) {
return baseRequest;
}
@@ -69,6 +69,31 @@ describe('Mistral provider outbound compatibility filtering', () => {
).toBe('User asked for a short response.');
});
it('preserves declared DeepSeek history on a Mistral-named gateway alias', () => {
const config = createCliConfig();
config.getResolvedModelConfig = vi.fn().mockReturnValue({
capabilities: {
reasoning: {
profile: 'deepseek-openai',
efforts: ['high', 'max'],
defaultEffort: 'high',
},
},
});
const provider = determineProvider(
createProviderConfig({
model: 'mistral-large-latest',
baseUrl: 'https://gateway.example/v1',
authType: 'openai' as ContentGeneratorConfig['authType'],
}),
config,
);
const original = createReasoningRequest();
expect(provider.buildRequest(original, 'prompt').messages[1]).toEqual(
original.messages[1],
);
});
it('also strips reasoning_content when a Mistral model is served behind a custom base URL', () => {
const originalRequest = createReasoningRequest();
const provider = determineProvider(
@@ -54,6 +54,12 @@ export class MistralOpenAICompatibleProvider extends DefaultOpenAICompatibleProv
userPromptId: string,
): OpenAI.Chat.ChatCompletionCreateParams {
const baseRequest = super.buildRequest(request, userPromptId);
if (
!isMistralHostname(this.contentGeneratorConfig) &&
this.getReasoningCapabilities(request.model)?.profile ===
'deepseek-openai'
)
return baseRequest;
return {
...baseRequest,
@@ -395,6 +395,45 @@ describe('ResponsesPipeline', () => {
});
describe('reasoning request shape', () => {
it.each([undefined, { effort: 'high' }])(
'applies default below an existing raw override %j',
async (raw) => {
mockResponse(
sseEvent('response.completed', { response: { status: 'completed' } }),
);
const config = makeCliConfig();
config.getResolvedModelConfig = vi.fn().mockReturnValue({
capabilities: {
reasoning: {
profile: 'openai-reasoning',
efforts: ['low', 'medium', 'high'],
defaultEffort: 'medium',
},
},
});
const pipeline = new ResponsesPipeline(
makeGeneratorConfig({
model: 'company-alias',
authType: 'openai-responses' as ContentGeneratorConfig['authType'],
...(raw ? { extra_body: { reasoning: raw } } : {}),
}),
config,
);
for await (const _ of pipeline.executeStream(
{ ...textRequest('hi'), model: 'company-alias' },
'p1',
)) {
// drain
}
const body = JSON.parse(
fetchMock.mock.calls[0]![1].body,
) as ResponsesApiRequest;
expect(body.reasoning).toEqual(
raw ?? { effort: 'medium', summary: 'auto' },
);
},
);
it('passes the effort straight through with no clamping, plus include + summary auto', async () => {
mockResponse(
sseEvent('response.completed', { response: { status: 'completed' } }),
@@ -8,6 +8,10 @@ import { GenerateContentResponse } from '@google/genai';
import type { GenerateContentParameters } from '@google/genai';
import type { ContentGeneratorConfig } from '../contentGenerator.js';
import type { Config } from '../../config/config.js';
import {
getEffectiveReasoning,
resolveReasoningForModel,
} from '../reasoning-overrides.js';
import type {
ResponsesApiInputItem,
ResponsesApiRequest,
@@ -528,7 +532,18 @@ export class ResponsesPipeline {
if (request.config?.thinkingConfig?.includeThoughts === false) {
return undefined;
}
const r = this.config.reasoning;
const r =
this.config.reasoning === undefined &&
this.config.extra_body?.['reasoning'] !== undefined
? undefined
: getEffectiveReasoning(
this.config,
resolveReasoningForModel(
this.cliConfig,
this.config,
request.model,
),
);
if (r === false) return undefined;
// `extra_body.enable_thinking` is the DashScope/Qwen-specific on/off
// toggle (predates the unified reasoning-effort ladder). It has no
@@ -0,0 +1,577 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import {
AuthType,
type ContentGeneratorConfig,
type ContentGenerator,
} from './contentGenerator.js';
import { BaseLlmClient } from './baseLlmClient.js';
import { LlmClient, SendMessageType } from './client.js';
import { buildAgentContentGeneratorConfig } from '../models/content-generator-config.js';
import { Config } from '../config/config.js';
import {
captureReasoningSnapshot,
getEffectiveReasoning,
resolveReasoningCapabilities,
validateReasoningCapabilities,
resolveReasoningForModel,
} from './reasoning-overrides.js';
import type { AvailableModel } from '../models/types.js';
const route: ContentGeneratorConfig = {
model: 'alias',
authType: AuthType.USE_OPENAI,
baseUrl: 'https://one.example/v1',
};
const declaration = {
profile: 'openai-effort' as const,
efforts: ['low', 'medium', 'high'] as const,
defaultEffort: 'medium' as const,
};
const model = (
id = 'alias',
defaultEffort: 'low' | 'medium' | 'high' = 'medium',
): AvailableModel => ({
id,
label: id,
authType: AuthType.USE_OPENAI,
baseUrl: route.baseUrl,
registryBaseUrl: route.baseUrl,
capabilities: { reasoning: { ...declaration, defaultEffort } },
});
function snapshotConfig(
rows: AvailableModel[],
marker: string | null = route.baseUrl!,
) {
const generation = {
...route,
apiKey: 'dummy',
reasoningRouteBaseUrl: marker,
reasoningSnapshot: captureReasoningSnapshot(rows),
};
const config = Object.create(Config.prototype) as Config;
Object.assign(config, {
reasoningSnapshot: generation.reasoningSnapshot,
getAllConfiguredModels: () => rows,
getContentGeneratorConfig: () => generation,
getModel: () => route.model,
getFastModel: () => undefined,
getModelsConfig: () => ({
getResolvedModel: (_auth: string, id: string) => ({
...rows.find((row) => row.id === id),
generationConfig: {},
}),
}),
notifyModelChangeListeners: vi.fn(),
debugLogger: { error: vi.fn() },
});
return { config, generation };
}
describe('reasoning declarations', () => {
it('overrides a known default without persisting a user choice', () => {
const resolved = resolveReasoningCapabilities(
{
...route,
model: 'qwen3.8-max',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
{ defaultEffort: 'medium' },
);
expect(resolved).toMatchObject({
profile: 'dashscope-effort',
efforts: ['low', 'medium', 'xhigh'],
defaultEffort: 'medium',
});
expect(getEffectiveReasoning(route, resolved)).toEqual({
effort: 'medium',
});
expect(route.reasoning).toBeUndefined();
expect(
getEffectiveReasoning({ reasoning: { effort: 'high' } }, resolved),
).toEqual({ effort: 'xhigh' });
expect(getEffectiveReasoning({ reasoning: false }, resolved)).toBe(false);
});
it('inherits mandatory thinking while changing a known model format', () => {
const capability = resolveReasoningCapabilities(
{ ...route, model: 'gpt-5' },
declaration,
);
expect(capability?.canDisable).toBe(false);
expect(getEffectiveReasoning({ reasoning: false }, capability)).toEqual({
effort: 'medium',
});
});
it('inherits default-off until an explicit default is supplied', () => {
const known = { ...route, model: 'gpt-5.2' };
const override = { efforts: ['low', 'medium', 'high'] };
expect(
getEffectiveReasoning({}, resolveReasoningCapabilities(known, override)),
).toBe(false);
expect(
getEffectiveReasoning(
{},
resolveReasoningCapabilities(known, {
...override,
defaultEffort: 'medium',
}),
),
).toEqual({ effort: 'medium' });
});
it('preserves undeclared and malformed legacy inputs', () => {
expect(resolveReasoningCapabilities(route, undefined)).toBeUndefined();
expect(
resolveReasoningCapabilities(route, { thinking: true }),
).toBeUndefined();
const reasoning = { effort: 'high' as const, budget_tokens: 5000 };
expect(getEffectiveReasoning({ reasoning })).toBe(reasoning);
});
it.each([
{},
{ defaultEffort: 'medium' },
{ ...declaration, profile: 'unknown' },
{ ...declaration, efforts: [] },
{ ...declaration, efforts: 'medium' },
{ ...declaration, defaultEffort: null },
{ ...declaration, efforts: ['low', 'low'] },
{ ...declaration, defaultEffort: 'max' },
{ ...declaration, profile: 'gemini' },
{ profile: 'dashscope-thinking', defaultEffort: 'medium' },
])('rejects an invalid declaration %j', (value) => {
expect(resolveReasoningCapabilities(route, value)).toBeUndefined();
expect(() => validateReasoningCapabilities(route, value)).toThrow(
'capabilities.reasoning',
);
});
it('clamps an inherited default when replacing the tier set', () => {
expect(
resolveReasoningCapabilities(
{ ...route, model: 'gpt-5.5' },
{ efforts: ['low', 'high'] },
),
).toMatchObject({ defaultEffort: 'high' });
});
it.each([
'claude-opus-4-6',
'us.anthropic.claude-opus-4-6-v1:0',
'anthropic.claude-opus-4.6',
])('inherits native Claude tiers for %s', (model) => {
expect(
resolveReasoningCapabilities(
{
...route,
model,
authType: AuthType.USE_ANTHROPIC,
},
{ defaultEffort: 'medium' },
),
).toMatchObject({
profile: 'anthropic-adaptive',
efforts: ['low', 'medium', 'high', 'max'],
defaultEffort: 'medium',
});
});
it.each([
['qwen3.5-plus', 'https://coding.dashscope.aliyuncs.com/v1'],
['qwen3-max-2026-01-23', 'https://coding.dashscope.aliyuncs.com/v1'],
['kimi-k2.5', 'https://coding.dashscope.aliyuncs.com/v1'],
['kimi-k2.6', 'https://api.moonshot.ai/v1'],
[
'qwen3.6-flash',
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
],
[
'deepseek-v3.2',
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
],
])('inherits %s toggle capabilities from its provider', (model, baseUrl) => {
const target = { ...route, model, baseUrl };
expect(
resolveReasoningCapabilities(target, { canDisable: false }),
).toMatchObject({
disableField: baseUrl.includes('moonshot')
? 'thinking'
: 'enable_thinking',
toggleOnly: true,
canDisable: false,
});
expect(
resolveReasoningCapabilities(target, { defaultEffort: 'medium' }),
).toBeUndefined();
});
it('inherits the selected provider policy for the same model', () => {
const target = { ...route, model: 'qwen3.8-max-preview' };
const input = { defaultEffort: 'medium' };
expect(
resolveReasoningCapabilities(
{
...target,
baseUrl:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
},
input,
),
).toMatchObject({ defaultEffort: 'medium', canDisable: false });
expect(
resolveReasoningCapabilities(
{
...target,
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
input,
)?.canDisable,
).toBeUndefined();
});
it.each(['openai/gpt-5.4', 'openai/gpt-5.4:batch'])(
'infers nested reasoning for %s',
(model) => {
expect(
resolveReasoningCapabilities(
{
...route,
model,
baseUrl: 'https://openrouter.ai/api/v1',
},
{ defaultEffort: 'medium' },
),
).toMatchObject({ profile: 'openai-reasoning', defaultEffort: 'medium' });
},
);
it('ignores misleading Qwen hosts during inference', () => {
expect(
resolveReasoningCapabilities(
{
...route,
model: 'qwen3.8-max',
baseUrl: 'https://evil.example/dashscope.aliyuncs.com',
},
{ defaultEffort: 'medium' },
),
).toBeUndefined();
});
});
describe('prompt reasoning snapshots', () => {
it('retains staged reasoning across unmatched route edits and tolerates malformed entries', () => {
const { config, generation } = snapshotConfig([model()]);
config.stageReasoningOverrides({ openai: [model('alias', 'high')] });
config.stageReasoningOverrides({ openai: [null] } as unknown as Parameters<
Config['stageReasoningOverrides']
>[0]);
config.applyReasoningOverrides();
expect(resolveReasoningForModel(config, generation)).toMatchObject({
defaultEffort: 'high',
});
config.stageReasoningOverrides({
openai: [{ id: 'alias', baseUrl: route.baseUrl }],
});
config.applyReasoningOverrides();
expect(resolveReasoningForModel(config, generation)).toBeUndefined();
});
it('refreshes cached side-model views only on adoption while existing views retain their table', async () => {
const { config, generation } = snapshotConfig([
model(),
model('child', 'low'),
]);
const client = new BaseLlmClient({} as ContentGenerator, config);
Object.assign(config, { baseLlmClient: client });
const first = await client.resolveForModel('child', { failClosed: true });
config.stageReasoningOverrides({ openai: [model('child', 'high')] });
config.applyReasoningOverrides();
const second = await client.resolveForModel('child', { failClosed: true });
expect(
resolveReasoningForModel(config, first.contentGeneratorConfig),
).toMatchObject({ defaultEffort: 'low' });
expect(second.contentGeneratorConfig.reasoningSnapshot).toBe(
generation.reasoningSnapshot,
);
expect(
resolveReasoningForModel(config, second.contentGeneratorConfig),
).toMatchObject({ defaultEffort: 'high' });
expect(config.applyReasoningOverrides()).toBe(false);
expect(
(await client.resolveForModel('child', { failClosed: true }))
.contentGenerator,
).toBe(second.contentGenerator);
});
it.each([
[false, false, 'high'],
[true, false, 'medium'],
[false, true, 'medium'],
] as const)(
'owns promotion only for a primary user prompt (concurrent=%s)',
async (isConcurrentSideQuery, refused, expected) => {
const { config, generation } = snapshotConfig([model()]);
config.stageReasoningOverrides({ openai: [model('alias', 'high')] });
const cutoff = new Error('post-admission cutoff');
Object.assign(config, {
assertCanStartTurn: () => {
if (refused) throw cutoff;
},
getTelemetryIncludeSensitiveSpanAttributes: () => false,
});
const client = Object.create(LlmClient.prototype) as LlmClient;
Object.assign(client, { config });
const stream = client.sendMessageStream(
'probe',
new AbortController().signal,
'probe',
{
type: SendMessageType.UserQuery,
isConcurrentSideQuery,
get goalSignal(): never {
throw cutoff;
},
},
);
await expect(stream.next()).rejects.toBe(cutoff);
expect(resolveReasoningForModel(config, generation)).toMatchObject({
defaultEffort: expected,
});
},
);
it.each([undefined, route.baseUrl])(
'preserves the inherited implicit route with endpoint %s',
(baseUrl) => {
const { config } = snapshotConfig(
[
{ ...model('alias', 'low'), registryBaseUrl: undefined },
model('alias', 'high'),
],
null,
);
const child = buildAgentContentGeneratorConfig(config, undefined, {
baseUrl,
authType: AuthType.USE_OPENAI,
});
expect(child.reasoningRouteBaseUrl).toBeNull();
expect(resolveReasoningForModel(config, child)).toMatchObject({
defaultEffort: 'low',
});
},
);
it('degrades invalid initial declarations without losing healthy routes', () => {
const invalid = model('invalid');
invalid.capabilities = { reasoning: { ...declaration, efforts: ['low'] } };
const config = Object.create(Config.prototype) as Config;
Object.assign(config, { getAllConfiguredModels: () => [invalid, model()] });
const generation = {
...route,
reasoningSnapshot: config.getReasoningSnapshot(),
};
expect(
resolveReasoningForModel(config, generation, 'invalid'),
).toBeUndefined();
expect(resolveReasoningForModel(config, generation)).toMatchObject({
defaultEffort: 'medium',
});
});
it('uses the captured capability for existing DashScope override controls', () => {
const config = Object.create(Config.prototype) as Config;
const generation = {
...route,
model: 'qwen3.8-max',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
reasoningRouteBaseUrl: route.baseUrl,
extra_body: { reasoning_effort: 'high' },
reasoningSnapshot: captureReasoningSnapshot([model('qwen3.8-max')]),
};
Object.assign(config, {
getContentGeneratorConfig: () => generation,
getReasoningEffort: () => undefined,
getResolvedModelConfig: () => {
throw new Error('must not read live reasoning');
},
});
expect(config.getReasoningEffortOverride()).toEqual({
source: 'extra_body',
field: 'reasoning_effort',
});
});
it('is immutable, exact-route scoped, and never falls through to a changed registry', () => {
const models = [
model(),
{
...model('alias', 'high'),
baseUrl: 'https://two.example/v1',
registryBaseUrl: 'https://two.example/v1',
},
];
const snapshot = captureReasoningSnapshot(models);
expect(
resolveReasoningForModel(
undefined,
{
...route,
model: 'parent',
thinkingMandatory: true,
reasoningSnapshot: snapshot,
},
'alias',
)?.canDisable,
).toBeUndefined();
models[0]!.capabilities = {
reasoning: { ...declaration, defaultEffort: 'low' },
};
const getResolvedModelConfig = vi.fn();
const config = { getResolvedModelConfig };
expect(
resolveReasoningForModel(config, {
...route,
reasoningSnapshot: snapshot,
}),
).toMatchObject({ defaultEffort: 'medium' });
expect(
resolveReasoningForModel(config, {
...route,
baseUrl: 'https://two.example/v1',
reasoningSnapshot: snapshot,
}),
).toMatchObject({ defaultEffort: 'high' });
expect(
resolveReasoningForModel(config, {
...route,
baseUrl: 'https://third.example/v1',
reasoningSnapshot: snapshot,
}),
).toBeUndefined();
expect(
resolveReasoningForModel(
config,
{ ...route, reasoningSnapshot: snapshot },
'missing-child',
),
).toBeUndefined();
expect(getResolvedModelConfig).not.toHaveBeenCalled();
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot[0]!.reasoning!.efforts)).toBe(true);
});
it('distinguishes implicit and explicit entries sharing the default endpoint', () => {
const snapshot = captureReasoningSnapshot([
{ ...model('alias', 'low'), registryBaseUrl: undefined },
model('alias', 'high'),
]);
expect(
resolveReasoningForModel(undefined, {
...route,
reasoningSnapshot: snapshot,
reasoningRouteBaseUrl: null,
}),
).toMatchObject({ defaultEffort: 'low' });
expect(
resolveReasoningForModel(undefined, {
...route,
reasoningSnapshot: snapshot,
reasoningRouteBaseUrl: route.baseUrl,
}),
).toMatchObject({ defaultEffort: 'high' });
});
it('adopts the latest table only at admission and retains a child table', () => {
let models = [model(), model('child', 'low')];
const generation = {
...route,
reasoningSnapshot: captureReasoningSnapshot(models),
};
const child = { ...generation, model: 'child' };
const config = Object.create(Config.prototype) as Config;
Object.assign(config, {
reasoningSnapshot: generation.reasoningSnapshot,
getAllConfiguredModels: () => models,
getContentGeneratorConfig: () => generation,
notifyModelChangeListeners: vi.fn(),
debugLogger: { error: vi.fn() },
});
models = [model('alias', 'low'), model('child', 'medium')];
models = [model('alias', 'high'), model('child', 'high')];
expect(resolveReasoningForModel(config, generation)).toMatchObject({
defaultEffort: 'medium',
});
expect(config.applyReasoningOverrides()).toBe(true);
expect(resolveReasoningForModel(config, generation)).toMatchObject({
defaultEffort: 'high',
});
expect(resolveReasoningForModel(config, child)).toMatchObject({
defaultEffort: 'low',
});
});
it.each([false, true, 'unknown'])(
'retains a valid observed update after invalid input (inactive implicit route: %s)',
(inactive) => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
let models = [model('alias', 'low')];
const generation = {
...route,
reasoningSnapshot: captureReasoningSnapshot(models),
};
const config = Object.create(Config.prototype) as Config;
Object.assign(config, {
reasoningSnapshot: generation.reasoningSnapshot,
getAllConfiguredModels: () => models,
getContentGeneratorConfig: () => generation,
modelsConfig: { reloadModelProvidersConfig: vi.fn() },
notifyModelChangeListeners: vi.fn(),
debugLogger: { error: vi.fn() },
});
models = [model('alias', 'high')];
config.reloadModelProvidersConfig();
if (inactive) {
models.push({
...model('child'),
registryBaseUrl: undefined,
capabilities: {
reasoning:
inactive === 'unknown'
? { defaultEffort: 'medium' }
: { efforts: ['low'], defaultEffort: 'high' },
},
});
} else {
models[0]!.capabilities = {
reasoning: {
...declaration,
efforts: ['low'],
defaultEffort: 'high',
},
};
}
config.reloadModelProvidersConfig();
expect(resolveReasoningForModel(config, generation)).toMatchObject({
defaultEffort: 'low',
});
expect(config.applyReasoningOverrides()).toBe(true);
expect(resolveReasoningForModel(config, generation)).toMatchObject({
defaultEffort: 'high',
});
expect(
config.getContentGeneratorConfig()?.reasoningSnapshot,
).toHaveLength(1);
expect(warning).toHaveBeenCalledWith(
expect.stringContaining('capabilities.reasoning'),
);
warning.mockRestore();
},
);
});
@@ -0,0 +1,386 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import type { ContentGeneratorConfig } from './contentGenerator.js';
import type {
AvailableModel,
ModelReasoningCapabilities,
} from '../models/types.js';
import { ALL_PROVIDERS } from '../providers/all-providers.js';
import { normalize } from './tokenLimits.js';
import { isOpenRouterHostname } from './openaiContentGenerator/provider/openrouter.js';
import {
parseClaudeModelVersion,
anthropicSupportedEffortTiers,
} from './anthropic-reasoning.js';
import {
clampReasoningEffort,
getGptReasoningCapabilities,
parseModelReasoningCapabilities,
REASONING_EFFORT_TIERS,
type ReasoningEffort,
} from './reasoning-effort.js';
export const REASONING_PROFILES = [
'openai-effort',
'openai-reasoning',
'deepseek-openai',
'dashscope-effort',
'dashscope-thinking',
'qwen-chat-template',
'anthropic-manual',
'anthropic-adaptive',
'deepseek-anthropic',
'gemini',
] as const;
export type ReasoningProfile = (typeof REASONING_PROFILES)[number];
export type ModelReasoningOverride = {
profile?: ReasoningProfile;
efforts?: readonly ReasoningEffort[];
defaultEffort?: ReasoningEffort;
thinking?: true;
toggleOnly?: boolean;
canDisable?: false;
disableField?: ModelReasoningCapabilities['disableField'];
};
export type ResolvedReasoning = ModelReasoningCapabilities & {
profile?: ReasoningProfile;
defaultEnabled?: boolean;
};
export type ReasoningSnapshot = ReadonlyArray<
Readonly<
Pick<AvailableModel, 'id' | 'authType' | 'baseUrl' | 'registryBaseUrl'> & {
reasoning: ModelReasoningOverride | undefined;
}
>
>;
type Route = Pick<
ContentGeneratorConfig,
'model' | 'authType' | 'baseUrl' | 'thinkingMandatory'
>;
function invalidReasoning(route: Route): never {
throw new Error(
`Model "${route.model}" capabilities.reasoning: invalid profile, efforts or defaultEffort`,
);
}
export function validateReasoningDeclaration(
route: Route,
declaration: unknown,
): ModelReasoningOverride | undefined {
if (declaration === undefined) return undefined;
if (
!declaration ||
typeof declaration !== 'object' ||
Array.isArray(declaration)
)
return invalidReasoning(route);
const input = declaration as ModelReasoningOverride;
if (
input.profile === undefined &&
('thinking' in input || 'disableField' in input)
)
return parseModelReasoningCapabilities(input);
if (
!Object.keys(input).length ||
Object.keys(input).some(
(key) =>
![
'profile',
'efforts',
'defaultEffort',
'thinking',
'toggleOnly',
'canDisable',
'disableField',
].includes(key),
)
)
return invalidReasoning(route);
if (
(input.profile !== undefined &&
!REASONING_PROFILES.includes(input.profile)) ||
(input.thinking !== undefined && input.thinking !== true) ||
(input.toggleOnly !== undefined && typeof input.toggleOnly !== 'boolean') ||
(input.canDisable !== undefined && input.canDisable !== false)
)
return invalidReasoning(route);
if (
input.efforts !== undefined &&
(!Array.isArray(input.efforts) ||
!input.efforts.length ||
input.efforts.some((tier) => !REASONING_EFFORT_TIERS.includes(tier)) ||
new Set(input.efforts).size !== input.efforts.length)
)
return invalidReasoning(route);
if (
input.defaultEffort !== undefined &&
(!REASONING_EFFORT_TIERS.includes(input.defaultEffort) ||
(input.efforts && !input.efforts.includes(input.defaultEffort)))
)
return invalidReasoning(route);
return input;
}
export function resolveReasoningCapabilities(
route: Route,
declaration: unknown,
): ResolvedReasoning | undefined {
try {
return validateReasoningCapabilities(route, declaration);
} catch {
return undefined;
}
}
export function validateReasoningCapabilities(
route: Route,
declaration: unknown,
): ResolvedReasoning | undefined {
const input = validateReasoningDeclaration(route, declaration);
if (!input) return undefined;
if (input.profile === undefined && 'thinking' in input)
return parseModelReasoningCapabilities(input);
const model = normalize(route.model);
const provider = ALL_PROVIDERS.find((candidate) =>
typeof candidate.baseUrl === 'string'
? candidate.baseUrl === route.baseUrl
: candidate.baseUrl?.some((option) => option.url === route.baseUrl),
);
const known = (
provider?.models?.find((candidate) => candidate.id === route.model) ??
provider?.models?.find((candidate) => candidate.id === model)
)?.capabilities?.reasoning;
const inherited = parseModelReasoningCapabilities(known);
if (!input.profile && inherited?.toggleOnly) {
if (input.efforts || input.defaultEffort) return invalidReasoning(route);
return parseModelReasoningCapabilities({ ...inherited, ...input });
}
const gpt = getGptReasoningCapabilities(route.model);
const claude =
route.authType === 'anthropic'
? parseClaudeModelVersion(route.model)
: undefined;
if (!inherited && !gpt && !claude && !input.profile)
return invalidReasoning(route);
const auth = route.authType;
let host = '';
try {
host = new URL(route.baseUrl ?? '').hostname.toLowerCase();
} catch {
host = '';
}
const dashscope =
auth === 'qwen-oauth' ||
/(^|\.)(dashscope(?:-intl|-us)?\.aliyuncs\.com|alibaba-inc\.com|aliyun-inc\.com|alicloudapi\.com)$/.test(
host,
) ||
/^token-plan\..+\.maas\.aliyuncs\.com$/.test(host);
const profile =
input.profile ??
(auth === 'gemini' || auth === 'vertex-ai'
? 'gemini'
: auth === 'openai-responses'
? 'openai-reasoning'
: auth === 'openai' && isOpenRouterHostname(route)
? 'openai-reasoning'
: auth === 'anthropic'
? model.includes('deepseek')
? 'deepseek-anthropic'
: claude &&
(claude.major > 4 ||
(claude.major === 4 && claude.minor >= 6))
? 'anthropic-adaptive'
: 'anthropic-manual'
: model.startsWith('qwen')
? dashscope
? inherited?.disableField === 'reasoning_effort'
? 'dashscope-effort'
: 'dashscope-thinking'
: 'qwen-chat-template'
: inherited?.disableField === 'thinking'
? 'deepseek-openai'
: 'openai-effort');
const protocol =
profile === 'gemini'
? 'gemini'
: profile.startsWith('anthropic-') || profile === 'deepseek-anthropic'
? 'anthropic'
: 'openai';
if (
(auth === 'openai-responses' && profile !== 'openai-reasoning') ||
(auth &&
protocol !==
(auth === 'vertex-ai'
? 'gemini'
: auth === 'qwen-oauth' || auth === 'openai-responses'
? 'openai'
: auth))
)
return invalidReasoning(route);
const toggleOnly =
profile === 'dashscope-thinking' || profile === 'qwen-chat-template';
const disableField =
(!input.profile && inherited?.disableField) ||
(toggleOnly
? 'enable_thinking'
: profile === 'openai-effort' || profile === 'dashscope-effort'
? 'reasoning_effort'
: 'thinking');
const inheritedEfforts =
inherited && !inherited.toggleOnly
? inherited.efforts
: (gpt?.efforts ??
(claude ? anthropicSupportedEffortTiers(route.model) : undefined));
const efforts = input.efforts ?? inheritedEfforts;
const inheritedDefault =
inherited && !inherited.toggleOnly
? inherited.defaultEffort
: gpt?.defaultEffort;
const defaultEffort =
input.defaultEffort ??
(inheritedDefault && efforts
? clampReasoningEffort(inheritedDefault, efforts)
: undefined);
if (
(toggleOnly &&
(input.efforts !== undefined || input.defaultEffort !== undefined)) ||
(!toggleOnly && (!efforts || !defaultEffort)) ||
(profile === 'gemini' &&
efforts?.some((tier) => tier === 'xhigh' || tier === 'max'))
)
return invalidReasoning(route);
const result = {
...input,
thinking: true,
profile,
toggleOnly,
disableField,
...(toggleOnly ? {} : { efforts, defaultEffort }),
...(route.thinkingMandatory ||
inherited?.canDisable === false ||
gpt?.thinkingMandatory
? { canDisable: false }
: {}),
};
const parsed = parseModelReasoningCapabilities(result);
if (!parsed) return invalidReasoning(route);
return {
...parsed,
profile,
...(gpt && input.defaultEffort === undefined
? { defaultEnabled: gpt.defaultEnabled }
: {}),
};
}
export function captureReasoningSnapshot(
models: readonly AvailableModel[],
): ReasoningSnapshot {
return Object.freeze(
models.map((model) =>
Object.freeze({
id: model.id,
authType: model.authType,
baseUrl: model.baseUrl,
registryBaseUrl: model.registryBaseUrl,
reasoning:
model.capabilities?.reasoning &&
typeof model.capabilities.reasoning === 'object'
? Object.freeze({
...structuredClone(model.capabilities.reasoning),
...('efforts' in model.capabilities.reasoning &&
Array.isArray(model.capabilities.reasoning.efforts)
? {
efforts: Object.freeze([
...model.capabilities.reasoning.efforts,
]),
}
: {}),
})
: model.capabilities?.reasoning,
}),
),
);
}
export function resolveReasoningForModel(
config: Pick<Config, 'getResolvedModelConfig'> | undefined,
generation: ContentGeneratorConfig,
model = generation.model,
strict = false,
): ResolvedReasoning | undefined {
let declaration: unknown;
if (generation.reasoningSnapshot) {
const rows = generation.reasoningSnapshot.filter(
(row) => row.authType === generation.authType && row.id === model,
);
const row =
generation.reasoningRouteBaseUrl === null && model === generation.model
? rows.find((row) => row.registryBaseUrl === undefined)
: typeof generation.reasoningRouteBaseUrl === 'string' &&
model === generation.model
? rows.find(
(row) => row.registryBaseUrl === generation.reasoningRouteBaseUrl,
)
: (rows.find((row) => row.registryBaseUrl === generation.baseUrl) ??
rows.find(
(row) =>
row.registryBaseUrl === undefined &&
row.baseUrl === generation.baseUrl,
));
declaration = row?.reasoning;
} else {
declaration = generation.authType
? config?.getResolvedModelConfig?.(
generation.authType,
model,
generation.baseUrl,
)?.capabilities?.reasoning
: undefined;
}
return (
strict ? validateReasoningCapabilities : resolveReasoningCapabilities
)(
{
...generation,
model,
thinkingMandatory:
model === generation.model ? generation.thinkingMandatory : undefined,
},
declaration,
);
}
export function getEffectiveReasoning(
generation: Pick<ContentGeneratorConfig, 'reasoning'>,
resolved?: ResolvedReasoning,
): ContentGeneratorConfig['reasoning'] {
if (!resolved) return generation.reasoning;
if (generation.reasoning === false && resolved.canDisable !== false)
return false;
const selected = generation.reasoning || undefined;
if (
!selected &&
resolved.defaultEnabled === false &&
resolved.canDisable !== false
)
return false;
if (resolved.toggleOnly)
return selected ?? (resolved.profile ? {} : undefined);
const effort = selected?.effort ?? resolved.defaultEffort;
return effort
? {
...selected,
effort:
resolved.profile && REASONING_EFFORT_TIERS.includes(effort)
? clampReasoningEffort(effort, resolved.efforts)
: effort,
}
: selected;
}
@@ -491,8 +491,8 @@ describe('buildAgentContentGeneratorConfig', () => {
const config = createMockConfig(parentConfig, {
capabilities: {
reasoning: {
thinking: true,
disableField: 'reasoning_effort',
profile: 'openai-effort',
defaultEffort: 'high',
efforts: ['low', 'medium', 'high'],
},
},
@@ -13,6 +13,10 @@
*/
import type { Config } from '../config/config.js';
import {
resolveReasoningForModel,
resolveReasoningCapabilities,
} from '../core/reasoning-overrides.js';
import {
createContentGenerator,
type AuthType,
@@ -27,7 +31,6 @@ import type { ResolvedModelConfig } from './types.js';
import {
clampReasoningEffort,
getGptReasoningCapabilities,
parseModelReasoningCapabilities,
REASONING_EFFORT_TIERS,
reasoningEffortsForCapability,
setGeneratorReasoningEffort,
@@ -145,6 +148,17 @@ function offeredReasoningEfforts(
base: Config,
target: Pick<ContentGeneratorConfig, 'authType' | 'model' | 'baseUrl'>,
): readonly ReasoningEffort[] {
if ('reasoningSnapshot' in target && target.reasoningSnapshot) {
const resolved = resolveReasoningForModel(
base,
target as ContentGeneratorConfig,
);
if (resolved) return reasoningEffortsForCapability(resolved);
return (
getGptReasoningCapabilities(target.model)?.efforts ??
REASONING_EFFORT_TIERS
);
}
if (target.authType && target.model) {
const models = base.getModelsConfig();
// Exact id + baseUrl first, then any entry with the same id: a gateway or
@@ -154,7 +168,8 @@ function offeredReasoningEfforts(
(target.baseUrl !== undefined
? models.getResolvedModel(target.authType, target.model, target.baseUrl)
: undefined) ?? models.getResolvedModel(target.authType, target.model);
const declared = parseModelReasoningCapabilities(
const declared = resolveReasoningCapabilities(
target,
resolved?.capabilities?.reasoning,
);
if (declared) return reasoningEffortsForCapability(declared);
@@ -256,6 +271,13 @@ function buildInheritedAgentContentGeneratorConfig(
authOverrides.authType,
'apiKey',
);
if (
!sameProvider ||
nextConfig.model !== parentConfig.model ||
(authOverrides.baseUrl !== undefined &&
authOverrides.baseUrl !== parentConfig.baseUrl)
)
nextConfig.reasoningRouteBaseUrl = authOverrides.baseUrl;
nextConfig.baseUrl =
authOverrides.baseUrl ??
resolveCredentialField(
@@ -321,6 +343,7 @@ function applyResolvedModelConfig(
(resolvedModel.baseUrl === parentConfig.baseUrl &&
resolvedModel.envKey === parentConfig.apiKeyEnvKey));
if (!inheritCredentials) targetConfig.customHeaders = undefined;
targetConfig.reasoningRouteBaseUrl = resolvedModel.registryBaseUrl ?? null;
targetConfig.model = resolvedModel.id;
targetConfig.authType = resolvedModel.authType;
targetConfig.baseUrl =
+2 -1
View File
@@ -10,6 +10,7 @@ import type {
InputModalities,
} from '../core/contentGenerator.js';
import type { ConfigSources } from '../utils/configResolver.js';
import type { ModelReasoningOverride } from '../core/reasoning-overrides.js';
import type { ReasoningEffort } from '../core/reasoning-effort.js';
export type ModelReasoningCapabilities = (
@@ -34,7 +35,7 @@ export interface ModelCapabilities {
/** Can run the normal agent tool loop, not only transcription requests. */
agent?: boolean;
/** Declarative reasoning controls and wire behavior for this model route. */
reasoning?: ModelReasoningCapabilities;
reasoning?: ModelReasoningCapabilities | ModelReasoningOverride;
}
/**