Kaynağa Gözat

feat(web): unify model input controls and catalog inheritance

Yichen Jiang 3 hafta önce
ebeveyn
işleme
cc394230bf
55 değiştirilmiş dosya ile 945 ekleme ve 557 silme
  1. 0 27
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md
  2. 0 27
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md
  3. 3 3
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.i18n.yaml
  4. 27 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md
  5. 27 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.zh.md
  6. 84 0
      apps/web/tests/expected/models-settings/catalog-inputs.expected.md
  7. 15 10
      apps/web/tests/expected/models-settings/declared-edit.expected.md
  8. 6 5
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  9. 6 5
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  10. 25 0
      apps/web/tests/model-input-layout.ts
  11. 72 11
      apps/web/tests/models-settings.e2e.ts
  12. 7 5
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  13. 2 2
      docs/persistence-catalog.i18n.yaml
  14. 11 11
      docs/persistence-catalog.md
  15. 11 11
      docs/persistence-catalog.zh.md
  16. 11 11
      docs/persistence-schema.json
  17. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  18. 2 0
      docs/subsystems/llm-streaming.md
  19. 2 0
      docs/subsystems/llm-streaming.zh.md
  20. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  21. 1 0
      packages/client/ui-primitives/README.md
  22. 1 0
      packages/client/ui-primitives/README.zh.md
  23. 28 0
      packages/client/ui-primitives/src/Checkbox.module.css
  24. 30 0
      packages/client/ui-primitives/src/Checkbox.tsx
  25. 1 0
      packages/client/ui-primitives/src/index.ts
  26. 26 0
      packages/client/ui-primitives/tests/checkbox.client.spec.tsx
  27. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  28. 3 3
      packages/client/ui-settings-models/README.md
  29. 3 3
      packages/client/ui-settings-models/README.zh.md
  30. 33 99
      packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx
  31. 0 61
      packages/client/ui-settings-models/src/client/ModelImageInput.tsx
  32. 61 0
      packages/client/ui-settings-models/src/client/ModelInputTypes.tsx
  33. 68 127
      packages/client/ui-settings-models/src/client/ModelListEditor.tsx
  34. 112 0
      packages/client/ui-settings-models/src/client/ModelRow.tsx
  35. 24 9
      packages/client/ui-settings-models/src/client/ModelsSection.module.css
  36. 3 0
      packages/client/ui-settings-models/src/client/ProviderEditor.tsx
  37. 6 14
      packages/client/ui-settings-models/src/client/locales.ts
  38. 1 1
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  39. 0 50
      packages/client/ui-settings-models/tests/model-image-input.client.spec.tsx
  40. 84 0
      packages/client/ui-settings-models/tests/model-input-types.client.spec.tsx
  41. 56 0
      packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx
  42. 62 44
      packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
  43. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  44. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  45. 1 1
      packages/llm/llm-pi-ai/README.md
  46. 1 1
      packages/llm/llm-pi-ai/README.zh.md
  47. 1 0
      packages/llm/llm-pi-ai/src/discovery.ts
  48. 8 0
      packages/llm/llm-pi-ai/tests/discovery.spec.ts
  49. 2 2
      packages/llm/llm/README.i18n.yaml
  50. 1 1
      packages/llm/llm/README.md
  51. 1 1
      packages/llm/llm/README.zh.md
  52. 1 0
      packages/llm/llm/src/index.ts
  53. 2 0
      packages/llm/llm/src/types.ts
  54. 3 3
      packages/llm/llm/tests/topology.spec.ts
  55. 1 0
      tsconfig.host.json

+ 0 - 27
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md

@@ -1,27 +0,0 @@
-# Agent Note: Model image-input settings
-
-Status: implemented
-
-English | [中文](2026-09-14-model-image-input-settings.zh.md)
-
-## Problem
-
-Models settings can edit a model id without exposing the input capabilities that determine whether image attachments are accepted. A custom vision model can therefore appear in the picker while retaining a text-only declaration.
-
-## Decision
-
-Each model row exposes image input under Model options. Supported declares text and image; Not supported declares text only; Default removes the model's explicit input field. DeepSeek writes `inputModalities`, whose absent value means text only. Pi-ai writes `input`, whose absent or empty value inherits the installed model catalog or provider default. Opening a row preserves that inheritance without materializing an override.
-
-The shared field replaces one drafted row and preserves unrelated metadata. Selecting text only or default for DeepSeek also removes its image request limits, because the adapter rejects those limits without image input. Saving uses the existing catalog-array settings mutation and adapter validation. Configuration declares an upstream capability; it does not add image processing to a text-only model.
-
-## Alternatives considered
-
-**Keep `input` editable only in the settings document.** The [earlier pi-ai modality decision](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md) kept this field outside the model-list editor. That leaves users who add custom vision models through the UI unable to enable their image input there. Per-row editing supplies that configuration while the default choice preserves catalog inheritance.
-
-**A two-state switch.** Treating an absent pi-ai declaration as disabled would misrepresent inherited vision support and encourage overwriting catalog defaults. The explicit Default choice preserves the adapter's existing resolution rules.
-
-**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields.
-
-## Consequences
-
-Users can configure image input for DeepSeek and custom pi-ai model rows through the same control. Restoring defaults can change effective capabilities when the installed catalog or provider defaults change. DeepSeek image limits must be configured again after disabling images. Provider routing and the [catalog recovery rules](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) remain owned by their existing decisions.

+ 0 - 27
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md

@@ -1,27 +0,0 @@
-# Agent Note:模型图片输入设置
-
-Status: implemented
-
-[English](2026-09-14-model-image-input-settings.md) | 中文
-
-## 问题
-
-模型设置可以编辑模型 ID,却没有展示决定图片附件是否被接受的输入能力。因此,自定义视觉模型虽然出现在选择器中,仍可能保留仅文本的声明。
-
-## 决策
-
-每个模型行在「模型选项」下提供图片输入设置。「支持」声明文本和图片;「不支持」声明仅文本;「默认」移除模型的显式输入字段。DeepSeek 写入 `inputModalities`,缺省时表示仅文本。Pi-ai 写入 `input`,缺省或空数组时继承已安装模型目录或提供方默认值。打开模型行保留这种继承,不会生成覆盖值。
-
-共享字段替换一个草稿模型行并保留无关元数据。DeepSeek 选择仅文本或默认时,还会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组设置变更和适配器校验。配置声明上游能力,不会为仅文本模型增加图片处理能力。
-
-## 考虑过的替代方案
-
-**仅允许在设置文档中编辑 `input`。** [早期 pi-ai 输入模态决策](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md)将该字段留在模型列表编辑器之外。这使通过 UI 添加自定义视觉模型的用户无法在同一界面启用图片输入。逐行编辑提供了该配置,而默认选项保留模型目录继承。
-
-**两态开关。** 将缺省的 pi-ai 声明视为禁用,会错误表达继承的视觉能力,并促使用户覆盖模型目录的默认值。显式「默认」选项保留适配器现有的解析规则。
-
-**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,使选择的仅文本状态有效,同时保留无关模型字段。
-
-## 影响
-
-用户可以通过相同控件配置 DeepSeek 和自定义 pi-ai 模型行的图片输入。恢复默认值后,有效能力可能随已安装模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)仍由现有决策负责。

+ 3 - 3
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.i18n.yaml → .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # 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-09-14-model-image-input-settings.md
-2026-09-14-model-image-input-settings.md: ef328400332aa58c02a450ead0195eff124c5fdf
-2026-09-14-model-image-input-settings.zh.md: 70ec427b138026124cad2ffdb1fc5f60597abe8a
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md
+2026-09-16-unified-model-input-controls.md: 8a9320a4d142c461b35de6d907685be66a5836ec
+2026-09-16-unified-model-input-controls.zh.md: c19f62b7ed0e05eee1f770ff01a2047e714773f3

+ 27 - 0
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md

@@ -0,0 +1,27 @@
+# Agent Note: Unified model input controls
+
+Status: implemented
+
+English | [中文](2026-09-16-unified-model-input-controls.zh.md)
+
+## Problem
+
+Separate DeepSeek and pi-ai row renderers let labels, icons, and spacing diverge. An automatic column count moves image input between the capacity row and a second row with small width changes. The image-support selector also cannot express image-only input, although both adapters accept it.
+
+## Decision
+
+Both catalogs render a shared model row. The two capacity fields occupy two columns, and Input types occupies the full next row. Text and Image use the controlled native Checkbox in `ui-primitives`, with caller-owned localized labels and native keyboard behavior. At least one type remains selected; the last checked type is disabled until another is selected.
+
+An undeclared or empty input field displays the installed model input types, then the route default, then Text, without materializing an override on open. Pi-ai catalog discovery carries optional `inputModalities` through the LLM service and Remote response; adopted candidates copy it to `input`. Known provider editors read the installed catalog without endpoint I/O, so existing capacity-only rows also display inherited vision support. Catalog reads for a previous provider are discarded, and input controls wait for the current read to settle. The first checkbox edit writes the exact nonempty selection to DeepSeek's `inputModalities` or pi-ai's `input`. Existing explicit selections, including image-only input, remain visible. Unrelated metadata survives edits; disabling DeepSeek images removes its image request limits because the adapter rejects those limits without image input. Saving uses the existing catalog-array mutation and adapter validation.
+
+## Alternatives considered
+
+**Independent model-row renderers.** Adapter-specific defaults and model discovery remain with their editors, but duplicate presentation creates avoidable visual drift. Shared rows keep those behaviors independent of field layout.
+
+**Keep the image-support selector.** Settings-file-only capability editing leaves custom vision models effectively text-only. A selector with an explicit Default option preserves visible pi-ai catalog inheritance; a two-state image switch would misrepresent inherited vision support as disabled. The requested checkbox interface instead exposes both input types directly, resolving inherited capabilities before enabling edits. Merely opening a row still preserves inheritance; restoring it after a checkbox edit requires removing the input field in settings or resetting the catalog override.
+
+**Allow no checked types.** DeepSeek rejects an empty list and pi-ai treats it as inheritance. Keeping a nonempty selection prevents the same gesture from meaning different things across adapters.
+
+## Consequences
+
+Users can edit text-only, image-only, and combined declarations through one layout. Configuration declares upstream capabilities; it does not add image processing to a text-only model. Removing a pi-ai input declaration can change effective capabilities as catalog or provider defaults change. DeepSeek image limits need reconfiguration after images are disabled. Provider routing and [catalog recovery](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) retain their existing owners. Component checks cover defaults, exact selections, metadata preservation, and disabled controls; browser scenarios cover saved adapter capabilities, reopened selections, the separate input-type row, and installed vision metadata across discovery, adoption, and reopening.

+ 27 - 0
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:统一模型输入控件
+
+Status: implemented
+
+[English](2026-09-16-unified-model-input-controls.md) | 中文
+
+## 问题
+
+DeepSeek 和 pi-ai 分别渲染模型行,导致标签、图标和间距不一致。自动列数会在宽度稍有变化时,将图片输入移到容量字段同一行或下一行。图片支持选择器也无法表达仅图片输入,而两个适配器都接受这种声明。
+
+## 决策
+
+两个模型目录渲染同一个模型行组件。两个容量字段占据两列,输入类型独占下一整行。文本和图片使用 `ui-primitives` 的受控原生 Checkbox(复选框),由调用方提供本地化标签,并保留原生键盘行为。至少保留一种输入类型;最后一个勾选项保持禁用,直到选中另一项。
+
+未声明或为空的输入字段优先显示已安装模型的输入类型,其次是路由默认值,最后回退为文本,打开时不会生成覆盖值。Pi-ai 目录发现将可选的 `inputModalities` 经 LLM 服务和 Remote(远程调用)响应传递给界面;采纳候选时将其复制到 `input`。已知提供方的编辑器读取已安装目录,不向端点发送请求,因此已有的仅容量模型行也能显示继承的视觉能力。上一个提供方的目录响应会被丢弃,输入控件会等待当前读取完成。首次修改复选框会将确切的非空选择写入 DeepSeek 的 `inputModalities` 或 pi-ai 的 `input`。已有显式选择保持可见,包括仅图片输入。编辑保留无关元数据;禁用 DeepSeek 图片时会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组变更和适配器校验。
+
+## 考虑过的替代方案
+
+**独立的模型行渲染器。** 适配器专属默认值和模型发现仍由各自编辑器负责,但重复的展示实现会造成可避免的视觉差异。共享模型行将这些行为与字段布局分离。
+
+**保留图片支持选择器。** 仅通过设置文件编辑能力,会让自定义视觉模型实际保持仅文本。带有显式「默认」选项的选择器会清楚展示 pi-ai 模型目录继承;两态图片开关则会将继承的视觉能力误表示为禁用。用户要求的复选框界面直接展示两种输入类型,并在允许编辑前解析继承的能力。仅打开模型行仍保留继承;修改复选框后,要恢复继承需在设置中移除输入字段,或重置整个模型目录覆盖。
+
+**允许全部取消。** DeepSeek 拒绝空列表,pi-ai 则将其视为继承。保留非空选择,避免同一个操作在两个适配器中具有不同含义。
+
+## 影响
+
+用户通过相同布局编辑仅文本、仅图片和组合声明。配置声明上游能力,不会为仅文本模型增加图片处理能力。移除 pi-ai 输入声明后,有效能力可能随模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)保留现有责任方。组件检查覆盖默认值、确切选择、元数据保留和禁用控件;浏览器场景覆盖保存后的适配器能力、重新打开后的选择,独占一行的输入类型,以及发现、采纳和重新打开过程中传递的内置视觉元数据。

+ 84 - 0
apps/web/tests/expected/models-settings/catalog-inputs.expected.md

@@ -0,0 +1,84 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "插件":
+      - img
+      - text: 插件
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+    - button "已归档会话":
+      - img
+      - text: 已归档会话
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: minimax-cn
+      - img "API 密钥已配置"
+      - button "编辑 minimax-cn": 编辑
+      - button "删除 minimax-cn": 删除
+    - listitem:
+      - text: openai
+      - button "编辑 openai": 编辑
+      - button "删除 openai": 删除
+      - text: openai API 密钥
+      - textbox "API 密钥":
+        - /placeholder: 输入 API 密钥,或留空使用环境认证
+      - group:
+        - text: 自定义设置 API 地址
+        - textbox "API 地址":
+          - /placeholder: 提供方默认
+        - region "模型目录":
+          - text: 模型目录 已自定义模型目录
+          - button "恢复默认模型"
+          - button "获取可用模型"
+          - textbox "模型 ID 1":
+            - /placeholder: 模型 ID
+            - text: gpt-6-astra
+          - textbox "显示名称 1":
+            - /placeholder: 显示名称
+            - text: GPT-6 Astra
+          - button "模型选项 1" [expanded]:
+            - img
+          - button "删除模型 1":
+            - img
+          - text: 上下文窗口
+          - textbox "上下文窗口 1":
+            - /placeholder: 256K
+            - text: 272K
+          - text: 最大输出 token 数
+          - textbox "最大输出 token 数 1":
+            - /placeholder: 32K
+            - text: 128K
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
+          - button "添加模型":
+            - img
+            - text: 添加模型
+      - button "取消"
+      - button "保存"
+    - listitem:
+      - text: Acme 网关 自定义
+      - button "编辑 Acme 网关 (acme-gateway)": 编辑
+      - button "删除 Acme 网关 (acme-gateway)": 删除
+  - button "添加提供方":
+    - img
+    - text: 添加提供方
+  - button "添加自定义提供方":
+    - img
+    - text: 添加自定义提供方

+ 15 - 10
apps/web/tests/expected/models-settings/declared-edit.expected.md

@@ -58,20 +58,25 @@
             - text: acme-large
           - textbox "显示名称 1":
             - /placeholder: 显示名称
-          - button "模型选项 1" [expanded]
-          - button "删除模型 1"
+          - button "模型选项 1" [expanded]:
+            - img
+          - button "删除模型 1":
+            - img
           - text: 上下文窗口
           - textbox "上下文窗口 1":
             - /placeholder: 256K
-          - text: 最大输出 token
-          - textbox "最大输出 token 1":
+          - text: 最大输出 token 数
+          - textbox "最大输出 token 数 1":
             - /placeholder: 32K
-          - text: 图片输入
-          - combobox "图片输入 1":
-            - option "使用默认值"
-            - option "支持" [selected]
-            - option "不支持"
-          - button "添加模型"
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
+          - button "添加模型":
+            - img
+            - text: 添加模型
       - button "取消"
       - button "保存"
   - button "添加提供方":

+ 6 - 5
apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md

@@ -54,11 +54,12 @@
           - text: 最大输出 token 数
           - textbox "最大输出 token 数 1":
             - /placeholder: 256K
-          - text: 图片输入
-          - combobox "图片输入 1":
-            - option "默认(仅文本)"
-            - option "支持" [selected]
-            - option "不支持"
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
           - textbox "模型 ID 2":
             - /placeholder: 模型 ID
             - text: deepseek-v4-pro

+ 6 - 5
apps/web/tests/expected/onboarding-deepseek-config/models.expected.md

@@ -56,11 +56,12 @@
           - textbox "最大输出 token 数 1":
             - /placeholder: 256K
             - text: 64K
-          - text: 图片输入
-          - combobox "图片输入 1":
-            - option "默认(仅文本)"
-            - option "支持" [selected]
-            - option "不支持"
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
           - button "添加模型":
             - img
             - text: 添加模型

+ 25 - 0
apps/web/tests/model-input-layout.ts

@@ -0,0 +1,25 @@
+/** Model input types stay below both capacity fields across editor widths. */
+import type { Locator, Page } from 'playwright'
+import { expect } from 'vitest'
+
+/** Verify the expanded row at the current viewport and a narrow viewport, restoring the caller's size. */
+export async function assertModelInputLayout(page: Page, editor: Locator): Promise<void> {
+  const original = page.viewportSize()
+  try {
+    for (const viewport of [original, { width: 760, height: 1000 }]) {
+      if (viewport !== null) await page.setViewportSize(viewport)
+      const context = await editor.getByLabel('上下文窗口 1', { exact: true }).boundingBox()
+      const output = await editor.getByLabel('最大输出 token 数 1', { exact: true }).boundingBox()
+      const types = await editor.getByRole('group', { name: '输入类型 1' }).boundingBox()
+      expect(context).not.toBeNull()
+      expect(output).not.toBeNull()
+      expect(types).not.toBeNull()
+      if (context === null || output === null || types === null) throw new Error('Expanded model fields are not visible')
+      expect(Math.abs(context.y - output.y)).toBeLessThan(1)
+      expect(types.y).toBeGreaterThanOrEqual(Math.max(context.y + context.height, output.y + output.height))
+      expect(types.width).toBeGreaterThan(context.width)
+    }
+  } finally {
+    if (original !== null) await page.setViewportSize(original)
+  }
+}

+ 72 - 11
apps/web/tests/models-settings.e2e.ts

@@ -13,6 +13,7 @@
 // never shadow the derived reference. The deletion dialog distinguishes a
 // reference-free profile from a page-managed key before the credential and
 // settings unsets reach the wire.
+import { assertModelInputLayout } from './model-input-layout.ts'
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -239,8 +240,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await dialog.getByRole('button', { name: '添加模型' }).click()
     await dialog.getByLabel('模型 ID 1').fill('acme-large')
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('default')
-    await dialog.getByLabel('图片输入 1').selectOption('enabled')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
+    await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
     await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
 
     const row = dialog.getByText('Acme Gateway', { exact: true }).first()
@@ -276,13 +277,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     const name = dialog.getByLabel('显示名称', { exact: true })
     expect(await name.inputValue()).toBe('Acme Gateway')
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('enabled')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
+    await assertModelInputLayout(page, dialog)
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
 
     await protocol.selectOption('anthropic-messages')
     await name.fill('Acme 网关')
-    await dialog.getByLabel('图片输入 1').selectOption('disabled')
+    await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).uncheck()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
     await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
     // The adapter re-resolved the route under the new protocol and re-registered
@@ -302,27 +304,86 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('restores the provider default for image input', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-image-default'))
+  it('saves image-only input and reopens the same selection', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-input-types'))
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
     await dialog.getByText('自定义设置').click()
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('disabled')
-    await dialog.getByLabel('图片输入 1').selectOption('default')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
+    const inputs = dialog.getByRole('group', { name: '输入类型 1' })
+    expect(await inputs.getByRole('checkbox', { name: '文本' }).isChecked()).toBe(true)
+    expect(await inputs.getByRole('checkbox', { name: '文本' }).isDisabled()).toBe(true)
+    await inputs.getByRole('checkbox', { name: '图片' }).check()
+    await inputs.getByRole('checkbox', { name: '文本' }).uncheck()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
     await dialog.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 10_000 })
     await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
-      inputModalities: ['text'],
+      inputModalities: ['image'],
     })
     await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
     await dialog.getByText('自定义设置').click()
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('default')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
+    expect(await inputs.getByRole('checkbox', { name: '文本' }).isChecked()).toBe(false)
+    expect(await inputs.getByRole('checkbox', { name: '图片' }).isDisabled()).toBe(true)
     await dialog.getByRole('button', { name: '取消', exact: true }).click()
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('inherits installed vision input and retains it when adopting a discovered model', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-catalog-inputs'))
+    await scaffold.ctx.settings.mutate('llm-pi-ai', [{
+      op: 'set', path: ['providers', 'openai'],
+      value: { models: [{ id: 'gpt-6-astra', name: 'GPT-6 Astra', contextWindow: 272000, maxTokens: 128000 }] },
+    }])
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    const edit = dialog.getByRole('button', { name: '编辑 openai', exact: true })
+    await edit.click()
+    await dialog.getByText('自定义设置').click()
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    const types = dialog.getByRole('group', { name: '输入类型 1' })
+    const image = types.getByRole('checkbox', { name: '图片', exact: true })
+    await expect.poll(() => image.isChecked()).toBe(true)
+    expect(await types.getByRole('checkbox', { name: '文本', exact: true }).isChecked()).toBe(true)
+    const before = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'catalog-inputs.expected.md'),
+      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await types.waitFor({ state: 'detached' })
+    expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toBe(before)
+
+    await edit.click()
+    await dialog.getByText('自定义设置').click()
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    await expect.poll(() => image.isEnabled()).toBe(true)
+    await image.uncheck()
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await types.waitFor({ state: 'detached' })
+    await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] })
+    await edit.click()
+    await dialog.getByText('自定义设置').click()
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    await expect.poll(() => image.isEnabled()).toBe(true)
+    expect(await image.isChecked()).toBe(false)
+
+    await dialog.getByRole('button', { name: '删除模型 1' }).click()
+    await dialog.getByRole('button', { name: '获取可用模型' }).click()
+    const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
+    await picker.getByRole('button', { name: '取消全选' }).click()
+    await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
+    await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
+    await picker.getByRole('button', { name: '添加所选' }).click()
+    await dialog.getByRole('button', { name: '模型选项 1' }).click()
+    expect(await image.isChecked()).toBe(true)
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await types.waitFor({ state: 'detached' })
+    await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text', 'image'] })
+    await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
+    await edit.waitFor({ state: 'detached' })
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('confirms an identified provider deletion before removing its profile and key', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
     const settingsDialog = page.getByRole('dialog', { name: '设置' })
@@ -359,7 +420,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
       'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md',
-      'native-delete.expected.md',
+      'native-delete.expected.md', 'catalog-inputs.expected.md',
     ])
   })
 })

+ 7 - 5
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -3,6 +3,7 @@
 // and the inline key write lands in an isolated harness home without a reload
 // or model call.
 import { randomBytes } from 'node:crypto'
+import { assertModelInputLayout } from './model-input-layout.ts'
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -208,11 +209,12 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro')
     expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2)
     await settings.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('enabled')
+    expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
+    await assertModelInputLayout(page, settings)
     const defaultModels = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DEFAULT_MODELS_EXPECTED, defaultModels, MODE)
     await settings.getByLabel('显示名称 1').fill('Configured Flash')
-    await settings.getByLabel('图片输入 1').selectOption('disabled')
+    await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).uncheck()
     await settings.getByRole('button', { name: '保存', exact: true }).click()
     await settings.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 15_000 })
     const savedDefaults = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
@@ -238,8 +240,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await settings.getByRole('button', { name: '模型选项 1' }).click()
     await settings.getByLabel('上下文窗口 1').fill('131072')
     await settings.getByLabel('最大输出 token 数 1').fill('64K')
-    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('default')
-    await settings.getByLabel('图片输入 1').selectOption('enabled')
+    expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
+    await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
 
     await expect.poll(
       () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
@@ -262,7 +264,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
     await settings.getByText('自定义设置').click()
     await settings.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('enabled')
+    expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
     await settings.getByRole('button', { name: '取消', exact: true }).click()
 
     await page.keyboard.press('Escape')

+ 2 - 2
docs/persistence-catalog.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 docs/persistence-catalog.md
-persistence-catalog.md: 99c24562c178b35944e5616729076d015eb3f4c6
-persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0
+persistence-catalog.md: b1a0fac7826487e46d5b4cf802cb46dc8bf62bfc
+persistence-catalog.zh.md: bb1b704eccbd9aa53bb67054a972f1ba0bdf0bb9

+ 11 - 11
docs/persistence-catalog.md

@@ -2166,7 +2166,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:44`](../packages/llm/llm/src
 
 SHA-256: `647cb2e3aeb58e27d1d4d60011f208bb66dce257e8654193c533b47c4c22c253`
 
-Sources: [`packages/llm/llm/src/types.ts:425`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2180,7 +2180,7 @@ Sources: [`packages/llm/llm/src/types.ts:425`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
 
-Sources: [`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2194,7 +2194,7 @@ Sources: [`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
 
-Sources: [`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2208,7 +2208,7 @@ Sources: [`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
 
-Sources: [`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2224,7 +2224,7 @@ Sources: [`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `90c811b8c2668898515e69a24e82caffeb08517f9c7aa193867ff0380f3b535d`
 
-Sources: [`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2238,7 +2238,7 @@ Sources: [`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
 
-Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:432`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2251,7 +2251,7 @@ Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1`
 
-Sources: [`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:433`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -4205,7 +4205,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8`
 
 SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5`
 
-Sources: [`packages/llm/llm/src/types.ts:381`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:383`](../packages/llm/llm/src/types.ts)
 
 `"in-history"`
 
@@ -5345,7 +5345,7 @@ Sources: [`packages/llm/llm/src/types.ts:67`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
 
-Sources: [`packages/llm/llm/src/types.ts:402`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:404`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -5358,7 +5358,7 @@ Sources: [`packages/llm/llm/src/types.ts:402`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `071d92b9dac661fcc826c678cf64d188f26fb93aab79c627b11a03acdd34dfca`
 
-Sources: [`packages/llm/llm/src/types.ts:424`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
 
 One of:
 
@@ -5423,7 +5423,7 @@ Sources: [`packages/llm/llm/src/types.ts:114`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `7f5f2a8618c2ece530a18b1358b890fb57fb8c35705e47b66bff2296f8c6e889`
 
-Sources: [`packages/llm/llm/src/types.ts:445`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:447`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|

+ 11 - 11
docs/persistence-catalog.zh.md

@@ -2168,7 +2168,7 @@ SHA-256: `c93b6f51b399607349b4bcc66b58e848a44250ffc1e2537207bd092dfff94192`
 
 SHA-256: `647cb2e3aeb58e27d1d4d60011f208bb66dce257e8654193c533b47c4c22c253`
 
-来源:[`packages/llm/llm/src/types.ts:425`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2182,7 +2182,7 @@ SHA-256: `647cb2e3aeb58e27d1d4d60011f208bb66dce257e8654193c533b47c4c22c253`
 
 SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
 
-来源:[`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2196,7 +2196,7 @@ SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
 
 SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
 
-来源:[`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2210,7 +2210,7 @@ SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
 
 SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
 
-来源:[`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2226,7 +2226,7 @@ SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
 
 SHA-256: `90c811b8c2668898515e69a24e82caffeb08517f9c7aa193867ff0380f3b535d`
 
-来源:[`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2240,7 +2240,7 @@ SHA-256: `90c811b8c2668898515e69a24e82caffeb08517f9c7aa193867ff0380f3b535d`
 
 SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
 
-来源:[`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:432`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2253,7 +2253,7 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
 
 SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1`
 
-来源:[`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:433`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -4207,7 +4207,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8`
 
 SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5`
 
-来源:[`packages/llm/llm/src/types.ts:381`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:383`](../packages/llm/llm/src/types.ts)
 
 `"in-history"`
 
@@ -5347,7 +5347,7 @@ SHA-256: `814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e`
 
 SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
 
-来源:[`packages/llm/llm/src/types.ts:402`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:404`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -5360,7 +5360,7 @@ SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
 
 SHA-256: `071d92b9dac661fcc826c678cf64d188f26fb93aab79c627b11a03acdd34dfca`
 
-来源:[`packages/llm/llm/src/types.ts:424`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
 
 以下类型之一:
 
@@ -5425,7 +5425,7 @@ SHA-256: `d61574d68558c5a184429e70b04da393dd5460bf5cc3ddef814546e659bd5127`
 
 SHA-256: `7f5f2a8618c2ece530a18b1358b890fb57fb8c35705e47b66bff2296f8c6e889`
 
-来源:[`packages/llm/llm/src/types.ts:445`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:447`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|

+ 11 - 11
docs/persistence-schema.json

@@ -14533,7 +14533,7 @@
         "packages/llm/llm/src/types.ts#StreamChunk"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:424"
+        "packages/llm/llm/src/types.ts:426"
       ]
     },
     {
@@ -14793,7 +14793,7 @@
         "packages/llm/llm/src/types.ts#ReplayEnvelope"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:402"
+        "packages/llm/llm/src/types.ts:404"
       ]
     },
     {
@@ -23535,7 +23535,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[2]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:427"
+        "packages/llm/llm/src/types.ts:429"
       ]
     },
     {
@@ -36760,7 +36760,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[0]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:425"
+        "packages/llm/llm/src/types.ts:427"
       ]
     },
     {
@@ -40656,7 +40656,7 @@
         "packages/llm/llm/src/types.ts#ToolSchema"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:445"
+        "packages/llm/llm/src/types.ts:447"
       ]
     },
     {
@@ -42233,7 +42233,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[3]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:428"
+        "packages/llm/llm/src/types.ts:430"
       ]
     },
     {
@@ -43908,7 +43908,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[4]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:429"
+        "packages/llm/llm/src/types.ts:431"
       ]
     },
     {
@@ -44283,7 +44283,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[1]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:426"
+        "packages/llm/llm/src/types.ts:428"
       ]
     },
     {
@@ -46661,7 +46661,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[6]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:431"
+        "packages/llm/llm/src/types.ts:433"
       ]
     },
     {
@@ -59727,7 +59727,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[5]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:430"
+        "packages/llm/llm/src/types.ts:432"
       ]
     },
     {
@@ -59808,7 +59808,7 @@
         "packages/llm/llm/src/types.ts#SystemPromptUpdate"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:381"
+        "packages/llm/llm/src/types.ts:383"
       ]
     },
     {

+ 2 - 2
docs/subsystems/llm-streaming.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 docs/subsystems/llm-streaming.md
-llm-streaming.md: 6d29664f4362a3196d3ba7526c6fb18e20b034ad
-llm-streaming.zh.md: 3c60ae3935f745e11b850990c98a75208fb1c9f3
+llm-streaming.md: 30dd26042b4894b294a67918006d9b607eadc6b2
+llm-streaming.zh.md: 6e5f1ff268e0c22a37c64a8f954c134bf71b3b73

+ 2 - 0
docs/subsystems/llm-streaming.md

@@ -704,6 +704,8 @@ interface LlmDiscoveredModel {
   contextWindow?: number
   /** Maximum output tokens, when disclosed. */
   maxTokens?: number
+  /** Accepted input types when disclosed by the catalog or endpoint; absent means unknown. */
+  inputModalities?: readonly ModelModality[]
 }
 ```
 

+ 2 - 0
docs/subsystems/llm-streaming.zh.md

@@ -710,6 +710,8 @@ interface LlmDiscoveredModel {
   contextWindow?: number
   /** Maximum output tokens, when disclosed. */
   maxTokens?: number
+  /** Accepted input types when disclosed by the catalog or endpoint; absent means unknown. */
+  inputModalities?: readonly ModelModality[]
 }
 ```
 

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
-README.md: 5e8b41a3119610ed7ca40f9ca974cc56cfc50cd7
-README.zh.md: 06b56d6dcd629aa1faef36950e6c884a1d863fee
+README.md: 9290831ca4f33899a88e61e8fdd8f9951de905fa
+README.zh.md: b865c49c86df5f2269cea90be9d93bfa3abdff51

+ 1 - 0
packages/client/ui-primitives/README.md

@@ -38,6 +38,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 |---|---|
 | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. |
 | `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
+| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
 | `Input` | Single-line text entry for search boxes and inline forms. |
 | `Menu` | Dropdown of items, separators, and group labels, with nested submenus. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
 | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |

+ 1 - 0
packages/client/ui-primitives/README.zh.md

@@ -38,6 +38,7 @@ kind: "package-library"
 |---|---|
 | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 |
 | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
+| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
 | `Input` | 单行文本输入,用于搜索框与行内表单。 |
 | `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
 | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |

+ 28 - 0
packages/client/ui-primitives/src/Checkbox.module.css

@@ -0,0 +1,28 @@
+.checkbox {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 14px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-primary);
+  cursor: pointer;
+}
+
+.checkbox input {
+  flex: 0 0 auto;
+  width: 16px;
+  height: 16px;
+  margin: 0;
+  accent-color: var(--dsw-alias-brand-primary);
+  cursor: inherit;
+}
+
+.checkbox input:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 2px;
+}
+
+.checkbox:has(input:disabled) {
+  cursor: default;
+  opacity: 0.5;
+}

+ 30 - 0
packages/client/ui-primitives/src/Checkbox.tsx

@@ -0,0 +1,30 @@
+/** Controlled native checkbox with a caller-owned visible and accessible label. */
+
+import clsx from 'clsx'
+import css from './Checkbox.module.css'
+
+/**
+ * Render a labeled checkbox with native keyboard and form semantics.
+ * @param props.checked - current checked state.
+ * @param props.onChange - receives the requested checked state.
+ * @param props.label - localized visible and accessible label.
+ * @param props.disabled - whether the control refuses changes.
+ * @param props.title - optional localized hover text.
+ * @param props.className - extra class for the label's placement.
+ * @returns the label containing its checkbox.
+ */
+export function Checkbox({ checked, onChange, label, disabled = false, title, className }: {
+  checked: boolean
+  onChange: (next: boolean) => void
+  label: string
+  disabled?: boolean
+  title?: string | undefined
+  className?: string | undefined
+}) {
+  return (
+    <label className={clsx(css.checkbox, className)} title={title}>
+      <input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => { onChange(event.target.checked) }} />
+      <span>{label}</span>
+    </label>
+  )
+}

+ 1 - 0
packages/client/ui-primitives/src/index.ts

@@ -12,6 +12,7 @@ export { Pill } from './Pill.tsx'
 export { Tag } from './Tag.tsx'
 export type { TagTone } from './Tag.tsx'
 export { Switch } from './Switch.tsx'
+export { Checkbox } from './Checkbox.tsx'
 export { Input } from './Input.tsx'
 export { Menu } from './Menu.tsx'
 export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'

+ 26 - 0
packages/client/ui-primitives/tests/checkbox.client.spec.tsx

@@ -0,0 +1,26 @@
+// @vitest-environment jsdom
+/** The shared checkbox keeps native label activation and controlled state. */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { Checkbox } from '../src/Checkbox.tsx'
+
+afterEach(cleanup)
+
+it('requests a checked state through its visible label and follows the owner', () => {
+  const onChange = vi.fn()
+  const { rerender } = render(<Checkbox label="Images" checked={false} onChange={onChange} />)
+  fireEvent.click(screen.getByText('Images'))
+  expect(onChange).toHaveBeenLastCalledWith(true)
+  expect(screen.getByRole<HTMLInputElement>('checkbox', { name: 'Images' }).checked).toBe(false)
+  rerender(<Checkbox label="Images" checked onChange={onChange} />)
+  fireEvent.click(screen.getByRole('checkbox'))
+  expect(onChange).toHaveBeenLastCalledWith(false)
+})
+
+it('refuses label activation while disabled', () => {
+  const onChange = vi.fn()
+  render(<Checkbox label="Images" checked disabled onChange={onChange} />)
+  fireEvent.click(screen.getByText('Images'))
+  expect(onChange).not.toHaveBeenCalled()
+  expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(true)
+})

+ 2 - 2
packages/client/ui-settings-models/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-settings-models/README.md
-README.md: a26cd22f8785f8f2c948ac74560ff85d80097577
-README.zh.md: 82b9e031b477aa83c59acdfbade38f8282adefbd
+README.md: 6feb95d987f11bcf81b9bfa2c67068567244dcec
+README.zh.md: 9316d698bdf11f630f5815b9fffd0bbdc4546031

+ 3 - 3
packages/client/ui-settings-models/README.md

@@ -35,15 +35,15 @@ The primary field on an editor card is a single **API key** input — the page n
 
 ### Editing a provider
 
-The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and image input; unrelated model fields survive edits.
+The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits.
 
 The DeepSeek card edits the shared `llm-deepseek` endpoint, credentials, and model catalog without a protocol selector. When Cordis YAML selects Messages, the public endpoint placeholder is `https://api.deepseek.com/anthropic`. Saving the card preserves protocol configuration.
 
-Expand **Customized settings → Model options → Image input** to declare whether the model supports images. **Supported** writes text and image input; **Not supported** writes text only. DeepSeek's **Default (text only)** removes `inputModalities`; an absent declaration means text only. Pi-ai's **Use default** removes `input` and inherits the installed model catalog or provider default. Selecting **Not supported** or **Default (text only)** for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, which its adapter rejects without image input. Declare support only for models that can actually process images.
+Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `settings.yaml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process.
 
 ### Adding and deleting providers
 
-The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
+The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
 
 ### First-run dialogs
 

+ 3 - 3
packages/client/ui-settings-models/README.zh.md

@@ -35,15 +35,15 @@ kind: "package-reference"
 
 ### 编辑提供方
 
-收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和图片输入;无关的模型字段在编辑后仍会保留。
+收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。
 
 `llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。
 
-展开**自定义设置 → 模型选项 → 图片输入**,声明模型是否支持图片。选择**支持**会写入文本和图片输入;选择**不支持**会写入仅文本。DeepSeek 的**默认(仅文本)**会移除 `inputModalities`;缺省的声明表示仅文本。Pi-ai 的**使用默认值**会移除 `input`,继承已安装模型目录或提供方的默认值。DeepSeek 选择**不支持**或**默认(仅文本)**时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。仅为实际能够处理图片的模型声明支持。
+展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
 
 ### 新增与删除提供方
 
-「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
+「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
 
 ### 首次运行弹窗
 

+ 33 - 99
packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx

@@ -7,11 +7,9 @@
 
 import { useState } from 'react'
 import type { ReactNode } from 'react'
-import {
-  IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16,
-} from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { en } from './locales.ts'
-import { ModelImageInput } from './ModelImageInput.tsx'
+import { ModelRow } from './ModelRow.tsx'
 import styles from './ModelsSection.module.css'
 
 /** One catalog entry kept structurally open so hidden or future fields survive an edit. */
@@ -145,7 +143,7 @@ export interface DeepSeekModelsEditorProps {
 
 /**
  * Render the direct DeepSeek adapter's model catalog: id and display name on
- * each row, capacities and image support behind the row's own disclosure.
+ * each row, capacities and input types behind the row's own disclosure.
  * @param props - effective rows plus the array-level override actions.
  * @returns the catalog editor.
  */
@@ -234,34 +232,17 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
     })
   }
 
-  /** One capacity field of one row, rendered inside the row's disclosure. */
-  const capacityField = (
-    model: DeepSeekModelDraft,
-    index: number,
-    field: CapacityField,
-    fallback: number | undefined,
-  ): ReactNode => (
-    <label className={styles['modelField']}>
-      <span className={styles['modelFieldLabel']}>{props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')}</span>
-      <input
-        className={styles['input']}
-        type="text"
-        inputMode="numeric"
-        value={capacityText(model, index, field)}
-        placeholder={fallback === undefined
-          ? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder')
-          : formatCapacity(fallback)}
-        aria-label={`${props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')} ${String(index + 1)}`}
-        disabled={props.disabled}
-        onChange={(event) => {
-          const text = event.target.value
-          setEditing(current => new Map(current).set(`${String(index)}:${field}`, text))
-          update(index, field, parseCapacity(text))
-        }}
-        onBlur={() => { settleCapacity(index, field) }}
-      />
-    </label>
-  )
+  const capacityInput = (model: DeepSeekModelDraft, index: number, field: CapacityField, fallback: number | undefined) => ({
+    value: capacityText(model, index, field),
+    placeholder: fallback === undefined
+      ? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder')
+      : formatCapacity(fallback),
+    onChange: (text: string) => {
+      setEditing(current => new Map(current).set(`${String(index)}:${field}`, text))
+      update(index, field, parseCapacity(text))
+    },
+    onBlur: () => { settleCapacity(index, field) },
+  })
 
   return (
     <section className={styles['modelCatalog']} aria-label={props.t('models')}>
@@ -290,72 +271,25 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
         : (
           <div className={styles['modelList']}>
             {props.models.map((model, index) => (
-              <div className={styles['modelEntry']} key={index}>
-                <div className={styles['modelRow']}>
-                  <input
-                    className={styles['input']}
-                    type="text"
-                    value={typeof model['id'] === 'string' ? model['id'] : ''}
-                    placeholder={props.t('modelId')}
-                    aria-label={`${props.t('modelId')} ${String(index + 1)}`}
-                    disabled={props.disabled}
-                    onChange={(event) => { update(index, 'id', event.target.value) }}
-                    onBlur={(event) => {
-                      // Settle a pasted id rather than trimming per keystroke,
-                      // which would stop the user typing an interior space.
-                      const trimmed = event.target.value.trim()
-                      if (trimmed !== event.target.value) update(index, 'id', trimmed)
-                    }}
-                  />
-                  <input
-                    className={styles['input']}
-                    type="text"
-                    value={typeof model['name'] === 'string' ? model['name'] : ''}
-                    placeholder={props.t('modelName')}
-                    aria-label={`${props.t('modelName')} ${String(index + 1)}`}
-                    disabled={props.disabled}
-                    onChange={(event) => {
-                      update(index, 'name', event.target.value === '' ? undefined : event.target.value)
-                    }}
-                  />
-                  <button
-                    type="button"
-                    className={styles['iconButton']}
-                    aria-label={`${props.t('modelAdvanced')} ${String(index + 1)}`}
-                    aria-expanded={expanded.has(index)}
-                    title={props.t('modelAdvanced')}
-                    onClick={() => { toggle(index) }}
-                  >
-                    {expanded.has(index) ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
-                  </button>
-                  <button
-                    type="button"
-                    className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
-                    aria-label={`${props.t('removeModel')} ${String(index + 1)}`}
-                    title={props.t('removeModel')}
-                    disabled={props.disabled}
-                    onClick={() => { remove(index) }}
-                  >
-                    <IconTrashOutline16 size={14} />
-                  </button>
-                </div>
-                {expanded.has(index)
-                  ? (
-                    <div className={styles['modelAdvanced']}>
-                      {capacityField(model, index, 'contextWindow', props.defaultContextWindow)}
-                      {capacityField(model, index, 'maxTokens', props.defaultMaxTokens)}
-                      <ModelImageInput
-                        model={model}
-                        field="inputModalities"
-                        position={index + 1}
-                        disabled={props.disabled}
-                        t={props.t}
-                        onChange={(next) => { props.onChange(props.models.map((row, at) => at === index ? next : row)) }}
-                      />
-                    </div>
-                  )
-                  : null}
-              </div>
+              <ModelRow
+                key={index}
+                model={model}
+                position={index + 1}
+                inputField="inputModalities"
+                expanded={expanded.has(index)}
+                disabled={props.disabled}
+                t={props.t}
+                contextWindow={capacityInput(model, index, 'contextWindow', props.defaultContextWindow)}
+                maxTokens={capacityInput(model, index, 'maxTokens', props.defaultMaxTokens)}
+                onFieldChange={(field, value) => { update(index, field, value) }}
+                onIdBlur={(value) => {
+                  const trimmed = value.trim()
+                  if (trimmed !== value) update(index, 'id', trimmed)
+                }}
+                onChange={(next) => { props.onChange(props.models.map((row, at) => at === index ? next : row)) }}
+                onToggle={() => { toggle(index) }}
+                onRemove={() => { remove(index) }}
+              />
             ))}
           </div>
         )}

+ 0 - 61
packages/client/ui-settings-models/src/client/ModelImageInput.tsx

@@ -1,61 +0,0 @@
-/** Image-input declarations shared by the DeepSeek and pi-ai catalog editors. */
-
-import type { ReactNode } from 'react'
-import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
-import type { ModelsKey } from './locales.ts'
-import styles from './ModelsSection.module.css'
-
-/** Props of {@link ModelImageInput}. */
-interface ModelImageInputProps {
-  /** Effective model row, including fields outside the curated editor. */
-  model: DeepSeekModelDraft
-  /** Adapter-owned field; pi-ai inherits capabilities when absent or empty. */
-  field: 'inputModalities' | 'input'
-  /** One-based row position for the accessible label. */
-  position: number
-  /** Prevent changes while read-only or saving. */
-  disabled: boolean
-  /** Section copy. */
-  t: (key: ModelsKey) => string
-  /** Replace this row, preserving unrelated configuration. */
-  onChange: (model: DeepSeekModelDraft) => void
-}
-
-/**
- * Edit image support while retaining an explicit choice to inherit defaults.
- * @param props - model declaration and row replacement action.
- * @returns the labeled image-input selector.
- */
-export function ModelImageInput({ model, field, position, disabled, t, onChange }: ModelImageInputProps): ReactNode {
-  const modalities = model[field]
-  const value = !Array.isArray(modalities) || modalities.length === 0
-    ? 'default'
-    : modalities.includes('image') ? 'enabled' : 'disabled'
-  return (
-    <label className={styles['modelField']}>
-      <span className={styles['modelFieldLabel']}>{t('modelImageInput')}</span>
-      <select
-        className={`${styles['input']} ${styles['selectInput']}`}
-        aria-label={`${t('modelImageInput')} ${String(position)}`}
-        value={value}
-        disabled={disabled}
-        onChange={(event) => {
-          const choice = event.target.value
-          const next = { ...model }
-          if (choice === 'default') Reflect.deleteProperty(next, field)
-          else next[field] = choice === 'enabled' ? ['text', 'image'] : ['text']
-          // DeepSeek rejects image request limits on a text-only model.
-          if (field === 'inputModalities' && choice !== 'enabled') {
-            Reflect.deleteProperty(next, 'imagePixelBudget')
-            Reflect.deleteProperty(next, 'imageMaxBytes')
-          }
-          onChange(next)
-        }}
-      >
-        <option value="default">{t(field === 'inputModalities' ? 'modelImageDefaultText' : 'modelImageDefault')}</option>
-        <option value="enabled">{t('modelImageEnabled')}</option>
-        <option value="disabled">{t('modelImageDisabled')}</option>
-      </select>
-    </label>
-  )
-}

+ 61 - 0
packages/client/ui-settings-models/src/client/ModelInputTypes.tsx

@@ -0,0 +1,61 @@
+/** Input-type declarations shared by the DeepSeek and pi-ai catalog editors. */
+
+import type { ReactNode } from 'react'
+import { Checkbox } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
+import type { ModelsKey } from './locales.ts'
+import styles from './ModelsSection.module.css'
+
+/** Props of {@link ModelInputTypes}. */
+interface ModelInputTypesProps {
+  /** Effective model row, including fields outside the curated editor. */
+  model: DeepSeekModelDraft
+  /** Adapter-owned field; pi-ai inherits capabilities when absent or empty. */
+  field: 'inputModalities' | 'input'
+  /** One-based row position for the accessible group label. */
+  position: number
+  /** Prevent changes while read-only or saving. */
+  disabled: boolean
+  /** Installed model or provider defaults when the row does not declare input types. */
+  fallback?: readonly string[] | undefined
+  /** Section copy. */
+  t: (key: ModelsKey) => string
+  /** Replace this row, preserving unrelated configuration. */
+  onChange: (model: DeepSeekModelDraft) => void
+}
+
+/**
+ * Edit a nonempty set of input types, displaying inherited types before an override exists.
+ * @param props - model declaration and row replacement action.
+ * @returns the labeled text and image checkboxes.
+ */
+export function ModelInputTypes({ model, field, position, disabled, fallback, t, onChange }: ModelInputTypesProps): ReactNode {
+  const modalities = model[field]
+  const selected = Array.isArray(modalities) && modalities.length > 0 ? modalities : fallback ?? ['text']
+  return (
+    <fieldset className={styles['modelInputTypes']} aria-label={`${t('modelInputTypes')} ${String(position)}`}>
+      <legend className={styles['modelFieldLabel']}>{t('modelInputTypes')}</legend>
+      <div className={styles['modelInputChoices']}>
+        {(['text', 'image'] as const).map(modality => (
+          <Checkbox
+            key={modality}
+            label={t(modality === 'text' ? 'modelInputText' : 'modelInputImage')}
+            checked={selected.includes(modality)}
+            disabled={disabled || (selected.length === 1 && selected.includes(modality))}
+            onChange={(checked) => {
+              const nextSelected = (['text', 'image'] as const).filter(value =>
+                value === modality ? checked : selected.includes(value))
+              const next = { ...model, [field]: nextSelected }
+              // DeepSeek rejects image request limits on a text-only model.
+              if (field === 'inputModalities' && !nextSelected.includes('image')) {
+                Reflect.deleteProperty(next, 'imagePixelBudget')
+                Reflect.deleteProperty(next, 'imageMaxBytes')
+              }
+              onChange(next)
+            }}
+          />
+        ))}
+      </div>
+    </fieldset>
+  )
+}

+ 68 - 127
packages/client/ui-settings-models/src/client/ModelListEditor.tsx

@@ -14,15 +14,15 @@
  * rows the user can still fill in by hand.
  */
 
-import { useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
 import type { ReactNode } from 'react'
 import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client'
-import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
 import type { ModelsOperations } from './operations.ts'
 import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
 import type { en } from './locales.ts'
-import { ModelImageInput } from './ModelImageInput.tsx'
+import { ModelRow } from './ModelRow.tsx'
 import styles from './ModelsSection.module.css'
 
 /**
@@ -65,6 +65,10 @@ export interface ProbeTarget {
 export interface ModelListEditorProps {
   /** The rows as currently drafted. */
   models: readonly ModelDraft[]
+  /** Installed provider whose catalog supplies defaults without endpoint I/O. */
+  catalogProvider?: string | undefined
+  /** Route input types for models absent from the installed catalog. */
+  defaultInput?: readonly string[] | undefined
   /** Whether the user layer currently owns the whole array; absent on a create. */
   overridden?: boolean
   /** Replace the drafted rows. */
@@ -88,30 +92,6 @@ export interface ModelListEditorProps {
   disabled: boolean
 }
 
-/** Disclosure chevron; rotates to point down while its row is open. */
-function IconChevron({ open }: { open: boolean }): ReactNode {
-  return (
-    <svg
-      width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden
-      style={{ transform: open ? 'rotate(90deg)' : undefined, transition: 'transform 120ms ease' }}
-    >
-      <path d="M6 3.5L10.5 8L6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
-    </svg>
-  )
-}
-
-/** Removal glyph for one model row. */
-function IconTrash(): ReactNode {
-  return (
-    <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
-      <path
-        d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9a1 1 0 001 .9h4.6a1 1 0 001-.9L12 4M6.5 6.8v4.4M9.5 6.8v4.4"
-        stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"
-      />
-    </svg>
-  )
-}
-
 /** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
 type CapacityField = 'contextWindow' | 'maxTokens'
 
@@ -142,13 +122,14 @@ function capacitySpelling(value: number | undefined): string {
   return value === undefined ? '' : formatCapacity(value)
 }
 
-/** Adopt a candidate, keeping whatever capacities the provider disclosed. */
+/** Adopt a candidate, preserving disclosed capacities and input types. */
 function adopt(candidate: LlmDiscoveredModel): ModelDraft {
   return {
     id: candidate.id,
     ...candidate.name === undefined ? {} : { name: candidate.name },
     ...candidate.contextWindow === undefined ? {} : { contextWindow: candidate.contextWindow },
     ...candidate.maxTokens === undefined ? {} : { maxTokens: candidate.maxTokens },
+    ...candidate.inputModalities === undefined ? {} : { input: [...candidate.inputModalities] },
   }
 }
 
@@ -159,8 +140,25 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft {
  */
 export function ModelListEditor(props: ModelListEditorProps): ReactNode {
   const { models, onChange, probe, operations, t, disabled } = props
+  const { catalogProvider } = props
   const [busy, setBusy] = useState(false)
   const [failure, setFailure] = useState<string | undefined>(undefined)
+  const [inheritedCatalog, setInheritedCatalog] = useState<{
+    provider: string
+    models: readonly LlmDiscoveredModel[]
+  } | undefined>(undefined)
+  useEffect(() => {
+    if (catalogProvider === undefined) return
+    let current = true
+    void operations.discoverModels(probe.settingsNs, { provider: catalogProvider }).then((answer) => {
+      if (!current) return
+      setInheritedCatalog({ provider: catalogProvider, models: answer.kind === 'found' ? answer.models : [] })
+      setFailure(answer.kind === 'refused' ? answer.message : undefined)
+    })
+    return () => { current = false }
+  }, [catalogProvider, operations, probe.settingsNs])
+  const catalog = inheritedCatalog?.provider === catalogProvider ? inheritedCatalog?.models : undefined
+  const inputDefaults = useMemo(() => new Map(catalog?.map(model => [model.id, model.inputModalities])), [catalog])
   const [candidates, setCandidates] = useState<readonly LlmDiscoveredModel[] | undefined>(undefined)
   const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
   const [candidateQuery, setCandidateQuery] = useState('')
@@ -240,6 +238,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
         return
       }
       const found = answer.models
+      if (catalogProvider !== undefined) setInheritedCatalog({ provider: catalogProvider, models: found })
       if (found.length === 0) {
         setFailure(t('fetchEmpty'))
         return
@@ -346,111 +345,53 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
         </button>
       </div>
       {models.length === 0 ? <p className={styles['modelEmpty']}>{t('modelsEmpty')}</p> : null}
-      {models.map((model, index) => (
-        <div key={index} className={styles['modelEntry']}>
-          <div className={styles['modelRow']}>
-            <input
-              className={styles['input']}
-              type="text"
-              value={textOf(model, 'id')}
-              placeholder={t('modelId')}
-              aria-label={`${t('modelId')} ${index + 1}`}
-              disabled={disabled}
-              onChange={(event) => { patch(index, { id: event.target.value }) }}
-            />
-            <input
-              className={styles['input']}
-              type="text"
-              value={textOf(model, 'name')}
-              placeholder={t('modelName')}
-              aria-label={`${t('modelName')} ${index + 1}`}
-              disabled={disabled}
-              onChange={(event) => { patch(index, { name: event.target.value === '' ? undefined : event.target.value }) }}
-            />
-            <button
-              type="button"
-              className={styles['iconButton']}
-              aria-label={`${t('modelAdvanced')} ${index + 1}`}
-              aria-expanded={expanded.has(index)}
-              title={t('modelAdvanced')}
-              onClick={() => { toggleExpanded(index) }}
-            >
-              <IconChevron open={expanded.has(index)} />
-            </button>
-            <button
-              type="button"
-              className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
-              aria-label={`${t('removeModel')} ${index + 1}`}
-              title={t('removeModel')}
-              disabled={disabled}
-              onClick={() => {
-                onChange(models.filter((_model, at) => at !== index))
-                // Both stores are keyed by position, so every row after this
-                // one shifts down and would otherwise inherit its neighbour's
-                // state — a different row's capacities popping open, or its
-                // half-typed text appearing in another row's field.
-                setExpanded((current) => {
-                  const next = new Set<number>()
-                  for (const at of current) {
-                    if (at < index) next.add(at)
-                    else if (at > index) next.add(at - 1)
-                  }
-                  return next
-                })
-                setEditing(current => reindexOnRemove(current, index))
-              }}
-            >
-              <IconTrash />
-            </button>
-          </div>
-          {expanded.has(index)
-            ? (
-              <div className={styles['modelAdvanced']}>
-                <label className={styles['modelField']}>
-                  <span className={styles['modelFieldLabel']}>{t('modelContextWindow')}</span>
-                  <input
-                    className={styles['input']}
-                    type="text"
-                    inputMode="numeric"
-                    value={capacityText(model, index, 'contextWindow')}
-                    placeholder={CAPACITY_HINT.contextWindow}
-                    aria-label={`${t('modelContextWindow')} ${index + 1}`}
-                    disabled={disabled}
-                    onChange={(event) => { editCapacity(index, 'contextWindow', event.target.value) }}
-                  />
-                </label>
-                <label className={styles['modelField']}>
-                  <span className={styles['modelFieldLabel']}>{t('modelMaxTokens')}</span>
-                  <input
-                    className={styles['input']}
-                    type="text"
-                    inputMode="numeric"
-                    value={capacityText(model, index, 'maxTokens')}
-                    placeholder={CAPACITY_HINT.maxTokens}
-                    aria-label={`${t('modelMaxTokens')} ${index + 1}`}
-                    disabled={disabled}
-                    onChange={(event) => { editCapacity(index, 'maxTokens', event.target.value) }}
-                  />
-                </label>
-                <ModelImageInput
-                  model={model}
-                  field="input"
-                  position={index + 1}
-                  disabled={disabled}
-                  t={t}
-                  onChange={(next) => { onChange(models.map((row, at) => at === index ? next : row)) }}
-                />
-              </div>
-            )
-            : null}
-        </div>
-      ))}
+      <div className={styles['modelList']}>
+        {models.map((model, index) => (
+          <ModelRow
+            key={index}
+            model={model}
+            position={index + 1}
+            inputField="input"
+            inputFallback={inputDefaults.get(textOf(model, 'id')) ?? props.defaultInput}
+            inputLoading={catalogProvider !== undefined && catalog === undefined}
+            expanded={expanded.has(index)}
+            disabled={disabled}
+            t={t}
+            contextWindow={{
+              value: capacityText(model, index, 'contextWindow'),
+              placeholder: CAPACITY_HINT.contextWindow,
+              onChange: (text) => { editCapacity(index, 'contextWindow', text) },
+            }}
+            maxTokens={{
+              value: capacityText(model, index, 'maxTokens'),
+              placeholder: CAPACITY_HINT.maxTokens,
+              onChange: (text) => { editCapacity(index, 'maxTokens', text) },
+            }}
+            onFieldChange={(field, value) => { patch(index, { [field]: value }) }}
+            onChange={(next) => { onChange(models.map((row, at) => at === index ? next : row)) }}
+            onToggle={() => { toggleExpanded(index) }}
+            onRemove={() => {
+              onChange(models.filter((_model, at) => at !== index))
+              setExpanded((current) => {
+                const next = new Set<number>()
+                for (const at of current) {
+                  if (at < index) next.add(at)
+                  else if (at > index) next.add(at - 1)
+                }
+                return next
+              })
+              setEditing(current => reindexOnRemove(current, index))
+            }}
+          />
+        ))}
+      </div>
       <button
         type="button"
         className={styles['addModelButton']}
         disabled={disabled}
         onClick={() => { onChange([...models, { id: '' }]) }}
       >
+        <IconPlusOutline16 size={14} />
         {t('addModel')}
       </button>
       {failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}

+ 112 - 0
packages/client/ui-settings-models/src/client/ModelRow.tsx

@@ -0,0 +1,112 @@
+/** Shared model fields and actions for both adapter catalog editors. */
+
+import type { ReactNode } from 'react'
+import { IconChevronDownOutline14, IconChevronRightOutline14, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
+import type { ModelsKey } from './locales.ts'
+import { ModelInputTypes } from './ModelInputTypes.tsx'
+import styles from './ModelsSection.module.css'
+
+/** A capacity's editable text and adapter-specific inherited hint. */
+interface CapacityInput {
+  value: string
+  placeholder: string
+  onChange: (value: string) => void
+  onBlur?: () => void
+}
+
+/** Adapter-owned data and actions for one model row. */
+interface ModelRowProps {
+  model: DeepSeekModelDraft
+  position: number
+  inputField: 'inputModalities' | 'input'
+  inputFallback?: readonly string[] | undefined
+  inputLoading?: boolean
+  expanded: boolean
+  disabled: boolean
+  t: (key: ModelsKey) => string
+  contextWindow: CapacityInput
+  maxTokens: CapacityInput
+  onFieldChange: (field: 'id' | 'name', value: string | undefined) => void
+  onIdBlur?: (value: string) => void
+  onChange: (model: DeepSeekModelDraft) => void
+  onToggle: () => void
+  onRemove: () => void
+}
+
+/**
+ * Render consistent model identity, capacity, and input-type controls.
+ * @param props - drafted fields and their owning editor's actions.
+ * @returns one expandable model entry.
+ */
+export function ModelRow(props: ModelRowProps): ReactNode {
+  const { model, position, t, disabled } = props
+  return (
+    <div className={styles['modelEntry']}>
+      <div className={styles['modelRow']}>
+        {(['id', 'name'] as const).map(field => (
+          <input
+            key={field}
+            className={styles['input']}
+            type="text"
+            value={typeof model[field] === 'string' ? model[field] : ''}
+            placeholder={t(field === 'id' ? 'modelId' : 'modelName')}
+            aria-label={`${t(field === 'id' ? 'modelId' : 'modelName')} ${String(position)}`}
+            disabled={disabled}
+            onChange={(event) => {
+              const value = event.target.value
+              props.onFieldChange(field, field === 'name' && value === '' ? undefined : value)
+            }}
+            onBlur={field === 'id' ? event => props.onIdBlur?.(event.target.value) : undefined}
+          />
+        ))}
+        <button
+          type="button"
+          className={styles['iconButton']}
+          aria-label={`${t('modelAdvanced')} ${String(position)}`}
+          aria-expanded={props.expanded}
+          title={t('modelAdvanced')}
+          onClick={props.onToggle}
+        >
+          {props.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
+        </button>
+        <button
+          type="button"
+          className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
+          aria-label={`${t('removeModel')} ${String(position)}`}
+          title={t('removeModel')}
+          disabled={disabled}
+          onClick={props.onRemove}
+        >
+          <IconTrashOutline16 size={14} />
+        </button>
+      </div>
+      {props.expanded
+        ? (
+          <div className={styles['modelAdvanced']}>
+            {(['contextWindow', 'maxTokens'] as const).map(field => (
+              <label className={styles['modelField']} key={field}>
+                <span className={styles['modelFieldLabel']}>{t(field)}</span>
+                <input
+                  className={styles['input']}
+                  type="text"
+                  inputMode="numeric"
+                  value={props[field].value}
+                  placeholder={props[field].placeholder}
+                  aria-label={`${t(field)} ${String(position)}`}
+                  disabled={disabled}
+                  onChange={(event) => { props[field].onChange(event.target.value) }}
+                  onBlur={props[field].onBlur}
+                />
+              </label>
+            ))}
+            <ModelInputTypes
+              model={model} field={props.inputField} position={position}
+              fallback={props.inputFallback} disabled={disabled || props.inputLoading === true} t={t} onChange={props.onChange}
+            />
+          </div>
+        )
+        : null}
+    </div>
+  )
+}

+ 24 - 9
packages/client/ui-settings-models/src/client/ModelsSection.module.css

@@ -397,11 +397,6 @@
   padding-top: 12px;
 }
 
-/* Model catalog: a table, not a stack of cards. The column captions are
-   written once above the rows, so a row is one line of fields plus its
-   delete control; each field still carries the indexed `aria-label` that
-   names it, and the caption strip is hidden from assistive tech to keep
-   that name from being announced twice. */
 .modelCatalog {
   display: flex;
   flex-direction: column;
@@ -431,9 +426,7 @@
   line-height: 18px;
 }
 
-/* Model list, shared with the pi-ai provider form: one bordered
-   entry per model, id and display name on the row, capacities and image input
-   behind the row's own disclosure. The rules use this stylesheet's token vocabulary —
+/* The rules use this stylesheet's token vocabulary —
    `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
    `--dsw-alias-text-primary` are undefined here and would resolve to their
    light-mode literals. */
@@ -497,7 +490,7 @@
 
 .modelAdvanced {
   display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+  grid-template-columns: repeat(2, minmax(0, 1fr));
   gap: 8px;
   padding: 8px 4px 2px;
 }
@@ -505,9 +498,31 @@
 .modelField {
   display: flex;
   flex-direction: column;
+  min-width: 0;
   gap: 4px;
 }
 
+.modelInputTypes {
+  grid-column: 1 / -1;
+  min-width: 0;
+  margin: 0;
+  padding: 0;
+  border: none;
+}
+
+.modelInputTypes legend {
+  padding: 0;
+  margin-bottom: 4px;
+}
+
+.modelInputChoices {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16px;
+  min-height: 32px;
+  align-items: center;
+}
+
 .modelFieldLabel {
   color: var(--dsw-alias-label-tertiary);
   font-size: 12px;

+ 3 - 0
packages/client/ui-settings-models/src/client/ProviderEditor.tsx

@@ -341,6 +341,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
     const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
     const defaultContextWindow = schema.getPath(fallback, ['defaultContextWindow'])
     const defaultMaxTokens = schema.getPath(fallback, ['maxTokens'])
+    const defaultInput = schema.getPath(fallback, ['defaultInput'])
     const keyPlaceholder = keyLocked
       ? t('keyEnvLocked')
       : keyState?.configured === true && props.credentialRequired !== true
@@ -465,6 +466,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
               : (
                 <ModelListEditor
                   {...catalogProps}
+                  catalogProvider={props.declared === true ? undefined : props.provider}
+                  defaultInput={Array.isArray(defaultInput) ? defaultInput : undefined}
                   probe={probe}
                   probeBlocked={keyFailure}
                   operations={operations}

+ 6 - 14
packages/client/ui-settings-models/src/client/locales.ts

@@ -51,11 +51,9 @@ export const en = {
   maxTokens: 'Max output tokens',
   maxTokensPlaceholder: 'Uses the provider default',
   modelAdvanced: 'Model options',
-  modelImageInput: 'Image input',
-  modelImageDefault: 'Use default',
-  modelImageDefaultText: 'Default (text only)',
-  modelImageEnabled: 'Supported',
-  modelImageDisabled: 'Not supported',
+  modelInputTypes: 'Input types',
+  modelInputText: 'Text',
+  modelInputImage: 'Image',
   addModel: 'Add model',
   removeModel: 'Delete model',
   modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
@@ -70,8 +68,6 @@ export const en = {
   advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
   modelCapacityInvalid: 'A capacity must be a number, optionally suffixed K or M.',
   modelDuplicate: 'Each model ID may appear once.',
-  modelContextWindow: 'Context window',
-  modelMaxTokens: 'Max output tokens',
   fetchModels: 'Fetch available models',
   fetching: 'Asking the provider\u2026',
   fetchNeedsBaseUrl: 'Enter the base URL first, then fetch.',
@@ -166,11 +162,9 @@ export const zh: { [Key in keyof typeof en]: string } = {
   maxTokens: '最大输出 token 数',
   maxTokensPlaceholder: '使用提供方默认值',
   modelAdvanced: '模型选项',
-  modelImageInput: '图片输入',
-  modelImageDefault: '使用默认值',
-  modelImageDefaultText: '默认(仅文本)',
-  modelImageEnabled: '支持',
-  modelImageDisabled: '不支持',
+  modelInputTypes: '输入类型',
+  modelInputText: '文本',
+  modelInputImage: '图片',
   addModel: '添加模型',
   removeModel: '删除模型',
   modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
@@ -185,8 +179,6 @@ export const zh: { [Key in keyof typeof en]: string } = {
   advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
   modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。',
   modelDuplicate: '每个模型 ID 只能出现一次。',
-  modelContextWindow: '上下文窗口',
-  modelMaxTokens: '最大输出 token',
   fetchModels: '获取可用模型',
   fetching: '正在询问提供方\u2026',
   fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',

+ 1 - 1
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -648,7 +648,7 @@ describe('ModelsSection', () => {
     fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
     // Only row 3 is open, so its capacity is addressed by its own label.
     fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
-    fireEvent.change(screen.getByLabelText(`${en.modelImageInput} 3`), { target: { value: 'enabled' } })
+    fireEvent.click(within(screen.getByRole('group', { name: `${en.modelInputTypes} 3` })).getByRole('checkbox', { name: en.modelInputImage }))
     fireEvent.click(screen.getByText(en.apply))
 
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })

+ 0 - 50
packages/client/ui-settings-models/tests/model-image-input.client.spec.tsx

@@ -1,50 +0,0 @@
-// @vitest-environment jsdom
-/** Image capability defaults, explicit choices, and hidden model metadata. */
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { ModelImageInput } from '../src/client/ModelImageInput.tsx'
-import { en } from '../src/client/locales.ts'
-
-afterEach(cleanup)
-
-describe.each(['inputModalities', 'input'] as const)('%s image input', (field) => {
-  it.each([
-    [undefined, 'default'],
-    [[], 'default'],
-    [['text'], 'disabled'],
-    [['text', 'image'], 'enabled'],
-    [['image'], 'enabled'],
-  ] as const)('displays %j without materializing an override', (modalities, selected) => {
-    const onChange = vi.fn()
-    render(<ModelImageInput model={{ id: 'preview', [field]: modalities }} field={field} position={2} disabled={false} t={key => en[key]} onChange={onChange} />)
-    expect(screen.getByRole<HTMLSelectElement>('combobox', { name: `${en.modelImageInput} 2` }).value).toBe(selected)
-    expect(onChange).not.toHaveBeenCalled()
-  })
-
-  it('enables images and keeps unrelated metadata', () => {
-    const onChange = vi.fn()
-    const model = { id: 'preview', contextWindow: 123456, systemPromptUpdate: 'in-history' }
-    render(<ModelImageInput model={model} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
-    fireEvent.change(screen.getByRole('combobox'), { target: { value: 'enabled' } })
-    expect(onChange).toHaveBeenCalledWith({ ...model, [field]: ['text', 'image'] })
-    expect(model).not.toHaveProperty(field)
-  })
-
-  it.each(['disabled', 'default'])('selects %s without leaving invalid DeepSeek image limits', (choice) => {
-    const onChange = vi.fn()
-    const model = { id: 'vision', [field]: ['image'], description: 'kept', imagePixelBudget: 'low', imageMaxBytes: 12345 }
-    render(<ModelImageInput model={model} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
-    fireEvent.change(screen.getByRole('combobox'), { target: { value: choice } })
-    expect(onChange).toHaveBeenCalledWith({
-      id: 'vision', description: 'kept',
-      ...choice === 'disabled' ? { [field]: ['text'] } : {},
-      ...field === 'input' ? { imagePixelBudget: 'low', imageMaxBytes: 12345 } : {},
-    })
-    expect(model[field]).toEqual(['image'])
-  })
-
-  it('disables the selector while read-only or saving', () => {
-    render(<ModelImageInput model={{ id: 'preview' }} field={field} position={1} disabled t={key => en[key]} onChange={vi.fn()} />)
-    expect(screen.getByRole<HTMLSelectElement>('combobox').disabled).toBe(true)
-  })
-})

+ 84 - 0
packages/client/ui-settings-models/tests/model-input-types.client.spec.tsx

@@ -0,0 +1,84 @@
+// @vitest-environment jsdom
+/** Input-type defaults, nonempty selections, and hidden model metadata. */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ModelInputTypes } from '../src/client/ModelInputTypes.tsx'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+describe.each(['inputModalities', 'input'] as const)('%s input types', (field) => {
+  it('edits the inherited selection without losing its image capability', () => {
+    const onChange = vi.fn()
+    render(<ModelInputTypes
+      model={{ id: 'vision' }} field={field} position={1} fallback={['text', 'image']}
+      disabled={false} t={key => en[key]} onChange={onChange}
+    />)
+    expect(screen.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).checked).toBe(true)
+    fireEvent.click(screen.getByRole('checkbox', { name: en.modelInputText }))
+    expect(onChange).toHaveBeenCalledWith({ id: 'vision', [field]: ['image'] })
+  })
+
+  it.each([
+    [undefined, true, false],
+    [[], true, false],
+    [['text'], true, false],
+    [['text', 'image'], true, true],
+    [['image'], false, true],
+  ] as const)('displays %j without materializing an override', (modalities, text, image) => {
+    const onChange = vi.fn()
+    render(<ModelInputTypes model={{ id: 'preview', [field]: modalities }} field={field} position={2} disabled={false} t={key => en[key]} onChange={onChange} />)
+    expect(screen.getByRole('group', { name: `${en.modelInputTypes} 2` })).toBeTruthy()
+    expect(screen.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputText }).checked).toBe(text)
+    expect(screen.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).checked).toBe(image)
+    expect(onChange).not.toHaveBeenCalled()
+  })
+
+  it('enables images and keeps unrelated metadata', () => {
+    const onChange = vi.fn()
+    const model = { id: 'preview', contextWindow: 123456, systemPromptUpdate: 'in-history' }
+    render(<ModelInputTypes model={model} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
+    fireEvent.click(screen.getByRole('checkbox', { name: en.modelInputImage }))
+    expect(onChange).toHaveBeenCalledWith({ ...model, [field]: ['text', 'image'] })
+    expect(model).not.toHaveProperty(field)
+  })
+
+  it('removes DeepSeek image limits when images are unchecked', () => {
+    const onChange = vi.fn()
+    const model = { id: 'vision', [field]: ['text', 'image'], description: 'kept', imagePixelBudget: 'low', imageMaxBytes: 12345 }
+    render(<ModelInputTypes model={model} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
+    fireEvent.click(screen.getByRole('checkbox', { name: en.modelInputImage }))
+    expect(onChange).toHaveBeenCalledWith({
+      id: 'vision', description: 'kept', [field]: ['text'],
+      ...field === 'input' ? { imagePixelBudget: 'low', imageMaxBytes: 12345 } : {},
+    })
+    expect(model[field]).toEqual(['text', 'image'])
+  })
+
+  it('allows image-only input without discarding image limits', () => {
+    const onChange = vi.fn()
+    const model = { id: 'vision', [field]: ['text', 'image'], imagePixelBudget: 'low' }
+    render(<ModelInputTypes model={model} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
+    fireEvent.click(screen.getByRole('checkbox', { name: en.modelInputText }))
+    expect(onChange).toHaveBeenCalledWith({ ...model, [field]: ['image'] })
+  })
+
+  it.each(['text', 'image'])('keeps the last selected type %s', (modality) => {
+    const onChange = vi.fn()
+    render(<ModelInputTypes model={{ id: 'preview', [field]: [modality] }} field={field} position={1} disabled={false} t={key => en[key]} onChange={onChange} />)
+    const selected = screen.getByRole<HTMLInputElement>('checkbox', { checked: true })
+    expect(selected.disabled).toBe(true)
+    selected.click()
+    expect(onChange).not.toHaveBeenCalled()
+  })
+
+  it('disables both checkboxes while read-only or saving', () => {
+    const onChange = vi.fn()
+    render(<ModelInputTypes model={{ id: 'preview', [field]: ['text', 'image'] }} field={field} position={1} disabled t={key => en[key]} onChange={onChange} />)
+    for (const checkbox of screen.getAllByRole<HTMLInputElement>('checkbox')) {
+      expect(checkbox.disabled).toBe(true)
+      checkbox.click()
+    }
+    expect(onChange).not.toHaveBeenCalled()
+  })
+})

+ 56 - 0
packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx

@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+/** Catalog reads preserve draft ownership and discard responses for a previous provider. */
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { ModelListEditor } from '../src/client/ModelListEditor.tsx'
+import type { ModelDiscoveryOutcome, ModelsOperations } from '../src/client/operations.ts'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+function operations(discoverModels: ModelsOperations['discoverModels']): ModelsOperations {
+  return {
+    discoverModels,
+    describeCredential: vi.fn(),
+    storeCredential: vi.fn(),
+    removeCredential: vi.fn(),
+    writeSettings: vi.fn(),
+  }
+}
+
+it('ignores a late catalog response after the provider changes', async () => {
+  const oldCatalog = Promise.withResolvers<ModelDiscoveryOutcome>()
+  const newCatalog = Promise.withResolvers<ModelDiscoveryOutcome>()
+  const actions = operations(vi.fn()
+    .mockReturnValueOnce(oldCatalog.promise)
+    .mockReturnValueOnce(newCatalog.promise))
+  const onChange = vi.fn()
+  const props = {
+    models: [{ id: 'm' }], onChange, operations: actions,
+    disabled: false, t: (key: keyof typeof en) => en[key],
+  }
+  const { rerender } = render(<ModelListEditor {...props} catalogProvider="old" probe={{ settingsNs: 'llm-pi-ai', provider: 'old' }} />)
+  fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
+  expect(screen.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).disabled).toBe(true)
+  rerender(<ModelListEditor {...props} catalogProvider="new" probe={{ settingsNs: 'llm-pi-ai', provider: 'new' }} />)
+  await act(async () => { newCatalog.resolve({ kind: 'found', models: [{ id: 'm', inputModalities: ['text', 'image'] }] }) })
+  expect(screen.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).checked).toBe(true)
+  await act(async () => { oldCatalog.resolve({ kind: 'found', models: [{ id: 'm', inputModalities: ['text'] }] }) })
+  expect(screen.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).checked).toBe(true)
+  expect(onChange).not.toHaveBeenCalled()
+})
+
+it('uses provider input defaults for a model absent from the installed catalog', async () => {
+  const onChange = vi.fn()
+  render(<ModelListEditor
+    models={[{ id: 'custom' }]} onChange={onChange} defaultInput={['image']} catalogProvider="openai"
+    probe={{ settingsNs: 'llm-pi-ai', provider: 'openai' }} disabled={false} t={key => en[key]}
+    operations={operations(() => Promise.resolve({ kind: 'found', models: [] }))}
+  />)
+  fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` }))
+  const text = screen.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputText })
+  await waitFor(() => { expect(text.disabled).toBe(false) })
+  expect(text.checked).toBe(false)
+  fireEvent.click(text)
+  expect(onChange).toHaveBeenCalledWith([{ id: 'custom', input: ['text', 'image'] }])
+})

+ 62 - 44
packages/client/ui-settings-models/tests/provider-form.client.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 /** Model-list editing, endpoint interrogation, and hand-declared provider creation. */
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { within, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import Schema from '@deepseek-ai/schemastery'
 import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -179,9 +179,9 @@ interface MutateCall {
   ops: { op: string; path: string[]; value?: unknown }[]
 }
 
-/** The first interrogation payload; fails the case when nothing was asked. */
-function firstProbe(discover: ReturnType<typeof vi.fn>): unknown {
-  const call = (discover.mock.calls as unknown as [string, Record<string, unknown>][])[0]
+/** The latest interrogation payload; fails the case when nothing was asked. */
+function lastProbe(discover: ReturnType<typeof vi.fn>): unknown {
+  const call = (discover.mock.calls as unknown as [string, Record<string, unknown>][]).at(-1)
   if (call === undefined) throw new Error('no interrogation was recorded')
   return { settingsNs: call[0], ...call[1] }
 }
@@ -263,7 +263,7 @@ describe('model list editing', () => {
     })
     openEditor('openai')
     expandModel(1)
-    fireEvent.change(screen.getByLabelText(`${en.modelImageInput} 1`), { target: { value: 'enabled' } })
+    fireEvent.click(within(screen.getByRole('group', { name: `${en.modelInputTypes} 1` })).getByRole('checkbox', { name: en.modelInputImage }))
     fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalled() })
     expect(firstMutate(mutate).ops).toEqual([{
@@ -279,8 +279,8 @@ describe('model list editing', () => {
     fireEvent.click(screen.getByRole('button', { name: en.addModel }))
     fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
     expandModel(1)
-    fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
-    fireEvent.change(screen.getByLabelText(`${en.modelImageInput} 1`), { target: { value: 'enabled' } })
+    fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: '65536' } })
+    fireEvent.click(within(screen.getByRole('group', { name: `${en.modelInputTypes} 1` })).getByRole('checkbox', { name: en.modelInputImage }))
     fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } })
     // Clearing an optional field must drop it rather than store an empty value.
     fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: '' } })
@@ -317,14 +317,14 @@ describe('model list editing', () => {
     fireEvent.click(screen.getByRole('button', { name: en.addModel }))
     fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
     expandModel(1)
-    fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '1M' } })
-    fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '32K' } })
+    fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: '1M' } })
+    fireEvent.change(screen.getByLabelText(`${en.maxTokens} 1`), { target: { value: '32K' } })
 
     // The field keeps the spelling rather than snapping to the expansion, and
     // a plain count is not rewritten into a suffix mid-word either.
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
-    fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '1000' } })
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('1000')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).value).toBe('1M')
+    fireEvent.change(screen.getByLabelText(`${en.maxTokens} 1`), { target: { value: '1000' } })
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('1000')
 
     fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalled() })
@@ -340,11 +340,11 @@ describe('model list editing', () => {
     fireEvent.click(screen.getByRole('button', { name: en.addModel }))
     fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
     expandModel(1)
-    fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: 'abc' } })
+    fireEvent.change(screen.getByLabelText(`${en.maxTokens} 1`), { target: { value: 'abc' } })
 
     // Silently dropping it would store a route sized differently from what the
     // field shows, so the text stays put and the write is refused instead.
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('abc')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('abc')
     expect(screen.getByText(`${en.model} 1: ${en.modelMaxTokensInvalid}`)).toBeTruthy()
     expect(buttonNamed(en.apply).disabled).toBe(true)
     expect(mutate).not.toHaveBeenCalled()
@@ -365,8 +365,8 @@ describe('model list editing', () => {
     // Opening a row reads the stored counts, which are plain integers; showing
     // them as such would make an already-configured route look unlike one the
     // user just typed, and re-applying would rewrite the field it read.
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('256K')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).value).toBe('1M')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('256K')
   })
 
   it('edits one row of several and lets a cleared capacity leave the profile', async () => {
@@ -376,11 +376,11 @@ describe('model list editing', () => {
     openEditor('openai')
 
     expandModel(2)
-    fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 2`), { target: { value: '2048' } })
+    fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '2048' } })
     fireEvent.change(screen.getByLabelText(`${en.modelName} 2`), { target: { value: 'Second' } })
-    fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '4096' } })
+    fireEvent.change(screen.getByLabelText(`${en.contextWindow} 2`), { target: { value: '4096' } })
     // Clearing it back to empty must drop the field, not store a zero.
-    fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '' } })
+    fireEvent.change(screen.getByLabelText(`${en.contextWindow} 2`), { target: { value: '' } })
     fireEvent.click(screen.getByText(en.apply))
 
     await waitFor(() => { expect(mutate).toHaveBeenCalled() })
@@ -420,8 +420,8 @@ describe('model list editing', () => {
     // 'second' now sits at position 1 and keeps its capacities open; 'third'
     // moved to position 2 and stays folded.
     expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('second')
-    expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
-    expect(screen.queryByLabelText(`${en.modelContextWindow} 2`)).toBeNull()
+    expect(screen.queryByLabelText(`${en.contextWindow} 1`)).not.toBeNull()
+    expect(screen.queryByLabelText(`${en.contextWindow} 2`)).toBeNull()
   })
 
   it('leaves an earlier row expanded and forgets the removed row\u2019s own state', async () => {
@@ -439,13 +439,13 @@ describe('model list editing', () => {
     expandModel(1)
     fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
     expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
-    expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
+    expect(screen.queryByLabelText(`${en.contextWindow} 1`)).not.toBeNull()
 
     // Removing the expanded row itself drops that state rather than handing it
     // to whichever row slides into the position.
     fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
     expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('third')
-    expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
+    expect(screen.queryByLabelText(`${en.contextWindow} 1`)).toBeNull()
   })
 
   it('separates emptying the list from restoring the adapter defaults', async () => {
@@ -505,6 +505,23 @@ describe('capacity spellings', () => {
 })
 
 describe('endpoint interrogation', () => {
+  it.each([[undefined], [[]], [['text']]])('renders catalog input inheritance for a stored declaration of %j', async (input) => {
+    const discover = vi.fn(() => Promise.resolve(ok([
+      { id: 'vision', inputModalities: ['text', 'image'] },
+    ])))
+    const model = { id: 'vision', ...input === undefined ? {} : { input } }
+    const { mutate } = await mountSection({ discover, providers: { openai: { models: [model] } } })
+    openEditor('openai')
+    expandModel(1)
+    const types = within(screen.getByRole('group', { name: `${en.modelInputTypes} 1` }))
+    await waitFor(() => { expect(types.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).disabled).toBe(false) })
+    expect(types.getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).checked).toBe(input?.length !== 1)
+    expect(discover).toHaveBeenCalledExactlyOnceWith('llm-pi-ai', { provider: 'openai' })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(screen.queryByRole('button', { name: en.apply })).toBeNull() })
+    expect(mutate).not.toHaveBeenCalled()
+  })
+
   it('asks the endpoint the form shows, with a key that is not yet stored', async () => {
     const discover = vi.fn(() => Promise.resolve(ok([{ id: 'acme-large', contextWindow: 65_536 }])))
     await mountSection({ discover })
@@ -515,7 +532,7 @@ describe('endpoint interrogation', () => {
     fireEvent.click(screen.getByText(en.fetchModels))
 
     await waitFor(() => { expect(discover).toHaveBeenCalled() })
-    expect(firstProbe(discover)).toEqual({
+    expect(lastProbe(discover)).toEqual({
       settingsNs: 'llm-pi-ai',
       // The route is named, so an adapter that already describes it answers
       // from its own registry rather than the endpoint.
@@ -536,7 +553,7 @@ describe('endpoint interrogation', () => {
     fireEvent.click(screen.getByText(en.fetchModels))
 
     await waitFor(() => { expect(discover).toHaveBeenCalled() })
-    expect(firstProbe(discover)).toEqual({
+    expect(lastProbe(discover)).toEqual({
       settingsNs: 'llm-pi-ai',
       provider: 'openai',
       baseURL: 'https://proxy.example/v1',
@@ -546,8 +563,8 @@ describe('endpoint interrogation', () => {
 
   it('adopts only the picked candidates, keeping a row the user already tuned', async () => {
     const discover = vi.fn(() => Promise.resolve(ok([
-      { id: 'kept', contextWindow: 999 },
-      { id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh' },
+      { id: 'kept', contextWindow: 999, inputModalities: ['text', 'image'] },
+      { id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh', inputModalities: ['text', 'image'] },
     ])))
     const { mutate } = await mountSection({
       discover,
@@ -565,14 +582,15 @@ describe('endpoint interrogation', () => {
     expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('fresh')
     expect(screen.getByLabelText<HTMLInputElement>(`${en.modelName} 2`).value).toBe('Fresh')
     expandModel(2)
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 2`).value).toBe('4096')
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 2`).value).toBe('2048')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 2`).value).toBe('4096')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('2048')
 
+    expect(within(screen.getByRole('group', { name: `${en.modelInputTypes} 2` })).getByRole<HTMLInputElement>('checkbox', { name: en.modelInputImage }).checked).toBe(true)
     fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalled() })
     expect(firstMutate(mutate).ops[0]?.value).toEqual([
       { id: 'kept', contextWindow: 111 },
-      { id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh' },
+      { id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh', input: ['text', 'image'] },
     ])
   })
 
@@ -608,7 +626,7 @@ describe('endpoint interrogation', () => {
     fireEvent.click(screen.getByText(en.fetchModels))
 
     await waitFor(() => { expect(discover).toHaveBeenCalled() })
-    expect(firstProbe(discover)).toEqual({ settingsNs: 'llm-pi-ai', provider: 'openai' })
+    expect(lastProbe(discover)).toEqual({ settingsNs: 'llm-pi-ai', provider: 'openai' })
   })
 
   it('keeps the create card asking only once it has an endpoint', () => {
@@ -629,7 +647,7 @@ describe('endpoint interrogation', () => {
     fireEvent.click(screen.getByText(en.fetchModels))
 
     // A provider being declared names no route, so only the endpoint travels.
-    expect(firstProbe(scripted.discover)).toEqual({
+    expect(lastProbe(scripted.discover)).toEqual({
       settingsNs: 'llm-pi-ai',
       baseURL: 'https://acme.test/v1',
       api: 'openai-completions',
@@ -643,11 +661,11 @@ describe('endpoint interrogation', () => {
     openEditor('openai')
 
     // The row shows what identifies a model; capacities are the exception.
-    expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
+    expect(screen.queryByLabelText(`${en.contextWindow} 1`)).toBeNull()
     expandModel(1)
-    expect(screen.getByLabelText(`${en.modelContextWindow} 1`)).toBeTruthy()
+    expect(screen.getByLabelText(`${en.contextWindow} 1`)).toBeTruthy()
     expandModel(1)
-    expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
+    expect(screen.queryByLabelText(`${en.contextWindow} 1`)).toBeNull()
   })
 
   it('closes the picker without adopting anything on cancel', async () => {
@@ -808,7 +826,7 @@ describe('hand-declared providers', () => {
     fireEvent.click(screen.getByRole('button', { name: en.addModel }))
     fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
     expandModel(1)
-    fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
+    fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: '65536' } })
     fireEvent.click(screen.getByText(en.create))
 
     await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
@@ -1130,7 +1148,7 @@ describe('hand-declared providers', () => {
     expect(buttonNamed(en.fetchModels).disabled).toBe(false)
     expect(buttonNamed(en.create).disabled).toBe(false)
     fireEvent.click(screen.getByText(en.fetchModels))
-    expect(firstProbe(discover)).toMatchObject({ baseURL })
+    expect(lastProbe(discover)).toMatchObject({ baseURL })
   })
 
   it('normalizes surrounding whitespace before interrogating and storing a base URL', async () => {
@@ -1144,7 +1162,7 @@ describe('hand-declared providers', () => {
     expect(screen.queryByText(en.customBaseUrlInvalid)).toBeNull()
     fireEvent.click(screen.getByText(en.fetchModels))
     await waitFor(() => { expect(discover).toHaveBeenCalledTimes(1) })
-    expect(firstProbe(discover)).toMatchObject({ baseURL: 'https://gateway.acme.example/v1' })
+    expect(lastProbe(discover)).toMatchObject({ baseURL: 'https://gateway.acme.example/v1' })
 
     fireEvent.click(await screen.findByText(en.fetchAdopt))
     await waitFor(() => { expect(buttonNamed(en.create).disabled).toBe(false) })
@@ -1196,7 +1214,7 @@ describe('hand-declared providers', () => {
     fireEvent.click(screen.getByRole('button', { name: en.addModel }))
     fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
     expandModel(1)
-    fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '64 KiB' } })
+    fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: '64 KiB' } })
 
     expect(screen.getByText(`${en.model} 1: ${en.modelContextInvalid}`)).toBeTruthy()
     expect(buttonNamed(en.create).disabled).toBe(true)
@@ -1211,7 +1229,7 @@ describe('hand-declared providers', () => {
       fireEvent.change(screen.getByLabelText(`${en.modelId} ${String(at)}`), { target: { value: id } })
       expandModel(at)
       // Deliberately mid-word: the buffer exists so text like this survives.
-      fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} ${String(at)}`),
+      fireEvent.change(screen.getByLabelText(`${en.contextWindow} ${String(at)}`),
         { target: { value: `${String(at)}.` } })
     }
 
@@ -1219,9 +1237,9 @@ describe('hand-declared providers', () => {
     // one after moves down carrying its own, and the removed row's text goes.
     fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
     expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1.')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).value).toBe('1.')
     expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('third')
-    expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 2`).value).toBe('3.')
+    expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 2`).value).toBe('3.')
   })
 
   it('refuses two models sharing one id', () => {
@@ -1535,7 +1553,7 @@ describe('API key field', () => {
     // a round trip to be told what the field already says.
     expect(buttonNamed(en.fetchModels).disabled).toBe(true)
     expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters)
-    expect(discover).not.toHaveBeenCalled()
+    expect(discover).toHaveBeenCalledExactlyOnceWith('llm-pi-ai', { provider: 'openai' })
   })
 
   it('carries the trimmed key into an interrogation, not the padded draft', async () => {
@@ -1546,7 +1564,7 @@ describe('API key field', () => {
     fireEvent.click(screen.getByRole('button', { name: en.fetchModels }))
 
     await waitFor(() => { expect(discover).toHaveBeenCalled() })
-    expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' })
+    expect(lastProbe(discover)).toMatchObject({ apiKey: 'sk-abc' })
   })
 
   it('reloads the section after creating a hand-declared provider', async () => {

+ 1 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -4827,7 +4827,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'LlmDiscoveredModel',
-    declaration: 'export interface LlmDiscoveredModel {\n    id: string;\n    name?: string;\n    contextWindow?: number;\n    maxTokens?: number;\n}',
+    declaration: 'export interface LlmDiscoveredModel {\n    id: string;\n    name?: string;\n    contextWindow?: number;\n    maxTokens?: number;\n    inputModalities?: readonly ModelModality[];\n}',
   },
   {
     name: 'LlmFailure',

+ 2 - 2
packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md
-README.md: ceb6c75ad22534db7af62e915e1aee1f11d55390
-README.zh.md: 3c6697fea5c5c185af29da8b7156f74ca322d339
+README.md: 89ae11fff20923309f3fc90e30de9f92977dcba2
+README.zh.md: 23d7ad170b5434b65b4d50e9a141e532694337dc

+ 1 - 1
packages/llm/llm-pi-ai/README.md

@@ -108,7 +108,7 @@ Profiles are re-read once per operation through the optional settings seam: the
 
 ### Discover models from endpoints
 
-The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves.
+The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call, preserving its `input` array as discovery `inputModalities`; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves.
 
 ### Failures and recovery
 

+ 1 - 1
packages/llm/llm-pi-ai/README.zh.md

@@ -108,7 +108,7 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的
 
 ### 从端点发现模型
 
-插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。
+插件会回答「该提供方可以提供哪些模型?」,供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求,并将其 `input` 数组保留为发现结果的 `inputModalities`;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。
 
 ### 失败与恢复
 

+ 1 - 0
packages/llm/llm-pi-ai/src/discovery.ts

@@ -280,6 +280,7 @@ export async function discoverModels(
         name: model.name,
         contextWindow: model.contextWindow,
         maxTokens: model.maxTokens,
+        inputModalities: [...model.input],
       }))
     }
   }

+ 8 - 0
packages/llm/llm-pi-ai/tests/discovery.spec.ts

@@ -74,6 +74,14 @@ async function harness(): Promise<Context> {
 }
 
 describe('catalog-route model discovery', () => {
+  it('includes the installed model input types for vision models', async () => {
+    const ctx = await harness()
+    const models = await ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })
+    const installed = getBuiltinModels('openai').find(model => model.id === 'gpt-6-astra')
+    expect(installed?.input).toEqual(['text', 'image'])
+    expect(models.find(model => model.id === 'gpt-6-astra')).toMatchObject({ inputModalities: installed?.input })
+  })
+
   it('answers from the installed registry, with capacities and no network call', async () => {
     const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'from-the-endpoint' }] }) })
     const ctx = await harness()

+ 2 - 2
packages/llm/llm/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/llm/llm/README.md
-README.md: 8e4e547190a039959f7db29898945f2ed68ff874
-README.zh.md: 9164f4a6e6107e439b732539cddeb14716bc3fdc
+README.md: cec84812483effd080029e46ee39d6c83b7973cd
+README.zh.md: 7f35b86049c38daa8c93d86910e19b1ddae2dce8

+ 1 - 1
packages/llm/llm/README.md

@@ -61,7 +61,7 @@ After a successful mount, `ctx.llm.listProviders()` reports the registered route
 - **Stream one model call** — `ctx.llm.stream(options)` yields raw chunks (token-level deltas) for any registered provider and model; consumers assemble them with `BlockAssembler`.
 - **Register provider adapters** — an adapter owns one or more provider routes, and its registration captures that route's retry policy; registering the same route twice fails with `DUPLICATE_ADAPTER`.
 - **Expose and activate providers through configuration** — adapters declare configurable-provider routes plus a settings namespace, so configuration surfaces can activate dormant providers and edit connection facts without a restart. `LlmConfigurableProvider.error` reports a configuration diagnostic for repair; unaffected models can remain serviceable.
-- **Discover and resolve models** — list the models an adapter advertises, interrogate an endpoint for the models it serves, and resolve one exact model's context window, output default, reasoning efforts, input modalities, and system prompt update mode: `LlmResolvedModelInfo.systemPromptUpdate` is `'in-history'` when the model reads the latest `system` message at any position as the effective system prompt and absent when only a leading system message is read; `normalizeModelInfo` rejects any other value with `INVALID_MODEL_INFO`.
+- **Discover and resolve models** — Discovery candidates preserve optional `inputModalities`; absence means unknown. List the models an adapter advertises, interrogate an endpoint for the models it serves, and resolve one exact model's context window, output default, reasoning efforts, input modalities, and system prompt update mode: `LlmResolvedModelInfo.systemPromptUpdate` is `'in-history'` when the model reads the latest `system` message at any position as the effective system prompt and absent when only a leading system message is read; `normalizeModelInfo` rejects any other value with `INVALID_MODEL_INFO`.
 - **Validate call config** — an explicit or configured reasoning effort is checked against the exact model before any provider I/O, and an adapter-configured output cap is materialized when the request omits one.
 - **Read an embedded Assistant stream without expanding it** — `assistantStreamFirstTokenTime` (first token), `assistantStreamHasVisibleContent` (any visible content), and `assistantStreamHasVisibleText` (any visible text) answer their questions from the compact records with early exit; `lastAssistantStreamChunk` scans backward to the last raw chunk of one type, `assistantStreamChunks` and `joinAssistantStreamText` scan the whole stream, and `assembleAssistantStream` feeds a `BlockAssembler` one joined delta per run with the same blocks, usage, and replay state as the per-member expansion. `runFirstTokenTime` and `runFirstVisibleTime` do the early-exit scan for one packed run, and `isTokenDelta`, `isVisibleChunk`, and `chunkHasVisibleText` define the token and visibility rules for a single chunk. `expandAssistantStream` remains the validating path for records read at a durable boundary; it is not memoized, because a retained expansion costs roughly ten times the compact stream for as long as the event lives.
 

+ 1 - 1
packages/llm/llm/README.zh.md

@@ -61,7 +61,7 @@ for await (const chunk of ctx.llm.stream({
 - **流式发起一次模型调用**——`ctx.llm.stream(options)` 为任何已注册提供方与模型产出原始分片(token 级增量);消费方用 `BlockAssembler` 组装。
 - **注册提供方适配器**——一个适配器拥有一个或多个提供方路由,其注册会捕获该路由的重试策略;重复注册同一路由会以 `DUPLICATE_ADAPTER` 失败。
 - **通过配置暴露并激活提供方**——适配器声明可配置提供方路由与 settings namespace,配置界面因此可以激活休眠提供方并编辑连接信息,无需重启。`LlmConfigurableProvider.error` 报告供修复的配置诊断;未受影响的模型仍可提供服务。
-- **发现与解析模型**——列出适配器公布的模型、询问端点它提供哪些模型,并解析某个精确模型的上下文窗口、输出默认值、推理(reasoning)强度、输入模态与系统提示词更新模式:当模型把任意位置最新的 `system` 消息读作有效系统提示词时,`LlmResolvedModelInfo.systemPromptUpdate` 为 `'in-history'`;只读取开头 system 消息时该字段缺失;`normalizeModelInfo` 以 `INVALID_MODEL_INFO` 拒绝任何其他值。
+- **发现与解析模型**——发现候选保留可选的 `inputModalities`;缺省表示未知。列出适配器公布的模型、询问端点它提供哪些模型,并解析某个精确模型的上下文窗口、输出默认值、推理(reasoning)强度、输入模态与系统提示词更新模式:当模型把任意位置最新的 `system` 消息读作有效系统提示词时,`LlmResolvedModelInfo.systemPromptUpdate` 为 `'in-history'`;只读取开头 system 消息时该字段缺失;`normalizeModelInfo` 以 `INVALID_MODEL_INFO` 拒绝任何其他值。
 - **校验调用配置**——显式或配置的推理强度会在任何提供方 I/O 之前对照精确模型校验;请求省略输出上限时,会填入适配器配置的输出上限。
 - **不展开即读取内嵌 Assistant 流**——`assistantStreamFirstTokenTime`(首 token)、`assistantStreamHasVisibleContent`(任一可见内容)与 `assistantStreamHasVisibleText`(任一可见文本)通过可提前退出的扫描直接从紧凑记录得出结果;`lastAssistantStreamChunk` 反向扫描到某一类型的最后一个原始 chunk,`assistantStreamChunks` 与 `joinAssistantStreamText` 扫描整个流,`assembleAssistantStream` 向 `BlockAssembler` 每个 run 喂一段拼接 delta,blocks/usage/replayState 与逐成员展开相同。`runFirstTokenTime` 与 `runFirstVisibleTime` 对单个打包 run 做提前退出扫描,`isTokenDelta`、`isVisibleChunk` 与 `chunkHasVisibleText` 定义单个 chunk 的 token 与可见性规则。`expandAssistantStream` 仍是持久边界读取记录的校验路径;它不被记忆化,因为保留的展开在事件生命周期内约花费紧凑流的十倍内存。
 

+ 1 - 0
packages/llm/llm/src/index.ts

@@ -614,6 +614,7 @@ export class LlmRuntime extends TypertRemoteService {
         ...model.name === undefined ? {} : { name: model.name },
         ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
         ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
+        ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
       })
     }
     return models

+ 2 - 0
packages/llm/llm/src/types.ts

@@ -309,6 +309,8 @@ export interface LlmDiscoveredModel {
   contextWindow?: number
   /** Maximum output tokens, when disclosed. */
   maxTokens?: number
+  /** Accepted input types when disclosed by the catalog or endpoint; absent means unknown. */
+  inputModalities?: readonly ModelModality[]
 }
 
 /** One adapter-discovered model; catalog membership is advisory, not request validation. */

+ 3 - 3
packages/llm/llm/tests/topology.spec.ts

@@ -235,17 +235,17 @@ describe('model discovery registry', () => {
       .resolves.toEqual([])
   })
 
-  it('normalizes what an interrogation returns without inventing capacities', async () => {
+  it('preserves discovered input types without inventing missing metadata', async () => {
     const ctx = await setup()
     ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([
-      { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 },
+      { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256, inputModalities: ['text', 'image'] },
       { id: '' },
       { id: 'keep' },
       { id: 'bare' },
     ] as never))
 
     expect(await ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })).toEqual([
-      { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 },
+      { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256, inputModalities: ['text', 'image'] },
       { id: 'bare' },
     ])
   })

+ 1 - 0
tsconfig.host.json

@@ -20,6 +20,7 @@
     "apps/web/tests/streaming-fence-highlight.e2e.ts",
     "apps/web/tests/declared-reasoning.e2e.ts",
     "apps/web/tests/support.ts",
+    "apps/web/tests/model-input-layout.ts",
     "apps/web/tests/scaffold-hermetic.e2e.ts",
     "apps/web/tests/startup-rpc-budget.e2e.ts",
     "apps/web/tests/minimal-preset.snapshot.ts",