Interrogating a draft provider endpoint
询问草稿中的提供方端点
Once a pi-ai route became [a declaration rather than a catalog lookup](../../archived/architecture/2026-08-03-pi-ai-declared-provider-catalog.md), a person adding a compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and OpenAI- and An
English
Problem
Once a pi-ai route became a declaration rather than a catalog lookup, a person adding a compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and OpenAI- and Anthropic-compatible endpoints publish that list through protocol-specific model-listing routes.
The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a question asked once, whose answer the user adopts into settings.yaml — so settings.yaml remains the only thing deciding what a route serves.
The awkward part is that the question is about something that does not exist yet. The provider being added has no route, no stored profile, and no stored credential; the endpoint and key are values in a form the user is still typing. Every existing LLM Service Definition operation is keyed by a registered provider route, so none of them can carry this.
Decision
Interrogation is keyed by settings namespace, not by provider route:
ctx.llm.registerModelDiscovery(settingsNs, discover)lets an adapter plugin offer to interrogate endpoints for the namespace it owns, andctx.llm.discoverModels(settingsNs, request)asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name.LlmModelDiscoveryRequestcarries the draft — an optionalprovider, an optionalbaseURL, an optionalapi, an optionalapiKey, and a signal — and needs at least one ofproviderorbaseURLto have anything to answer about.providerexists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. The custom-provider card parses a non-emptybaseURLand permits only HTTP or HTTPS before it calls this operation or writes the profile. Nothing in this path writes settings or credentials. A named configured route reads its stored credential and deployment-owned profileheadersinside the Host: the credential is write-only and the curated Models page does not edit headers, so neither can be reconstructed from that page's draft. The typed key wins over the stored credential, while the profile headers still accompany the request.LlmDiscoveredModelmakes every field butidoptional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.llm.discoverModelscarries the same draft over the wire. ItsapiKeyis the third and last payload on which a secret may ride, alongsidesettings.update/mutateandcredentials.set, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where asubscribeEnvelopes()observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds intomodel-discovery-failed, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
dsh-llm-pi-ai applies the protocol-specific listing routes, authentication, URL normalization, response formats, and metadata rules documented in the dsh-llm-pi-ai README. Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. A protocol without a documented listing contract answers DISCOVERY_UNSUPPORTED, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. baseURL is treated as a prefix rather than a URL to resolve against, so deployment path segments remain intact. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared content-length is checked first as a courtesy but never trusted as the bound, matching dsh-web-fetch's two-stage pattern for its own caller-supplied URLs.
Why not pi-ai's own refresh machinery
pi-ai supplies createProvider({ fetchModels }) plus Models.refresh() and a ModelsStore, and the layer below already builds pi-ai Provider objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that settings.yaml owns the catalog. It would also have bought nothing: no built-in pi-ai provider implements fetchModels, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening. The route's stored credential is resolved by the plugin's own per-request resolver, and only on the branch that reaches the network, so a catalog route answers without touching credentials and never fails over one the question did not need.
Alternatives considered
Key interrogation by provider route. Symmetric with every other LLM Service Definition operation, and it would let the request omit the endpoint. But the case that motivates the feature — adding a provider — has no route, so the operation would only work for providers already configured, which are the ones that need it least.
Put the capability on LlmAdapter. Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve.
Have the host read the entire stored profile instead of accepting a draft. No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. The draft remains authoritative for the endpoint and protocol. The narrow Host-side exceptions are the stored credential, which is write-only, and profile headers, which remain deployment configuration rather than Models-page fields.
Interrogate every pi-ai protocol. Coverage based on convenient response similarities would be arbitrary and would make a wrong guess indistinguishable from a provider with no models. Anthropic is included only through its documented native listing contract, as the dsh-llm-pi-ai README documents; Google's field set and Azure's request contract differ, while Codex uses OAuth. An unsupported protocol sends the user to hand-entry, which remains the documented fallback.
Buffer the reply with response.text() and check its length. Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed.
Consequences
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. A missing scheme, unsupported scheme, or malformed URL fails on the field without a Remote call; localhost, IPv4 and IPv6 literals, and custom ports remain valid. Provider and network failures remain distinct interrogation results. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness User-Agent for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage remains protocol-shaped rather than provider-shaped, and an endpoint using an unsupported request contract must be filled in by hand. Because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
Testing
packages/llm/llm/tests/topology.spec.ts covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the NO_DISCOVERY/INVALID_DISCOVERY refusals, and the model-discovery-failed Remote mapping. packages/llm/llm-pi-ai/tests/discovery.spec.ts drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. packages/llm/llm-pi-ai/tests/loader-composition.spec.ts boots settings and credentials through the Loader and proves settings-only headers reach GET /models with request-owned headers winning collisions. packages/llm/llm-pi-ai/tests/adapter.spec.ts rejects profile headers Fetch cannot represent, and packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts proves a settings write reports that configuration error while its last good routes keep serving. packages/client/connection/tests/node-half.host.spec.ts pins the llm/discoverModels /api carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, malformed and non-HTTP(S) custom-provider URLs make no request, localhost and IP literals with custom ports remain valid, provider failures stay distinct from URL errors, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection.
中文
Problem
当 pi-ai 路由变成一份声明而非 catalog 查表之后,要接入一个兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而 OpenAI 与 Anthropic 兼容端点会通过各自协议的模型列表路由公布这份信息。
显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是一次性询问,其答案由用户采纳进 settings.yaml,从而让 settings.yaml 始终是决定路由服务内容的唯一真源。
麻烦之处在于,被问的对象还不存在。正在新增的提供方没有路由、没有已存 profile、也没有已存凭据;端点与密钥都是用户尚在输入的表单值。而现有的每个 LLM(大语言模型)Service Definition 操作都以已注册的提供方路由为键,因此没有一个能承载它。
Decision
询问以 settings namespace 为键,而不是提供方路由:
ctx.llm.registerModelDiscovery(settingsNs, discover)让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,ctx.llm.discoverModels(settingsNs, request)发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。LlmModelDiscoveryRequest携带草稿——可选的provider、可选的baseURL、可选的api、可选的apiKey,以及一个 signal——且provider与baseURL至少要有一个,才有东西可答。provider之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。自定义提供方卡片会解析非空的baseURL,且只允许 HTTP 或 HTTPS,之后才会调用该操作或写入 profile。这条路径不写 settings 与 credentials。已配置且具名的路由会在 Host 内读取已存凭据和部署方持有的 profileheaders:凭据只写,而精选的 Models 页面不编辑 headers,因此页面草稿无法重建两者。键入的密钥优先于已存凭据,profile headers 则仍随请求发送。LlmDiscoveredModel除id外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。llm.discoverModels把同一份草稿送过协议层。它的apiKey是可承载机密的第三个、也是最后一个载荷(另两个是settings.update/mutate与credentials.set),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,subscribeEnvelopes()观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为model-discovery-failed,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
dsh-llm-pi-ai 会应用dsh-llm-pi-ai README记录的列表路由、认证、URL 归一化、响应格式与元数据规则。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。没有文档化列表约定的协议会以 DISCOVERY_UNSUPPORTED 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。baseURL 按前缀而非待解析 URL 处理,因此部署路径段会保持不变。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 content-length 作为善意提示,但绝不把它当作边界;这与 dsh-web-fetch 面对自己的调用方提供 URL 时所用的两段式模式一致。
为什么不用 pi-ai 自己的 refresh 机制
pi-ai 提供了 createProvider({ fetchModels }) 加上 Models.refresh() 与 ModelsStore,而下层本来就在构造 pi-ai Provider 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的提供方与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「settings.yaml 拥有 catalog」的决定相抵触。而且它什么也换不来:没有任何一个 pi-ai 内置提供方实现了 fetchModels,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。路由已存的凭据由本插件自己那套逐请求解析器取出,且只在真正要联网的那条分支上进行,因此 catalog 路由作答时既不触碰凭据,也不会因为一把这次询问根本用不上的密钥而失败。
Alternatives considered
以提供方路由为键。 与其他每个 LLM Service Definition 操作对称,也能让请求省去端点。但催生该功能的场景——新增提供方——没有路由,于是这个操作只对已配置好的提供方可用,而它们恰恰最不需要它。
把能力挂在 LlmAdapter 上。 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。
让 Host 读取整个已存 profile,而不是接受草稿。 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。草稿仍是端点和协议的权威来源。Host 侧的狭窄例外是只写的已存凭据,以及仍属部署配置、而非 Models 页面字段的 profile headers。
询问每一种 pi-ai 协议。 根据便利的响应相似性选择覆盖范围会显得武断,也会让猜错的响应与「提供方没有模型」无法区分。Anthropic 仅通过其文档化原生列表约定纳入支持,具体由dsh-llm-pi-ai README记录;Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth。不支持的协议会把用户送去手工填写,这仍是既定回退路径。
用 response.text() 缓冲整个回复再判断长度。 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。
Consequences
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。缺少 scheme、不支持的 scheme 或 URL 格式错误都会直接在字段处失败,不会发起 Remote 调用;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。提供方与网络失败仍是独立的询问结果。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness User-Agent,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。
代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围仍按协议而非按提供方划分,使用不受支持请求约定的端点仍须手工填写。由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
Testing
packages/llm/llm/tests/topology.spec.ts 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、NO_DISCOVERY/INVALID_DISCOVERY 两种拒绝,以及 model-discovery-failed Remote 映射。packages/llm/llm-pi-ai/tests/discovery.spec.ts 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。packages/llm/llm-pi-ai/tests/loader-composition.spec.ts 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 GET /models,且请求所持有的 headers 赢得冲突。packages/llm/llm-pi-ai/tests/adapter.spec.ts 拒绝 Fetch 无法表示的 profile headers,packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。packages/client/connection/tests/node-half.host.spec.ts 固定 llm/discoverModels 的 /api 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、格式错误和非 HTTP(S) 的自定义提供方 URL 不会发起请求、带自定义端口的 localhost 与 IP 字面地址仍然有效、提供方失败与 URL 错误保持区别、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。