DSH / Atlas
2026-08-04implementedarchitecture

Declaring a provider from the Models page

在 Models 页上声明一个提供方

The two layers below made a pi-ai route [a declaration](2026-08-03-pi-ai-declared-provider-catalog.md) and gave the host a way to [interrogate a draft endpoint](2026-08-04-draft-provider-endpoint-interrogation.md). Neither reached a person who does not edit YAML: the Models page still offered one API-key field per provider and a fold with a base URL, so adding a gateway meant opening `$DSH_HOME/settings.yaml` and kno

English

Problem

The two layers below made a pi-ai route a declaration and gave the host a way to interrogate a draft endpoint. Neither reached a person who does not edit YAML: the Models page still offered one API-key field per provider and a fold with a base URL, so adding a gateway meant opening $DSH_HOME/settings.yaml and knowing the profile shape, and correcting a stale context window meant the same. The capability existed and the surface did not expose it.

Two things were missing, and they are not the same shape. Editing an existing route's models is a field on a card that already exists. Declaring a route is a create: the route id is being chosen, so until it is chosen there is no settings address to edit.

Decision

The model list is a component shared by both flows; the create is its own card.

ModelListEditor edits a profile's models array — one row per model with id, display name, context window, and output cap — and owns the fetch action. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing an optional field drops it rather than storing a value the schema would reject, and a capacity that is not a positive integer is not stored at all.

Fetching asks about the endpoint the form currently shows — a base URL edited but unsaved, a key typed but unstored — so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end; the adapter's own message appears beside rows that stay editable by hand.

CustomProviderCard declares a route pi-ai does not ship. It is a separate card because the route id is chosen here: one settings.mutate sets the whole profile at providers.<route>, and the key travels separately through credentials.set under the same <ROUTE>_API_KEY derivation an existing provider uses. The three facts a hand-declared route cannot default — endpoint, protocol, and at least one model — gate the create button, so a failure names the field while the user is still looking at it.

The protocol choices come from the namespace's own schema, read through the settings descriptor the page already fetches (providers.*.api is a union of the adapter's supportedProtocols()). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones.

The editor reaches the two fields a route the directory reports as declared names for itself — its display name and that protocol. A create card asking for a field no editor can change leaves that field reachable only through settings.yaml, which is the posture this note set out to end. Both render in the fold beside the endpoint, the protocol from the same schema read. Clearing the name unsets it, and what the route falls back to is the layer beneath the one the field edits — a cordis.yml may pin a name for a route the catalog does not ship, so the placeholder reads the composition layer and names the route id only when nothing pins one. The protocol has no fallback to clear to. Because an apply can now rename the route, the saved notice names it as the refreshed directory reports it rather than as the target captured when the card opened. A catalog route gets neither: it defaults its name from its catalog entry, and each of its models carries its own protocol, so a route-level one could only override every one of them.

The Provider ID is the one create-card field that stays fixed, and not for want of a control. It is the providers.<route> dict key, so changing it is a move rather than an edit, and the editor is addressed by the settingsPath that move would invalidate. It is referenced from outside this namespace — agent-default-model stores a provider string, and every request/header in every session log already records one — so a rename would silently strip meaning from referents this page cannot see. And it is the stem of the derived credential reference: the page writes keys but can never read one back, so it cannot move OLD_API_KEY to NEW_API_KEY, leaving a rename to either orphan the stored key or point the profile at a reference under the previous name. Declaring the new route and deleting the old one does all three explicitly, and the page already offers both halves.

Alternatives considered

Declare a provider through ProviderEditor with extra fields. One card instead of two, but the editor is addressed by settingsPath, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing.

Add a wire field for the protocol list. Explicit. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema.

Let the Provider ID be edited, with the page performing the move. The card would unset the old key and set the new profile in one settings.mutate, and the rest is a rename. But the credential cannot travel with it — the page holds a redacted descriptor, never a value — and the referents in other namespaces and in logged sessions have no rename path at all, so the honest version of this feature is the create-then-delete the page already has.

Offer the protocol on every pi-ai route, with an inherit choice. Symmetric with the base URL beside it, and repointing a catalog route at a gateway speaking another wire protocol is a real thing to want. But no consumer asks for it, one wrong pick silently repoints every model on the route, and the inherit choice would be the only way to write a declared route into a profile the adapter refuses. settings.yaml still expresses the repoint for a deployment that means it.

Fetch against the stored profile instead of the live form. No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one.

Write adopted candidates straight into the list. Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing.

Consequences

A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list, plus a name and a protocol on a declared route.

What it costs: only pi-ai routes can be hand-declared, because llm-pi-ai is the one namespace whose profiles describe a whole provider — a llm-deepseek route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure credentials.set already has and no longer than the card lives.

Testing

packages/client/ui-settings-models/tests/provider-form.client.spec.tsx drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. protocolChoices is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any <select> that takes .input without .selectInput, because the OS arrow it would otherwise keep sits flush inside the 240px cap select.input imposes. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single api path op, a rename travelling as a single displayName one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. apps/web/tests/models-settings.e2e.ts reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach settings.yaml and the row re-registers under the rename.

中文

Problem

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

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

Decision

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

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

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

CustomProviderCard 声明 pi-ai 未提供的路由。它之所以是独立卡片,正因为路由 id 是在这里选定的:一次 settings.mutateproviders.<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、该行也以新名重新注册。