# Agent Note: 在 Models 页上声明一个提供方

Status: implemented
Archived: 2026-09-04

[English](2026-08-04-declaring-a-provider-from-the-models-page.md) | 中文

## Problem

下面两层已经让 pi-ai 路由变成[一份声明](2026-08-03-pi-ai-declared-provider-catalog.zh.md)，并给了 host [询问草稿端点](2026-08-04-draft-provider-endpoint-interrogation.zh.md)的能力。但两者都没有抵达不编辑 YAML 的人：Models 页仍然只为每个提供方提供一个 API 密钥输入框和一个装着 API 地址的折叠区，因此接入一个网关意味着打开 `$DSH_HOME/settings.yaml` 并知道 profile 的形状，更正一个陈旧的上下文窗口也是如此。能力已经存在，界面却没有暴露它。

缺的是两件事，而它们的形状并不相同。编辑既有路由的模型，是一张已经存在的卡片上的一个*字段*；声明一条路由则是一次*创建*：路由 id 正在此处被选定，而在选定之前根本没有可编辑的 settings 地址。

## Decision

模型列表是两条流程共用的组件；创建则是它自己的卡片。

`ModelListEditor` 编辑 profile 的 `models` 数组——一行一个模型，含 id、显示名称、上下文窗口与输出上限——并持有获取动作。空列表意味着「使用该路由的内置 catalog」，因此每一行都只会被刻意添加；清空某个可选字段会丢弃它，而不是存入一个 schema 会拒绝的值，不是正整数的容量则根本不会被存下。

获取会询问表单**当前显示**的端点——已修改但未保存的 API 地址、已键入但未存储的密钥——因此新增一个提供方是一趟走完，而不是「先保存再回来」。回复会打开一个选择框而不是直接写入：已配置过的候选默认不勾选，因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路；适配器自己的消息会出现在各行旁边，而这些行仍可手工编辑。

`CustomProviderCard` 声明 pi-ai 未提供的路由。它之所以是独立卡片，正因为路由 id 是在这里选定的：一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile，密钥则经 `credentials.set` 单独传递，使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的三件事——端点、协议、至少一个模型——会门控创建按钮，因此失败会在用户仍看着该字段时点名它。

协议选项来自该 namespace **自己的 schema**，经页面本就会获取的 settings 描述符读出（`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union）。没有新增 wire 字段，客户端里没有常量，提供的选项也无从与被接受的集合发生漂移。

对于目录报告为**已声明**的路由，编辑器够得着它为自己命名的那两个字段——显示名称与该协议。创建卡片索要一个编辑器改不了的字段，等于把该字段留在只有 `settings.yaml` 才能触及的位置，而那正是本记录要终结的姿态。两者都渲染在折叠区里、紧挨着端点，协议读的是同一份 schema。清空名称即取消设置，而路由退回的是该字段所编辑层之下的那一层——`cordis.yml` 可以为目录未提供的路由钉一个名称，因此占位符读组合层，只有没人钉名称时才报路由 id。协议没有可退回的兜底。既然一次保存现在可以改名，保存回执便按刷新后的目录来点名这条路由，而不是按卡片打开时捕获的 target。内置目录路由两个都不给：它的名称由目录条目兜底，它的每个模型各自带着自己的协议，路由级协议只可能把它们全部覆盖掉。

**Provider ID** 是创建卡片上唯一保持固定的字段，原因不是没做控件。它是 `providers.<route>` 这个字典键，因此改它是一次搬移而非一次编辑，而编辑器正是由那次搬移会作废的 `settingsPath` 寻址的。它还被本 namespace 之外引用——`agent-default-model` 存着一个 `provider` 字符串，每条会话日志里的每个 `request/header` 也都已经记下了一个——因此重命名会悄悄抽空这个页面看不见的那些引用。它同时是派生凭据引用的词干：页面写得了密钥却永远读不回来，因此无法把 `OLD_API_KEY` 搬到 `NEW_API_KEY`，重命名要么让已存密钥成为孤儿，要么让 profile 指向一个仍带旧名的引用。声明新路由再删掉旧的，把这三件事都显式做了一遍，而页面本就提供这两半。

## Alternatives considered

**在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张，但编辑器由 `settingsPath` 寻址，而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿；推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。

**为协议列表新增一个协议字段。** 显式。但 settings schema 本来就会跨越协议层、本来就含有那个 union，因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。

**开放 Provider ID 编辑，由页面来完成这次搬移。** 卡片可以在一次 `settings.mutate` 里取消旧键、设置新 profile，剩下的就只是改名。但凭据没法跟着走——页面手里只有脱敏描述符，从来没有值——而其他 namespace 与已记录会话里的引用根本没有重命名通路，因此这个功能诚实的版本，就是页面已经具备的「先建后删」。

**给每条 pi-ai 路由都提供协议，并附一个「继承」选项。** 与紧挨着的 API 地址对称，而且把内置目录路由指向讲另一种协议的网关确实是有人会想要的事。但目前没有消费方提出这个诉求，一次选错就会静默地把该路由上每个模型都重指，而「继承」选项还会成为把已声明路由写成适配器拒绝的 profile 的唯一途径。真要这么做的部署，`settings.yaml` 仍然表达得了。

**针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说，密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程，而端点已修改的表单会悄悄去询问旧地址。

**把采纳的候选直接写进列表。** 点击更少，但一次获取就会覆盖用户已更正的容量，而只公布 id 的列表会把真实数字替换成空。

## Consequences

网关、自建服务，或比已安装 catalog 更新的模型，如今无需离开浏览器就能配置，而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器；编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表，已声明路由上还多了一个名称输入框和一个协议选择框。

代价是：只有 pi-ai 路由可以手工声明，因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点，因此讲其他协议的网关会报告自己无法被询问，其模型需手工键入。另外，页面在一次获取期间会把密钥保存在组件状态里，这与 `credentials.set` 已有的暴露面相同，且不长于卡片的存活时间。

## Testing

`packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面：添加、编辑与移除行；被清空的可选字段离开 profile、非整数容量从不进入；询问携带已修改的端点、未保存的密钥，以及 profile 自身的协议；选择框的默认选中、勾选切换、取消，以及「采纳保留已调优的行」；空列表、被拒、传输被拒三条路径；创建写入一份 profile 加其凭据；创建按钮上的每一道门控；以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。样式 gate 读取本包自己的源码，任何只取 `.input` 而不取 `.selectInput` 的 `<select>` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点，已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串，以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由，捕获该卡片，并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。
