diff --git a/docs/design/external-reasoning-overrides-v2.md b/docs/design/external-reasoning-overrides-v2.md new file mode 100644 index 00000000000..e50104f1ed0 --- /dev/null +++ b/docs/design/external-reasoning-overrides-v2.md @@ -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. diff --git a/docs/design/external-reasoning-overrides-v2.zh-CN.md b/docs/design/external-reasoning-overrides-v2.zh-CN.md new file mode 100644 index 00000000000..305851719e7 --- /dev/null +++ b/docs/design/external-reasoning-overrides-v2.zh-CN.md @@ -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 报告和行数分类统计。测试通过不能授权范围扩张。本地请求与浏览器 +验收使用受控端点,不代表已验证真实提供方接受所有配置。 diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index 3b70c3ee3ba..9450fe4a65b 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -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. diff --git a/integration-tests/reasoning-overrides.mjs b/integration-tests/reasoning-overrides.mjs new file mode 100644 index 00000000000..5b6aa391d6a --- /dev/null +++ b/integration-tests/reasoning-overrides.mjs @@ -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); +} diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 20d942f0f3a..0162c983161 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -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, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 57568281e4f..b4c79e7e76e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -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) { diff --git a/packages/cli/src/acp-integration/model-configuration.test.ts b/packages/cli/src/acp-integration/model-configuration.test.ts index 128d2233b66..f35ee63d2f5 100644 --- a/packages/cli/src/acp-integration/model-configuration.test.ts +++ b/packages/cli/src/acp-integration/model-configuration.test.ts @@ -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'); + }, + ); +}); diff --git a/packages/cli/src/acp-integration/model-configuration.ts b/packages/cli/src/acp-integration/model-configuration.ts index edce02cbed7..8d5172251b5 100644 --- a/packages/cli/src/acp-integration/model-configuration.ts +++ b/packages/cli/src/acp-integration/model-configuration.ts @@ -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 @@ -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); +} diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index c5313bc265a..3d0f56f3016 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -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 }) + ._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, diff --git a/packages/cli/src/serve/workspace-providers-status.test.ts b/packages/cli/src/serve/workspace-providers-status.test.ts index d6bde1094a0..e7d3664b807 100644 --- a/packages/cli/src/serve/workspace-providers-status.test.ts +++ b/packages/cli/src/serve/workspace-providers-status.test.ts @@ -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({ diff --git a/packages/cli/src/serve/workspace-providers-status.ts b/packages/cli/src/serve/workspace-providers-status.ts index 859b81f01ff..9bd96f4995d 100644 --- a/packages/cli/src/serve/workspace-providers-status.ts +++ b/packages/cli/src/serve/workspace-providers-status.ts @@ -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, diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 9387ba22029..e01a165d114 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -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, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5e66f9efaf7..ad0554ddd1d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; diff --git a/packages/core/src/core/anthropic-reasoning.ts b/packages/core/src/core/anthropic-reasoning.ts new file mode 100644 index 00000000000..d34206c5dd4 --- /dev/null +++ b/packages/core/src/core/anthropic-reasoning.ts @@ -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; +} diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index aa501c2091f..80a2f2ce026 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -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/ (external, cli)` diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 96976d4fa8b..84d7fa158d9 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -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 diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index a192c31f62a..4e3006ea177 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -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; diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 792a41b8886..57758b461da 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -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 = { ...(generationConfig || {}), authType, + reasoningSnapshot: + generationConfig?.reasoningSnapshot ?? config?.getReasoningSnapshot?.(), + reasoningRouteBaseUrl: + generationConfig && 'reasoningRouteBaseUrl' in generationConfig + ? generationConfig.reasoningRouteBaseUrl + : config?.getCurrentModelRegistryBaseUrl?.(), proxy: config?.getProxy(), }; diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 892e142ffa1..3c0d6d8ec95 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -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(); diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index 5f02b1a7936..bec405d89e2 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -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 }; diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index ffd81def3db..9b06b8d15b2 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -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], diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 18d124265b0..38f2914e11c 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -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 | undefined { return value as Record; } +function profileReasoning( + profile: NonNullable, + reasoning: ContentGeneratorConfig['reasoning'], +): Record { + 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; + 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 { - 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; + 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; // 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 {}; diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 3f105aff9ef..ca8d4c6ae0b 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -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 { - 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 {}; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts index 827e3bb8312..0ef97ab461d 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts @@ -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, diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index 46361cfade2..3c7f9ce1b22 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -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', diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index a4cc5da8844..c671c251a2e 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -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(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): void { if ( + this.getReasoningCapabilities(body['model'] as string | undefined) + ?.profile || !getGptReasoningCapabilities(body['model'] as string | undefined) || isOpenRouterHostname(this.contentGeneratorConfig) || this.contentGeneratorConfig.samplingParams?.['reasoning'] !== undefined || diff --git a/packages/core/src/core/openaiContentGenerator/provider/mimo.ts b/packages/core/src/core/openaiContentGenerator/provider/mimo.ts index f369ca95099..a0803e19bd2 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/mimo.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/mimo.ts @@ -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; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts b/packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts index 47910b6b586..97bfa06c18b 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/mistral.test.ts @@ -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( diff --git a/packages/core/src/core/openaiContentGenerator/provider/mistral.ts b/packages/core/src/core/openaiContentGenerator/provider/mistral.ts index dcfe01c51fd..88782546896 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/mistral.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/mistral.ts @@ -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, diff --git a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts index 9c7a58b1318..e3cb706ad49 100644 --- a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts +++ b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts @@ -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' } }), diff --git a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts index 694b44c58c2..939621564ca 100644 --- a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts +++ b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts @@ -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 diff --git a/packages/core/src/core/reasoning-overrides.test.ts b/packages/core/src/core/reasoning-overrides.test.ts new file mode 100644 index 00000000000..283a8867970 --- /dev/null +++ b/packages/core/src/core/reasoning-overrides.test.ts @@ -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(); + }, + ); +}); diff --git a/packages/core/src/core/reasoning-overrides.ts b/packages/core/src/core/reasoning-overrides.ts new file mode 100644 index 00000000000..4be5bf58407 --- /dev/null +++ b/packages/core/src/core/reasoning-overrides.ts @@ -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 & { + 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 | 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, + 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; +} diff --git a/packages/core/src/models/content-generator-config.test.ts b/packages/core/src/models/content-generator-config.test.ts index 3dd3a908b2e..1c5309e80c4 100644 --- a/packages/core/src/models/content-generator-config.test.ts +++ b/packages/core/src/models/content-generator-config.test.ts @@ -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'], }, }, diff --git a/packages/core/src/models/content-generator-config.ts b/packages/core/src/models/content-generator-config.ts index cdaa2407ad3..935252cc54f 100644 --- a/packages/core/src/models/content-generator-config.ts +++ b/packages/core/src/models/content-generator-config.ts @@ -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, ): 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 = diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index 9742951810b..4fce515553b 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -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; } /**