mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user