diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 3424cf5be3..b65218274c 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: b776a2e5ddf7339170ac64fb8daa3ec314dc45c2 -2026-07-24-web-session-model-selector.zh.md: 1068ad50ad34e52cc4ffbd52c2287e7c5018588e +2026-07-24-web-session-model-selector.md: 4bd52bba8d3ba16c01fc59d2b561516f6fd0bb87 +2026-07-24-web-session-model-selector.zh.md: 35f3525b80e7d27fb573e854aefc317ed354df53 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index b776a2e5dd..4bd52bba8d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -10,15 +10,15 @@ The Web conversation displayed and sent through the Host's fixed provider/model ## Decision -The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The target starts from the latest `request/header` when the session has used a model, otherwise from the Host default. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed route persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. +The Web Host reuses `installAgentLlmTarget` for every created or resumed agent. The provider/model/reasoning target starts from the latest `request/header` when the session has used a model, otherwise from the Host default route. `session.selectModel` changes the session-local mutable target, and prompt assembly captures it with request routing; a switch during a running step therefore applies to the next assembled step. The next consumed target persists through the existing full `request/header` snapshot, while a choice that has not reached a request remains process-local. -The session RPC domain exposes `session.history`'s current `modelTarget`, a `session.models` directory, and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider. Provider catalogs load concurrently and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: the current model is inserted as an unlisted row when its registered provider omits it, and selecting an unlisted model under a registered provider remains valid. +The session RPC domain exposes a `session.models` directory and `session.selectModel`. The directory is built dynamically from the LLM registry and grouped by provider; each listed model's exact metadata adds adapter-owned reasoning effort ids, names, descriptions, and optional default. Provider catalogs and exact metadata load concurrently by provider and fail independently, so successful groups remain usable alongside retryable failure records. Catalog membership stays advisory: the current model is inserted as an unlisted row when its registered provider omits it, while exact resolution decides whether a route and explicit effort are available. Selection uses `resolveCallConfig` to reject unsupported effort ids and materialize an adapter-configured default before updating the target. -The browser `Session` object owns the current target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. A Host session primes the directory when its selector mounts so the compact trigger can resolve a catalog name, and each menu open refreshes it. The resident shell has no session model route before Workspace selection connects or reuses a Host session, so its disabled no-session input dispatches no selector. Directory and selection calls share an operation generation so older responses cannot replace a newer result; a separate target-change generation lets concurrent history restore the logged model across a mount-time directory refresh without allowing old history to overwrite a user selection. Failures retain the previous current target and usable groups. +The browser `ModelService` owns one `ModelDirectory` per live session. Its snapshot contains the current complete target, grouped catalog, provider failures, operation error, and `idle`/`loading`/`ready`/`selecting`/`error` state. Mounting primes the trigger label and each menu open refreshes the directory. Directory and selection calls share an operation generation so older responses cannot replace a newer result; connection reset discards the process-local projection before restoring the Host target. Failures retain the previous current target and usable groups. -`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and the session standard kit. `@deepseek-ai/dsh-client-ui-model-selector` occupies that dedicated seat, including for a Host-owned blank-session hero. Its compact trigger and radio rows display the catalog name, falling back to the model id for an unlisted current target, while the upward menu displays provider headings once with keyboard navigation, dismissal, retry states, and current selection marking. +`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. -The production browser roster is the flat config tree in `apps/cli/cordis.yml`; the selector is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest still declares the graph edge on `ui-conversation`, while cordis service availability governs activation. +The production browser roster is the flat config tree in `apps/cli/cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. ## Alternatives considered @@ -26,6 +26,8 @@ The production browser roster is the flat config tree in `apps/cli/cordis.yml`; **Hardcode the current DeepSeek catalog in the Web client.** This would drift from registered adapters and exclude deployment-owned providers. The LLM registry remains the source of provider and model metadata, including partial lookup failures. +**Keep `High`/`Max` as client-local UI state.** Static DeepSeek labels cannot represent `off`, pi-ai provider vocabularies, adapter defaults, validation, resume, or the next provider request. Exact-model metadata owns the selectable vocabulary, and the session target owns the selected id. + **Make the selection a global default.** A global mutation would unexpectedly redirect other open conversations. The target belongs to one live session, while Host configuration remains the default for sessions without a logged request. **Reject changes while an agent is running.** The shared atomic target already separates the assembled step from the next selection. Keeping the selector available lets the user prepare the following step without altering the in-flight request. @@ -34,8 +36,8 @@ The production browser roster is the flat config tree in `apps/cli/cordis.yml`; ## Consequences -Any Host-backed Web conversation, including a blank session, can switch among dynamically discovered provider groups without displaying duplicated `provider/model` labels, and the current used route survives resume and reconnect. Catalog names remain presentation-only; selection and persistence continue to use provider/model ids. A provider catalog outage degrades only that group. Selection changes can reduce provider-side cache reuse when the route changes, but the selector adds no prompt content and does not disturb the in-flight step. The resident shell uses the Host default and exposes no selector only while it has no current session. +Any Host-backed Web conversation, including a blank session, can switch among dynamically discovered provider groups and adapter-owned reasoning levels without displaying duplicated `provider/model` labels. The current consumed target survives resume and reconnect; catalog names remain presentation-only, while selection and persistence use provider/model/effort ids. A provider catalog or exact-metadata outage degrades only that group. Route changes can reduce provider-side cache reuse, but the selector adds no prompt content and does not disturb the in-flight step. A model without reasoning metadata has no Effort row. ## Testing -Host tests pin grouped discovery, duplicate-catalog isolation, partial provider failure, logged restoration, unlisted current targets, unavailable-provider rejection, and next-assembly switching. Client tests pin state transitions, failure preservation, transport errors, stale-response fencing, mount/open overlap, history restoration, and snapshot reference stability. UI tests pin the dedicated model-seat lifecycle and lock propagation, catalog-name labels with id fallback, provider grouping, radio semantics, retry/error states, successful and failed selection, outside dismissal, and Arrow/Home/End/Escape navigation. The keyless built-app fixture loads the selector through the production-shaped boot graph, selects OpenAI's GPT-5, sends a turn, and verifies that the next generated response reports the selected route. +Host tests pin grouped discovery, catalog and exact-metadata failure isolation, logged effort restoration, unlisted current targets, unsupported effort rejection, default materialization, and next-assembly switching. Client tests pin the shared directory, reconnect restoration, and complete-target submission. Component tests pin dynamic effort labels, descriptions, provider-default exposure, and effort submission. The keyless built-app fixture loads the production model plugin, selects OpenAI's GPT-5 and its Max effort, sends a turn, and verifies that the next generated response reports both ids. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 1068ad50ad..35f3525b80 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -10,15 +10,15 @@ Web 对话原本通过 Host 固定的提供方与模型路由显示并发送消 ## 决策 -Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,目标从最新的 `request/header` 开始;否则采用 Host 默认值。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的路由通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 +Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlmTarget`。如果会话已经使用过模型,提供方/模型/推理(reasoning)目标从最新的 `request/header` 开始;否则采用 Host 默认路由。`session.selectModel` 会更改会话级可变目标,提示词组装则将该目标与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一条实际采用的目标通过现有的完整 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.history` 的当前 `modelTarget`、`session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组。各提供方目录会并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:如果当前模型的已注册提供方没有列出该模型,系统会将其作为未列出行插入;在已注册提供方下选择未列出的模型仍然有效。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:如果当前模型的已注册提供方没有列出该模型,系统会将其作为未列出行插入;精确解析则决定路由与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在更新目标前具体化适配器配置的默认值。 -浏览器中的 `Session` 对象持有当前目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。Host 会话的选择器挂载时会预加载目录,使紧凑型触发器能够解析目录名称;此后每次打开菜单都会刷新目录。常驻壳在 Workspace 选择连接或复用 Host 会话之前没有会话模型路由,因此其禁用的无会话输入栏不会分发选择器。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;另设目标变更代次,使历史恢复即使与挂载时的目录刷新并发,也能还原日志记录的模型,同时防止旧历史覆盖用户选择。失败时保留先前的当前目标和可用分组。 +浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整目标、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 目标。失败时保留先前的当前目标和可用分组。 -`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话标准工具包。`@deepseek-ai/dsh-client-ui-model-selector` 占用该专用 seat,Host 拥有的空白会话 hero 也包括在内。其紧凑型触发器和单选菜单项显示目录名称;当前目标未列出时则回退到模型 ID。向上展开的菜单只显示一次提供方标题,同时提供键盘导航、关闭操作、重试状态和当前选择标记。 +`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 -生产环境的浏览器名册是 `apps/cli/cordis.yml` 中的平铺 config tree;选择器对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)仍声明对 `ui-conversation` 的图依赖边,激活则由 Cordis 服务可用性驱动。 +生产环境的浏览器名册是 `apps/cli/cordis.yml` 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 ## 考虑过的替代方案 @@ -26,6 +26,8 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm **在 Web 客户端中硬编码当前 DeepSeek 目录。** 该目录会与已注册适配器发生偏离,也会排除部署自有的提供方。LLM 注册表继续作为提供方与模型元数据的真源,也负责呈现部分查询失败。 +**将 `High`/`Max` 保留为客户端本地 UI 状态。** 静态 DeepSeek 标签无法覆盖 `off`、pi-ai 的提供方词汇、适配器默认值与校验,也不能参与恢复或下一次提供方请求。精确模型元数据拥有可选词汇,会话目标则拥有已选择的 ID。 + **将选择设为全局默认值。** 全局变更会意外改道其他已打开的对话。目标仅属于一个实时会话;对于没有已记录请求的会话,Host 配置仍是默认值。 **agent 运行期间拒绝更改。** 共享原子目标已经将当前组装步骤与下一次选择分离。保持选择器可用,可以让用户为下一个步骤预先选择模型,而不会改变正在执行的请求。 @@ -34,8 +36,8 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm ## 影响 -任何由 Host 支撑的 Web 对话(包括空白会话)都可以在动态发现的提供方分组之间切换,而无需显示重复的 `provider/model` 标签;当前实际使用的路由会在恢复和重连后保留。目录名称仅用于呈现;选择和持久化仍然使用提供方/模型 ID。某个提供方的目录不可用时,只有相应分组会降级。路由变更可能降低提供方侧的缓存复用率,但选择器不会添加任何提示词内容,也不会干扰正在执行的步骤。常驻壳仅在没有当前会话时使用 Host 默认值且不暴露选择器。 +任何由 Host 支撑的 Web 对话(包括空白会话)都可以在动态发现的提供方分组和适配器持有的推理级别之间切换,而无需显示重复的 `provider/model` 标签。当前实际采用的目标会在恢复和重连后保留;目录名称仅用于呈现,而选择和持久化使用提供方/模型/推理强度 ID。某个提供方的目录或精确元数据不可用时,只有相应分组会降级。路由变更可能降低提供方侧的缓存复用率,但选择器不会添加任何提示词内容,也不会干扰正在执行的步骤。没有推理元数据的模型不显示 Effort 行。 ## 测试 -Host 测试固定分组发现、重复目录项隔离、部分提供方失败、已记录目标恢复、当前未列出目标、不可用提供方拒绝,以及切换仅影响下一次组装。客户端测试固定状态转换、失败时保留原状态、传输错误、过时响应栅栏、挂载与打开重叠、从历史记录恢复,以及快照引用稳定性。UI 测试固定专用模型 seat 的生命周期与锁定状态传播、显示目录名称并回退到 ID、提供方分组、单选语义、重试与错误状态、选择成功与失败、点击外部关闭,以及 Arrow/Home/End/Escape 键盘导航。无密钥 built-app fixture(测试前置数据)通过与生产环境同形的 boot 图加载选择器,选择 OpenAI 的 GPT-5,发起一个轮次,并验证下一条生成的响应会报告所选路由。 +Host 测试固定分组发现、目录与精确元数据失败隔离、已记录推理强度恢复、当前未列出目标、不支持的推理强度拒绝、默认值具体化,以及切换仅影响下一次组装。客户端测试固定共享目录、重连恢复与完整目标提交。组件测试固定动态推理强度标签、说明、提供方默认值展示与推理强度提交。无密钥 built-app fixture(测试前置数据)加载生产模型插件,选择 OpenAI 的 GPT-5 及其 Max 推理强度,发起一个轮次,并验证下一条生成的响应会报告两个 ID。 diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index ece209e889..67881d003c 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -17,7 +17,9 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] }, { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-model-selector', dir: 'ui-model-selector', url: '/plugins/ui-model-selector.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-model', dir: 'ui-model', url: '/plugins/ui-model.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-command'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] @@ -118,11 +120,20 @@ it('projects titles and routes the next turn through the selected model in the b const revised = titleSurfaces(revisedLabel) const modelTrigger = await screen.findByRole('button', { - name: '选择模型,当前 DeepSeek-V4-Flash', + name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High', }) fireEvent.click(modelTrigger) + fireEvent.click(screen.getByRole('menuitem', { name: /Model/ })) fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ })) - await waitFor(() => { expect(modelTrigger.textContent).toBe('GPT-5') }) + await waitFor(() => { + expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Medium') + }) + fireEvent.click(modelTrigger) + fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ })) + fireEvent.click(screen.getByRole('menuitemradio', { name: 'Max' })) + await waitFor(() => { + expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Max') + }) // fx-alpha starts in the running state. Selecting above is intentionally // allowed for the next turn; stop the fixture's resident run before sending @@ -131,7 +142,7 @@ it('projects titles and routes the next turn through the selected model in the b const composer = await screen.findByPlaceholderText('Message the agent') fireEvent.change(composer, { target: { value: 'report model' } }) fireEvent.keyDown(composer, { key: 'Enter' }) - await screen.findByText('当前模型:openai/gpt-5', {}, { timeout: 10_000 }) + await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 }) await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`) .toMatchFileSnapshot('./snapshots/session-title.json') diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 181b385077..a1380c4b58 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,7 +10,8 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, - ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelTarget, SessionModels, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 36541ed7e3..b2e232006b 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -46,6 +46,25 @@ const MARKDOWN_FIXTURE = [ const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)' +const DEEPSEEK_REASONING = { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'high', +} + +const OPENAI_REASONING = { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'medium', name: 'Medium' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'medium', +} + function sid(id: string): SessionId { return id as SessionId } @@ -666,20 +685,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { id: 'deepseek', name: 'DeepSeek', models: [ - { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' }, + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: '快速响应', + reasoning: DEEPSEEK_REASONING, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: '复杂任务', + reasoning: DEEPSEEK_REASONING, + }, ], }, { id: 'openai', name: 'OpenAI', - models: [{ id: 'gpt-5', name: 'GPT-5' }], + models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }], }, ], failures: [], }), selectModel: (request) => { - const selected = { provider: request.payload.provider, model: request.payload.model } + const selected: ModelTarget = { + provider: request.payload.provider, + model: request.payload.model, + ...request.payload.reasoningEffort === undefined + ? {} + : { reasoningEffort: request.payload.reasoningEffort }, + } modelTargets.set(request.payload.sessionId, selected) return ok(request, { selected }) }, @@ -718,7 +753,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { userText === 'render markdown' ? MARKDOWN_FIXTURE : userText === 'report model' - ? `当前模型:${modelTargets.get(id)?.provider ?? 'unknown'}/${modelTargets.get(id)?.model ?? 'unknown'}` + ? (() => { + const target = modelTargets.get(id) + return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}` + + (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`) + })() : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, ) return ok(request, { accepted: true as const }) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index e255664eb1..0e50d8617f 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,7 +15,8 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, - ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelTarget, SessionModels, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index 51a0f28b3c..c45190753a 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model/README.md -README.md: 4de0e787a79d28ef8e92ee757d8d366e964c74d3 -README.zh.md: 0eef4af91c69fdab1e04377244d6f256399caa75 +README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642 +README.zh.md: 325b1d93d99ed22e0945c26f5a3a9e5b3b209c85 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index 4de0e787a7..267717c784 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat (a compact trigger + upward provider-grouped menu, figma 313:14108's ToggleButton chrome) both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance — the host-reported current target is the single fact both surfaces echo, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter (an older response never overwrites a newer one); a connection reset drops every resident projection and repulls the Host-restored target before displaying it again. Provider-local catalog failures list inline while usable groups stay selectable; whole-request and selection failures surface on each entry's own retry face (the popup shell's error/retry, the seat menu's inline error) without forking the state. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. ## Model Experience -Indirectly, through the `session.selectModel` RPC both entries submit: the host snapshots the selected provider/model pair at the next prompt-assembly boundary, so the following request routes (and stamps its prompt variables) with the chosen target while a running step keeps its assembled one — the directory, both menus, and every selection interaction stay client-side and never enter the session log. +Indirectly, through the `session.selectModel` RPC both entries submit: the Host snapshots the selected provider/model/reasoning target at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target. The selection becomes durable only when the existing request header records a request that consumes it; menu interaction adds no prompt content. #### KV Cache effect @@ -17,5 +17,5 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse ## Known Limitations and Deferred Work - **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go). -- **Directory names are presentation-only** — selection and persistence use provider/model ids; a provider whose catalog lookup fails lists as an unselectable failure row until reload. -- **The seat shows no effort level** — the figma mock's `High` text has no wire concept behind it yet; the trigger renders the model name alone. +- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload. +- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 0eef4af91c..325b1d93d9 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位(紧凑触发器 + 向上展开的按提供方分组菜单,视觉取 figma 313:14108 的 ToggleButton)都通过同一个 `ModelDirectory` 实例经 `session.models` 加载会话的建议目录、经 `session.selectModel` 提交——host 报告的 current target 是两个界面共同回显的唯一事实,在任一入口切换,另一入口下次打开显示的就是新值。目录加载与选择共享一个代次计数器(旧响应永不覆盖新结果);连接重置会先丢弃所有常驻目录投影,再重新拉取 Host 恢复的 target 后显示,避免继续呈现未消费的进程内选择。提供方级目录失败内联列出,可用分组保持可选;整体失败与选择失败落各入口自己的重试面(popup 壳的 error/retry、坑位菜单的内联错误),状态不分叉。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。 +模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。 ## Model Experience -间接影响,经两个入口共同提交的 `session.selectModel` RPC:host 在下一次提示词组装边界快照所选提供方/模型对,因此后续请求按所选目标路由(并盖入提示词变量),运行中的步骤保持其已组装目标——目录、两个菜单及全部选择交互都留在 client 侧,永不进入 session log。 +间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。 #### KV Cache effect @@ -17,5 +17,5 @@ ## Known Limitations and Deferred Work - **无创建期选择**——两个入口都寻址既有会话的 agent;没有 Draft 期模型选择折入会话创建的通道(host `targetFor` 处的种子序注释记录了该层未来的落点)。 -- **目录名仅供呈现**——选择与持久化使用提供方/模型 id;目录查询失败的提供方以不可选失败行列出,重新加载前保持原样。 -- **坑位不显示 effort 档位**——figma 设计稿中的 `High` 文本尚无对应 wire 概念;触发器只渲染模型名。 +- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。 +- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。 diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 16db0acd27..0207bf32af 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -6,37 +6,38 @@ * the shared directory, and the effort levels. The trigger (313:14108's * ToggleButton) shows both: model name + effort in the caption tone. * Data and submission ride the SAME per-session ModelDirectory as the - * /model popup; effort is a client-local display echo until a wire carries - * a per-session override (see the directory's state contract). + * /model popup; exact-model reasoning metadata and the selected effort come + * from the Host rather than a client-owned vocabulary. */ import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type FocusEvent, } from 'react' import clsx from 'clsx' -import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ModelEffort } from './directory.ts' import type { ModelSelectInjected } from './slots.ts' import css from './ModelSelect.module.css' -/** The displayable effort levels (deepseek wire vocabulary, capitalized for the UI). */ -const EFFORT_LEVELS: readonly { id: ModelEffort; label: string }[] = [ - { id: 'high', label: 'High' }, - { id: 'max', label: 'Max' }, -] - /** Which pane the dropdown shows: the two-row root or one drilled-in list. */ type Pane = 'root' | 'model' | 'effort' +/** One dynamic effort row; undefined means preserve the provider default. */ +interface EffortChoice { + key: string + effort: string | undefined + label: string + description?: string +} + /** * Render the composer model seat. * @param props - owner share (locked) + injected face (shared directory store/verbs). * @returns the trigger and, while open, the two-level menu. */ -export function ModelSelect({ locked, directory, load, select, setEffort }: ModelSelectInjected & { locked: boolean }) { +export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) { const state = useSyncExternalStore( fn => directory.subscribe(fn), () => directory.getSnapshot(), @@ -52,13 +53,39 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode group.models.map(model => ({ group, model, - target: { provider: group.id, model: model.id } satisfies ModelTarget, + target: { + provider: group.id, + model: model.id, + ...model.reasoning?.defaultEffort === undefined + ? {} + : { reasoningEffort: model.reasoning.defaultEffort }, + } satisfies ModelTarget, }))), [state.groups]) const selectedIndex = state.current === null ? -1 : choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model) + const currentChoice = choices[selectedIndex] + const reasoning = currentChoice?.model.reasoning + const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort + const effortLabel = reasoning === undefined + ? undefined + : effectiveEffort === undefined + ? 'Provider default' + : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort + const effortChoices = useMemo(() => reasoning === undefined + ? [] + : [ + ...reasoning.defaultEffort === undefined + ? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }] + : [], + ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({ + key: `effort:${effort.id}`, + effort: effort.id, + label: effort.name, + ...effort.description === undefined ? {} : { description: effort.description }, + })), + ], [reasoning]) const busy = state.status === 'selecting' - const effortLabel = EFFORT_LEVELS.find(l => l.id === state.effort)?.label ?? 'High' // Mount-time load resolves the trigger label; every open refreshes. useEffect(() => { load() }, [load]) @@ -122,7 +149,24 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode }) } + const chooseEffort = (effort: string | undefined): void => { + if (state.current === null) return + if (effectiveEffort === effort) { + close(true) + return + } + const target: ModelTarget = { + provider: state.current.provider, + model: state.current.model, + ...effort === undefined ? {} : { reasoningEffort: effort }, + } + void select(target).then((accepted) => { + if (accepted && rootRef.current !== null) close(true) + }) + } + const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型' + const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}` itemRefs.current = [] let itemIndex = 0 const itemRef = () => { @@ -136,16 +180,16 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode ref={triggerRef} type="button" className={css.trigger} - aria-label={`选择模型,当前 ${modelLabel},effort ${effortLabel}`} + aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? `${id}-menu` : undefined} - title={`${modelLabel} · ${effortLabel}`} + title={triggerLabel} disabled={locked} onClick={() => { open ? close() : show() }} > {modelLabel} - {effortLabel} + {effortLabel !== undefined && {effortLabel}} @@ -154,7 +198,7 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode id={`${id}-menu`} className={css.menu} role="menu" - aria-label="模型与 effort" + aria-label="模型与推理等级" aria-busy={state.status === 'loading' || busy} > {pane === 'root' && ( @@ -164,11 +208,13 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode {modelLabel} - + {reasoning !== undefined && ( + + )} )} @@ -234,24 +280,40 @@ export function ModelSelect({ locked, directory, load, select, setEffort }: Mode )} - {pane === 'effort' && EFFORT_LEVELS.map(level => ( - - ))} + {pane === 'effort' && ( + <> + {state.error !== null && ( +
+ 模型操作失败:{state.error} + +
+ )} + {effortChoices.length === 0 + ?
当前模型未提供推理等级。
+ : effortChoices.map(level => ( + + ))} + + )} )} diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index eaf83f1a44..827502d258 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -11,19 +11,8 @@ import type { import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -/** Thinking-effort display levels (the deepseek wire vocabulary). */ -export type ModelEffort = 'high' | 'max' - /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** - * Displayed thinking-effort level. Client-local echo only for now: the - * design pairs model and effort as one two-level selection, but no wire - * carries a per-session effort override yet (the deepseek adapter's - * reasoningEffort is deployment config) — selecting it updates this - * display state and nothing else. - */ - effort: ModelEffort /** Target the host reports for the next assembled step; null before the first load. */ current: ModelTarget | null /** Successfully loaded provider groups (last good load). */ @@ -40,7 +29,7 @@ export interface ModelDirectoryState { export class ModelDirectory { /** The shared snapshot both entries render from (uSES-safe store). */ readonly store: SnapshotStore = createSnapshotStore({ - effort: 'high', current: null, groups: [], failures: [], status: 'idle', error: null, + current: null, groups: [], failures: [], status: 'idle', error: null, }) /** Latest operation wins; an older response never overwrites a newer one. */ @@ -85,16 +74,21 @@ export class ModelDirectory { } /** - * Select the complete route (both entries submit through here). Success + * Select the complete provider/model/reasoning target (both entries submit through here). Success * updates the shared current; failure surfaces on the store and throws so * each entry's own retry surface engages. - * @param target - provider and provider-owned model id. + * @param target - provider, provider-owned model id, and optional adapter-owned effort. */ async select(target: ModelTarget): Promise { const generation = ++this.generation this.store.update((s) => { s.status = 'selecting'; s.error = null }) const { result } = await this.sessions.selectModel({ - sessionId: this.sessionId, provider: target.provider, model: target.model, + sessionId: this.sessionId, + provider: target.provider, + model: target.model, + ...target.reasoningEffort === undefined + ? {} + : { reasoningEffort: target.reasoningEffort }, }) if (this.disposed || generation !== this.generation) { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) @@ -107,15 +101,6 @@ export class ModelDirectory { this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null }) } - /** - * Set the displayed effort level (client-local; see the state field's contract). - * @param effort - the level to display. - */ - setEffort(effort: ModelEffort): void { - if (this.disposed) return - this.store.update((s) => { s.effort = effort }) - } - /** * Drop the previous Host generation's projection and repull it. Clearing * first prevents an unconsumed process-local selection from being displayed diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index aea7562a8d..a2bef2a5b6 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -20,7 +20,7 @@ import type { ModelSelectInjected } from './slots.ts' import { ModelSelect } from './ModelSelect.tsx' export { ModelDirectory } from './directory.ts' -export type { ModelDirectoryState, ModelEffort } from './directory.ts' +export type { ModelDirectoryState } from './directory.ts' export { ModelService } from './service.ts' export type { ModelSelectInjected } from './slots.ts' @@ -61,7 +61,16 @@ function optionsOf(directory: SessionModels): SelectOption[] { function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefined { for (const group of state.groups) { for (const model of group.models) { - if (rowId(group.id, model.id) === id) return { provider: group.id, model: model.id } + if (rowId(group.id, model.id) !== id) continue + const sameRoute = state.current?.provider === group.id && state.current.model === model.id + const reasoningEffort = sameRoute + ? state.current?.reasoningEffort ?? model.reasoning?.defaultEffort + : model.reasoning?.defaultEffort + return { + provider: group.id, + model: model.id, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + } } } return undefined @@ -114,7 +123,6 @@ export function apply(ctx: ClientContext): void { directory: directory.store, load: () => { directory.load().catch(() => { /* surfaced on the store */ }) }, select: (target: ModelTarget) => directory.select(target).then(() => true, () => false), - setEffort: (effort) => { directory.setEffort(effort) }, } }, }, ModelSelect), 'ui-model: composer model seat registration') diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts index 9b3a1ac351..f05087ab62 100644 --- a/packages/client/ui-model/src/client/slots.ts +++ b/packages/client/ui-model/src/client/slots.ts @@ -6,7 +6,7 @@ */ import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { ModelDirectoryState, ModelEffort } from './directory.ts' +import type { ModelDirectoryState } from './directory.ts' /** Injected business face of the composer model seat. */ export interface ModelSelectInjected { @@ -15,15 +15,9 @@ export interface ModelSelectInjected { /** Refresh the advisory directory (fire-and-forget; errors land on the store). */ load(): void /** - * Select a complete provider/model target through the shared route. - * @param target - target picked from one provider group. + * Select a complete provider/model/reasoning target through the shared route. + * @param target - model target and optional adapter-owned effort. * @returns whether the host accepted the selection. */ select(target: ModelTarget): Promise - /** - * Set the displayed thinking-effort level (client-local echo; see the - * directory state contract). - * @param effort - the level to display. - */ - setEffort(effort: ModelEffort): void } diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index d4ae9619b7..8caa7d097f 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -23,8 +23,30 @@ const GROUPS = [{ id: 'deepseek', name: 'DeepSeek', models: [ - { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + reasoning: { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'high', + }, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + reasoning: { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'high', + }, + }, ], }] @@ -38,9 +60,15 @@ async function bench() { calls.models += 1 return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } }) }, - selectModel: (payload: { provider: string; model: string }) => { + selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => { calls.select += 1 - current = { provider: payload.provider, model: payload.model } + current = { + provider: payload.provider, + model: payload.model, + ...payload.reasoningEffort === undefined + ? {} + : { reasoningEffort: payload.reasoningEffort }, + } return Promise.resolve({ result: { ok: true as const, value: { selected: current } } }) }, } } }) @@ -102,9 +130,21 @@ describe('ui-model dual entry', () => { b.mint('s1') const seatFace = b.seat().inject!(sid('s1')) // Switch through the SEAT entry. - expect(await seatFace.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).toBe(true) - expect(b.hostCurrent()).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) + expect(await seatFace.select({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'max', + })).toBe(true) + expect(b.hostCurrent()).toEqual({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'max', + }) + expect(seatFace.directory.getSnapshot().current).toEqual({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'max', + }) // The POPUP's next options pass reflects it without a seat-side reload. const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true }) @@ -117,7 +157,11 @@ describe('ui-model dual entry', () => { const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')! await b.contribution().ui.onSelect(pro, projection('s1')) - expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) + expect(seatFace.directory.getSnapshot().current).toEqual({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'high', + }) }) it('both entries share one directory instance per session, isolated across sessions', async () => { diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx new file mode 100644 index 0000000000..dd24b2153e --- /dev/null +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { ModelDirectoryState } from '../src/client/directory.ts' +import { ModelSelect } from '../src/client/ModelSelect.tsx' + +const reasoning = { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max', description: 'Largest budget' }, + ], + defaultEffort: 'high', +} + +function state(overrides: Partial = {}): ModelDirectoryState { + return { + current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + groups: [{ + id: 'deepseek', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }], + }], + failures: [], + status: 'ready', + error: null, + ...overrides, + } +} + +afterEach(cleanup) + +describe('ModelSelect reasoning effort', () => { + it('renders adapter metadata and submits the effort as part of the session target', async () => { + const directory = createSnapshotStore(state()) + const select = vi.fn(async (target: ModelTarget) => { + directory.update((snapshot) => { snapshot.current = target }) + return true + }) + render() + + const trigger = screen.getByRole('button', { + name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High', + }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ })) + expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) + .toEqual(['Off', 'High', 'MaxLargest budget']) + + fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ })) + await waitFor(() => { + expect(select).toHaveBeenCalledWith({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + reasoningEffort: 'max', + }) + expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash,推理等级 Max') + }) + }) + + it('offers provider default only when the adapter does not configure a model default', () => { + const directory = createSnapshotStore(state({ + groups: [{ + id: 'provider', + name: 'Provider', + models: [{ + id: 'model', + name: 'Model', + reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] }, + }], + }], + current: { provider: 'provider', model: 'model' }, + })) + render() + + fireEvent.click(screen.getByRole('button', { + name: '选择模型,当前 Model,推理等级 Provider default', + })) + fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ })) + expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) + .toEqual(['Provider default', 'Standard']) + }) +}) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e89a354d1f..bab152a4fe 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: b5b35f0ec0e5194eae98f74af938f9cff36bc6f1 -README.zh.md: 9cb91a1da151389e713f5e995b52675db562ab15 +README.md: 296ce8dd999aa251f87a9dbadb2ef821fe58e173 +README.zh.md: b6191ac4b096af191be17a766f3910c02db6c3ed diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b5b35f0ec0..296ce8dd99 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. -Session model routing is a session-domain contract. `session.history` returns the selected `modelTarget`, `session.models` returns that target with provider-grouped advisory model metadata and provider-local lookup failures, and `session.selectModel` replaces the target selected for the next prompt-assembly boundary. Catalog membership is not validation: a registered provider may accept an unlisted model, while an unregistered provider returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 9cb91a1da1..b6191ac4b0 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,7 +12,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 -会话模型路由属于会话领域契约。`session.history` 返回选中的 `modelTarget`;`session.models` 返回该目标,以及按提供方分组的仅供参考模型元数据和逐提供方查询失败记录;`session.selectModel` 则替换将在下一提示词组装边界使用的目标。目录成员关系不构成校验:已注册提供方可以接受未列出的模型,而未注册提供方会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4e26dcc36a..ff702479a9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -8,7 +8,10 @@ import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { + Agent, AgentLlmTarget, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus, +} from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' @@ -21,7 +24,7 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget, + ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' @@ -359,7 +362,7 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const agentOptions = { provider: defaults.provider, model: defaults.model } - type WebLlmTargetRef = AgentLlmTargetRef & { current: ModelTarget } + type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget } const targets = new WeakMap() /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ const resumes = new Map>() @@ -383,7 +386,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const target: WebLlmTargetRef = { current: logged === undefined ? { provider: defaults.provider, model: defaults.model } - : { provider: logged.provider, model: logged.model }, + : { + provider: logged.provider, + model: logged.model, + ...logged.reasoningEffort === undefined + ? {} + : { reasoningEffort: logged.reasoningEffort }, + }, assembled: undefined, } installAgentLlmTarget(agent.ctx, target) @@ -710,15 +719,50 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const current = targetFor(found.agent).current const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { try { - const models = await ctx.llm.listModels(provider.id) - const group: ModelProviderGroup = { - id: provider.id, - name: provider.name, - models: models.map(model => ({ + const advertised = await ctx.llm.listModels(provider.id) + const models = [...advertised] + if ( + provider.id === current.provider + && !models.some(model => model.id === current.model) + ) { + models.push({ + provider: provider.id, + id: current.model, + name: current.model, + }) + } + const entries = await Promise.all(models.map(async (model) => { + const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) + const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined + ? undefined + : { + efforts: resolved.reasoning.efforts.map(effort => ({ + id: effort.id, + name: effort.name, + ...effort.description === undefined + ? {} + : { description: effort.description }, + })), + ...resolved.reasoning.defaultEffort === undefined + ? {} + : { defaultEffort: resolved.reasoning.defaultEffort }, + } + return { id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, - })), + ...provider.id === current.provider + && model.id === current.model + && !advertised.some(candidate => candidate.id === current.model) + ? { unlisted: true as const } + : {}, + ...reasoning === undefined ? {} : { reasoning }, + } + })) + const group: ModelProviderGroup = { + id: provider.id, + name: provider.name, + models: entries, } return { kind: 'group' as const, group } } catch (error: unknown) { @@ -732,17 +776,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []) - const currentGroup = groups.find(group => group.id === current.provider) - if ( - currentGroup !== undefined - && !currentGroup.models.some(model => model.id === current.model) - ) { - currentGroup.models.push({ - id: current.model, - name: current.model, - unlisted: true, - }) - } return ok(request, { current: { ...current }, groups: groups.filter(group => group.models.length > 0), @@ -751,19 +784,33 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async selectModel(request) { - const { sessionId, provider, model } = request.payload + const { sessionId, provider, model, reasoningEffort } = request.payload const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) - if (!ctx.llm.listProviders().some(entry => entry.id === provider)) { + try { + const resolved = await ctx.llm.resolveCallConfig({ + provider, + model, + ...reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, + }) + const selected: AgentLlmTarget = { + provider: resolved.provider, + model: resolved.model, + ...resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }, + } + targetFor(found.agent).current = selected + return ok(request, { selected: { ...selected } }) + } catch (error: unknown) { return err(request, { code: 'model-unavailable', - message: `provider "${provider}" is not registered`, + message: error instanceof Error ? error.message : String(error), details: { provider, model }, }) } - const selected: ModelTarget = { provider, model } - targetFor(found.agent).current = selected - return ok(request, { selected: { ...selected } }) }, async prompt(request) { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index fe5192920a..ad5fbd3bf0 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -26,8 +26,8 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, - SessionModels, SessionsApi, SessionSummary, + HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelTarget, SessionModels, SessionsApi, SessionSummary, } from './sessions.ts' export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 5c12b0eabd..02efe769af 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -10,8 +10,8 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, - SessionSummary, + HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelTarget, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -83,14 +83,29 @@ export const sessionHistoryRequestSchema = z.object({ export const modelTargetSchema = z.object({ provider: z.string().min(1), model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), }) satisfies z.ZodType> +/** One adapter-owned reasoning effort. */ +export const modelReasoningEffortSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), +}) satisfies z.ZodType> + +/** Exact-model reasoning metadata. */ +export const modelReasoningSchema = z.object({ + efforts: z.array(modelReasoningEffortSchema).min(1), + defaultEffort: z.string().min(1).optional(), +}) satisfies z.ZodType> + /** One advisory model entry inside a provider group. */ export const modelCatalogModelSchema = z.object({ id: z.string().min(1), name: z.string().min(1), description: z.string().optional(), unlisted: z.literal(true).optional(), + reasoning: modelReasoningSchema.optional(), }) satisfies z.ZodType> /** One successfully loaded provider group. */ @@ -154,6 +169,7 @@ export const sessionSelectModelRequestSchema = z.object({ sessionId: sessionIdSchema, provider: z.string().min(1), model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), }) satisfies z.ZodType>> /** session.selectModel response value. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index ea9a0024e2..88308c829b 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -32,12 +32,32 @@ export interface HistoryEntry { view?: ToolEventView } -/** Complete provider/model route selected for one session. */ +/** Complete model target selected for one session. */ export interface ModelTarget { /** Registered provider route. */ provider: string /** Provider-owned model id. */ model: string + /** Adapter-owned reasoning effort; absence preserves adapter/provider default behavior. */ + reasoningEffort?: string +} + +/** One adapter-owned reasoning effort displayed for an exact model route. */ +export interface ModelReasoningEffort { + /** Opaque value submitted back to the owning adapter. */ + id: string + /** Adapter-supplied display name. */ + name: string + /** Optional adapter-supplied description. */ + description?: string +} + +/** Selectable reasoning metadata for one exact model route. */ +export interface ModelReasoning { + /** Efforts in adapter-preferred display order. */ + efforts: ModelReasoningEffort[] + /** Adapter-configured default; absence preserves the provider default. */ + defaultEffort?: string } /** One model displayed inside its provider group. */ @@ -50,6 +70,8 @@ export interface ModelCatalogModel { description?: string /** The current model was inserted because the advisory catalog omitted it. */ unlisted?: true + /** Exact-route reasoning metadata when the adapter exposes it. */ + reasoning?: ModelReasoning } /** One provider and the models it advertised successfully. */ @@ -139,10 +161,16 @@ export interface SessionsApi { models(request: RpcRequest<{ sessionId: SessionId }>): Promise> /** - * Selects the complete route for this session. The registered provider is - * validated, while model catalog membership remains advisory. + * Selects the complete target for this session. Exact model metadata + * validates an optional reasoning effort, while catalog membership remains + * advisory. */ - selectModel(request: RpcRequest<{ sessionId: SessionId; provider: string; model: string }>): + selectModel(request: RpcRequest<{ + sessionId: SessionId + provider: string + model: string + reasoningEffort?: string + }>): Promise> /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 96efe60a9d..645d6f8d2c 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -8,9 +8,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { - GenerateOptions, LlmCallConfig, LlmModelInfo, LlmProviderInfo, StreamChunk, + GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, + LlmResolvedModelInfo, StreamChunk, } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -29,6 +30,8 @@ class CatalogAdapter extends LlmAdapter { constructor( private readonly name: string, private readonly models: readonly LlmModelInfo[] | Error, + private readonly reasoning?: LlmModelReasoningInfo, + private readonly exactError?: Error, ) { super() } @@ -43,12 +46,35 @@ class CatalogAdapter extends LlmAdapter { : Promise.resolve(this.models) } + override resolveModel(provider: string, model: string): Promise { + if (this.exactError !== undefined) return Promise.reject(this.exactError) + return Promise.resolve({ + provider, + id: model, + name: model, + ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, + }) + } + override async *stream(_options: GenerateOptions): AsyncIterable { // Catalog tests never enter provider streaming. } } -async function harness(logged?: { provider: string; model: string }): Promise<{ +const REASONING: LlmModelReasoningInfo = { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), +} + +async function harness(logged?: { + provider: string + model: string + reasoningEffort?: ReasoningEffortId +}): Promise<{ ctx: Context agent: Agent sessionId: SessionId @@ -62,8 +88,11 @@ async function harness(logged?: { provider: string; model: string }): Promise<{ ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [ { provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' }, { provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' }, - ])) + ], REASONING)) ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline'))) + ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [ + { provider: 'metadata-broken', id: 'listed', name: 'Listed' }, + ], undefined, new Error('reasoning metadata offline'))) ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', [])) ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [ { provider: 'duplicate', id: 'same', name: 'Same' }, @@ -90,22 +119,41 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false describe('Web session model selection', () => { it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { - const { ctx, sessionId } = await harness({ provider: 'deepseek', model: 'private-preview' }) + const { ctx, sessionId } = await harness({ + provider: 'deepseek', + model: 'private-preview', + reasoningEffort: ReasoningEffortId('max'), + }) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) - expect(catalog.current).toEqual({ provider: 'deepseek', model: 'private-preview' }) + expect(catalog.current).toEqual({ + provider: 'deepseek', + model: 'private-preview', + reasoningEffort: 'max', + }) expect(catalog.groups).toEqual([{ id: 'deepseek', name: 'DeepSeek', models: [ - { id: 'deepseek-chat', name: 'DeepSeek Chat' }, - { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' }, - { id: 'private-preview', name: 'private-preview', unlisted: true }, + { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING }, + { + id: 'deepseek-reasoner', + name: 'DeepSeek Reasoner', + description: 'Reasoning model', + reasoning: REASONING, + }, + { + id: 'private-preview', + name: 'private-preview', + unlisted: true, + reasoning: REASONING, + }, ], }]) expect(catalog.failures).toEqual([ { id: 'broken', name: 'Broken Provider', message: 'catalog offline' }, + { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' }, { id: 'duplicate', name: 'Duplicate Provider', @@ -130,8 +178,13 @@ describe('Web session model selection', () => { sessionId, provider: 'deepseek', model: 'private-preview', + reasoningEffort: 'max', }))) - expect(selected.selected).toEqual({ provider: 'deepseek', model: 'private-preview' }) + expect(selected.selected).toEqual({ + provider: 'deepseek', + model: 'private-preview', + reasoningEffort: 'max', + }) await expect(agentEvents(ctx, agent).waterfall( 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' }) @@ -140,7 +193,25 @@ describe('Web session model selection', () => { .toMatchObject({ provider: 'deepseek', model: 'private-preview' }) await expect(agentEvents(ctx, agent).waterfall( 'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed), - )).resolves.toMatchObject({ provider: 'deepseek', model: 'private-preview' }) + )).resolves.toMatchObject({ + provider: 'deepseek', + model: 'private-preview', + reasoningEffort: 'max', + }) + + const unsupported = await api.sessions.selectModel(request({ + sessionId, + provider: 'deepseek', + model: 'private-preview', + reasoningEffort: 'medium', + })) + expect(unsupported.result).toMatchObject({ + ok: false, + error: { + code: 'model-unavailable', + message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"', + }, + }) const rejected = await api.sessions.selectModel(request({ sessionId, @@ -151,12 +222,12 @@ describe('Web session model selection', () => { ok: false, error: { code: 'model-unavailable', - message: 'provider "missing" is not registered', + message: 'no adapter registered for provider "missing"', details: { provider: 'missing', model: 'model' }, }, }) expect(expectValue(await api.sessions.models(request({ sessionId }))).current) - .toEqual({ provider: 'deepseek', model: 'private-preview' }) + .toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 6f165b6e1e..4c512cb6a9 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -54,7 +54,15 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra rpcId: request.rpcId, result: { ok: true, - value: { selected: { provider: request.payload.provider, model: request.payload.model } }, + value: { + selected: { + provider: request.payload.provider, + model: request.payload.model, + ...request.payload.reasoningEffort === undefined + ? {} + : { reasoningEffort: request.payload.reasoningEffort }, + }, + }, }, } }, @@ -160,11 +168,22 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true) - expect((await c.sessions.selectModel({ + const selected = await c.sessions.selectModel({ sessionId: 's' as never, provider: 'deepseek', model: 'deepseek-v4-flash', - })).result.ok).toBe(true) + reasoningEffort: 'max', + }) + expect(selected.result).toMatchObject({ + ok: true, + value: { + selected: { + provider: 'deepseek', + model: 'deepseek-v4-flash', + reasoningEffort: 'max', + }, + }, + }) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index aa08f8a0d2..70408248ae 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -141,7 +141,7 @@ describe('sessions domain schemas', () => { }).hasMore).toBe(false) expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ - current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, groups: [{ id: 'deepseek', name: 'DeepSeek', @@ -150,6 +150,13 @@ describe('sessions domain schemas', () => { name: 'DeepSeek V4 Flash', description: 'fast', unlisted: true, + reasoning: { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'max', name: 'Max', description: 'Largest budget' }, + ], + defaultEffort: 'off', + }, }], }], failures: [{ id: 'broken', name: 'Broken', message: 'offline' }], @@ -158,15 +165,31 @@ describe('sessions domain schemas', () => { sessionId: 's1', provider: 'deepseek', model: 'deepseek-v4-pro', - }).model).toBe('deepseek-v4-pro') + reasoningEffort: 'max', + }).reasoningEffort).toBe('max') expect(sessionSelectModelValueSchema.parse({ - selected: { provider: 'deepseek', model: 'deepseek-v4-pro' }, - }).selected.model).toBe('deepseek-v4-pro') + selected: { provider: 'deepseek', model: 'deepseek-v4-pro', reasoningEffort: 'max' }, + }).selected.reasoningEffort).toBe('max') expect(() => sessionSelectModelRequestSchema.parse({ sessionId: 's1', provider: '', model: 'm', })).toThrow() + expect(() => sessionSelectModelRequestSchema.parse({ + sessionId: 's1', + provider: 'deepseek', + model: 'm', + reasoningEffort: '', + })).toThrow() + expect(() => sessionModelsValueSchema.parse({ + current: { provider: 'deepseek', model: 'm' }, + groups: [{ + id: 'deepseek', + name: 'DeepSeek', + models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }], + }], + failures: [], + })).toThrow() const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] }) expect(prompt.mode).toBe('queue') expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()