DSH / Atlas
2026-08-03implementedarchitecture

pi-ai routes are declared providers, not catalog lookups

pi-ai 路由是被声明的提供方,而不是 catalog 查表

`dsh-llm-pi-ai` treated the pi-ai package's generated catalog as the boundary of what could be configured. A route key had to name an installed provider (`resolveProfiles` rejected anything else), model listing returned `getBuiltinModels(provider)` verbatim, and request-time model resolution looked the id up in that same catalog and overrode only `baseURL`. Three consequences followed, and all three were dead ends ra

English

Problem

dsh-llm-pi-ai treated the pi-ai package's generated catalog as the boundary of what could be configured. A route key had to name an installed provider (resolveProfiles rejected anything else), model listing returned getBuiltinModels(provider) verbatim, and request-time model resolution looked the id up in that same catalog and overrode only baseURL. Three consequences followed, and all three were dead ends rather than gaps: an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog could not be configured at all; a model the catalog had not caught up with failed with UNKNOWN_MODEL even against a correct endpoint; and a model's context window and output cap were whatever the pinned pi-ai release said, so a deployment could neither correct a stale value nor supply one for a model pi-ai had never described. Upgrading the package was the only way to move any of it.

The adapter also streamed through streamSimple from @earendil-works/pi-ai/compat, an entry point whose own module documentation declares it a temporary compatibility surface — its catalog reads are @deprecated, and it is deleted when pi-ai finishes its ModelManager migration. The three configuration limits and the deprecated dependency have the same fix, because pi-ai's supported runtime (createModels() / createProvider()) is built around a provider being declared rather than looked up.

Decision

A provider route is a declaration, and the installed catalog is its default. resolveProfiles no longer checks route keys against getBuiltinProviders(). Instead each route resolves to a materialized model list plus the pi-ai Provider that serves it:

  • catalog.ts merges the installed catalog under the profile's own entries. A profile's models list replaces the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same id. Only the fields the harness consumes are configurable — id, name, contextWindow, maxTokens; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added reasoningEfforts and compat, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Input modalities were opened later, as input on an entry plus a route-level defaultInput, once the image-admission points turned an unreported modality into a refusal a deployment could not lift ([[2026-08-12-pi-ai-route-default-input-modalities]]); the "nothing reads them" reasoning had described llm-deepseek's serializer rather than this route, whose converter carries images. Pricing stays absent for the original reason: replay.ts zeroes pi-ai's cost metadata and no consumer reports spend. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every Model field this package does not model, which is how headers went missing from an nvidia route once already.
  • provider.ts builds the route's Provider. A catalog route that keeps its catalog protocol reuses the installed provider with getModels() replaced; every other route is built by createProvider() over a protocol table whose entries are the same @earendil-works/pi-ai/api/*.lazy factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused.
  • adapter.ts turns each resolution into an immutable snapshot — the profiles plus a createModels() collection holding those providers — and every operation captures a whole snapshot before its first await.
  • A model's explicitly configured maxTokens becomes the seam's defaultMaxTokens. The value inherited from the installed catalog does not: pi-ai requires Model.maxTokens as the model's output capability, while defaultMaxTokens is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked.

Snapshots, not a shared collection

Models.streamSimple() resolves its provider lazily, when the returned stream is first consumed — which is after the adapter has awaited the route's credential. A single collection mutated in place would therefore let a request that started under one configuration finish under another, or fail on a provider that no longer exists, even though llm.prepareCall() already froze that step's config and captured its adapter registration. A configuration change builds a new collection and leaves the one in use alone, so the seam's per-step freeze holds all the way down: switching models mid-reply takes effect on the next step, never inside the one in flight.

The directory replaces atomically

The configurable-provider directory follows the profiles, so it changes whenever a declared route appears or leaves. Withdrawing the old registration and making a new one cannot express that: a candidate set the registry refuses — a profile keyed deepseek-official, which llm-deepseek already declares — would leave this plugin's whole directory withdrawn and the Models page empty, silently, because the settings-change callback contains the failure. registerConfigurableProviders therefore returns a handle carrying replace(entries) with the same validate-the-candidate-set-first atomicity registerAdapter has, and the plugin uses it. A refused swap costs a diagnostic; the previous entries keep serving.

Resolution fails loud and names the route and model at fault: a model the catalog does not describe falls back to the route's own defaultContextWindow/defaultMaxTokens, so a listing that discloses nothing but ids still yields a serviceable route; a route the catalog does not ship needs api, baseURL, and a non-empty models list. Because the built Provider is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did.

The configurable-provider directory is now the installed catalog joined with every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it.

A capability whose only level does nothing is reported unavailable

pi-ai reports a model with no reasoning metadata as supporting the single level off, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: off becomes an omitted reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows off selected.

reasoningInfo therefore omits the Service Definition's reasoning field whenever model.reasoning is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model whose entry declares no reasoningEfforts ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] made declared efforts carry that metadata) and the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone off; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, off included, because there it selects between real alternatives.

Credentials stay outside pi-ai

pi-ai's Models carries its own credential concept — a CredentialStore keyed by provider id, with envApiKeyAuth resolving credential.key ?? env(VAR). Adopting it would have created a second credential source of truth beside ctx.credentials and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing apiKeyEnv must fail with MISSING_CREDENTIAL rather than authenticate with whatever unrelated key the environment holds.

ModelsImpl.applyAuth honours options.apiKey as the request's key, but only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery, returning nothing — and so failing the request with Provider is not configured — when the provider has no api-key method at all. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's apiKey; the collection is constructed with no credential store.

A route's auth follows from that. A catalog route keeps the installed provider's own auth, which preserves provider-native ambient discovery for a profile naming no credential, and keeps it through an api override too: which environment a provider reads is a property of the provider, not of the wire format its models speak. The exception is a catalog provider with no api-key method — openai-codex authenticates through OAuth alone — where a profile that names a credential also gets the harness method beside the provider's own, because otherwise its configured key would be refused before any request went out. A keyless profile on such a route adds nothing and keeps the honest refusal: this adapter holds no OAuth store to resolve through. A hand-declared route gets a harness-owned ApiKeyAuth that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an Authorization header, and says so itself.

Alternatives considered

  • Keep createProvider() but skip the Models collection, streaming through provider.streamSimple(model, ctx, {apiKey}). Smallest diff and the credential path is untouched, but createProvider's auth is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves refreshModels needing a hand-built RefreshModelsContext, and keeps the adapter off the runtime pi-ai actually supports.

  • Reuse the installed provider for catalog routes and createProvider() only for declared ones, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations.

  • Rebuild every route through createProvider(), including catalog ones. Fully symmetric, but a built Provider does not expose its api, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working.

  • Expose pi-ai's whole Model shape (cost, input modalities, thinkingLevelMap, compat). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later, field by field as a consumer appeared: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as reasoningEfforts, not a raw thinkingLevelMap) and the two reasoning-dispatch compat switches once selectors and dispatch actually consumed them, and [[2026-08-12-pi-ai-route-default-input-modalities]] opened modalities (as input and defaultInput, not a raw Model.input passthrough) once the image-admission points read them. Cost stays closed for the original reason.

  • Keep one mutable Models collection and re-sync it. Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: stream() awaits a credential between capturing its model and dispatching it.

  • Simulate an atomic directory swap with dispose-then-register. No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity.

  • A runtime dynamic catalogfetchModels plus ModelsStore, refreshed in the background. Rejected for this change: it makes the model list external mutable state needing cache, invalidation, and an offline path, and the product need is a one-shot discovery action whose result the user adopts into settings.yaml. That action belongs to the configuration surface and is deferred with it; settings.yaml stays the single source of truth for what a route serves.

Consequences

Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a settings.yaml edit, and a stale context window can be corrected in place. The deprecated /compat import is gone, so pi-ai deleting it is no longer a breaking event. defaultMaxTokens now flows from configuration when a deployment states one, without inventing a cap from catalog metadata.

What it costs: settings.yaml grows for a declared route, because it must state its endpoint, protocol, and model ids. api applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's /models, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error finish chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401.

Testing

tests/catalog.spec.ts covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its Authorization-header workaround, an OAuth-only catalog route authenticating with the key its profile names while a keyless one stays unconfigured, a repointed route keeping its catalog auth, and every resolution failure that names a route or model. tests/catalog.spec.ts also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. packages/llm/llm/tests/topology.spec.ts covers replace — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. tests/sdk-options.spec.ts re-targets the SDK boundary from the removed /compat import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's design-verification role is unchanged.

中文

Problem

dsh-llm-pi-ai 把 pi-ai 包生成的 catalog 当成了可配置范围的边界。路由键必须点名一个已安装提供方(resolveProfiles 拒绝其余一切),模型列举原样返回 getBuiltinModels(provider),请求期的模型解析又在同一份 catalog 里查这个 id、且只覆盖 baseURL。由此产生三个后果,而且三个都是死路而非缺口:OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,根本无法配置;catalog 尚未跟上的模型即便端点正确也会以 UNKNOWN_MODEL 失败;模型的上下文窗口与输出上限完全由锁定的 pi-ai 版本决定,部署既无法更正陈旧值,也无法为 pi-ai 从未描述过的模型补上。要动其中任何一条,只能升级依赖。

适配器还经 @earendil-works/pi-ai/compatstreamSimple 发起流式请求,而该入口自己的模块文档声明它是临时兼容面——其 catalog 读取标了 @deprecated,并会在 pi-ai 完成 ModelManager 迁移时被删除。这三条配置限制与这个废弃依赖的解法是同一个,因为 pi-ai 受支持的运行时(createModels() / createProvider())正是围绕「提供方是被声明出来的,而非查出来的」建立的。

Decision

提供方路由是一份声明,已安装 catalog 是它的默认值。resolveProfiles 不再拿路由键去核对 getBuiltinProviders(),而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai Provider

  • catalog.ts 把已安装 catalog 合并到 profile 自身条目之下。profile 的 models 列表替换该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 id 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——idnamecontextWindowmaxTokens;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 reasoningEffortscompat,当初「推理(reasoning)沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。输入模态后来被开放,形态是条目上的 input 加路由级 defaultInput——图片准入点使得「未被报告的模态」变成部署无法解除的拒绝之后([[2026-08-12-pi-ai-route-default-input-modalities]]);当初「没有任何读取方」那句论证描述的其实是 llm-deepseek 的序列化器,而不是这条路由,它的转换器能携带图片。定价仍因原有理由不出现在配置面:replay.ts 把 pi-ai 的成本元数据清零,且没有任何消费方报告开销。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 Model 字段——headers 就是这样从某条 nvidia 路由上消失过一次。

  • provider.ts 构造路由的 Provider。保持 catalog 协议不变的 catalog 路由会复用已安装提供方,只替换 getModels();其余路由都由 createProvider() 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 @earendil-works/pi-ai/api/*.lazy factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。

  • adapter.ts 把每次解析变成一份不可变快照——profiles 加上持有这些 provider 的 createModels() 集合——每个操作都在自己第一个 await 之前整体捕获一份。

  • 模型显式配置maxTokens 会成为 seam 的 defaultMaxTokens;从已安装 catalog 继承来的那份不会:pi-ai 要求 Model.maxTokens 表示模型的输出能力,而 defaultMaxTokens 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。

快照,而不是共享集合

Models.streamSimple() 惰性解析提供方——在返回的流首次被消费时,而那已在适配器 await 路由凭据之后。因此就地改动的单一集合,会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的提供方,尽管 llm.prepareCall() 早已冻结了该步的 config 并捕获了其适配器注册。配置变化改为构造集合,正在被使用的那个原封不动,于是 seam 的每步冻结得以贯通到底:回复途中切换模型在下一步生效,绝不影响在途的那一步。

目录原子替换

可配置提供方目录跟随 profiles,因此每当一条声明路由出现或离开它都会变化。「撤销旧注册再新建一个」表达不了这件事:注册表拒绝的候选集合——比如一份键为 deepseek-official 的 profile,而 llm-deepseek 已声明了它——会让本插件的整个目录被撤走、Models 页变空,而且是静默的,因为 settings 变更回调把失败容住了。因此 registerConfigurableProviders 改为返回带 replace(entries) 的句柄,其「候选集先整体校验」的原子性与 registerAdapter 相同,插件改用它。被拒的替换只付出一条诊断;先前的条目继续服务。

解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型会回落到该路由自己的 defaultContextWindowdefaultMaxTokens,因此只公布 id 的列表也能得到可服务的路由;catalog 未提供的路由需要 apibaseURL 和非空的 models 列表。由于构造出的 Provider 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。

可配置提供方目录现在是已安装 catalog 当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。

唯一档位什么也做不到的能力,报告为不可用

pi-ai 把没有推理元数据的模型报告为只支持 off 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:off 在派发时变成被省略的推理选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 off 已选中。

因此只要 model.reasoning 为假,reasoningInfo 就省略 Service Definition 的 reasoning 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖条目未声明 reasoningEfforts 的每一个手工声明模型([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 让声明的档位携带这份元数据)以及 pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 off,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、off 也在内,因为在那里它是在真实备选之间做选择。

凭据留在 pi-ai 之外

pi-ai 的 Models 自带一套凭据概念——按提供方 ID 索引的 CredentialStore,配合 envApiKeyAuth 解析 credential.key ?? env(VAR)。采用它会在 ctx.credentials 之外制造第二个凭据真源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 apiKeyEnv 必须以 MISSING_CREDENTIAL 失败,而不是用环境里恰好持有的某个无关密钥完成认证。

ModelsImpl.applyAuth 会把 options.apiKey 当作该请求的密钥,但这条路必须经由一个声明了 api-key 方法的提供方:resolveProviderAuth 在覆盖存在时短路到该方法,否则依次落到凭据存储与环境发现;若提供方压根没有 api-key 方法,它返回空,请求随即以 Provider is not configured 失败。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 apiKey 传入;该集合构造时不带任何凭据存储。

路由的 auth 由此推出。catalog 路由保留已安装提供方自己的 auth,从而为不点名凭据的 profile 保住其提供方原生环境发现,且在 api 覆盖之下同样保留:提供方读哪个环境是提供方自身的属性,而非其模型所讲协议格式(wire format)的属性。例外是没有 api-key 方法的 catalog 提供方——openai-codex 只走 OAuth——此时点名了凭据的 profile 会在提供方原有 auth 之外再获得 harness 的方法,否则它配置的密钥会在任何请求发出之前被拒。这类路由上不点名凭据的 profile 什么也不加、并保留那句诚实的拒绝:本适配器没有可供解析的 OAuth 存储。手工声明的路由则获得一个 harness 自有的 ApiKeyAuth,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 Authorization 标头,并且会自己说出来。

Alternatives considered

  • 保留 createProvider() 但不建 Models 集合,改由 provider.streamSimple(model, ctx, {apiKey}) 发起。改动最小且凭据路径原封不动,但 createProviderauth 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 refreshModels 需要手工构造 RefreshModelsContext,并使适配器始终不在 pi-ai 真正支持的运行时上。

  • catalog 路由复用已安装提供方,只有声明式路由走 createProvider(),且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。

  • 让每条路由都经 createProvider() 重建,包括 catalog 路由。完全对称,但已构造的 Provider 不暴露自己的 api,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。

  • 完整暴露 pi-ai 的 Model 形状(成本、输入模态、thinkingLevelMapcompat)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来逐字段兑现了,每次都等到出现真实读取方:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 reasoningEfforts 的形态,而非裸 thinkingLevelMap)和两个推理分派 compat 开关;[[2026-08-12-pi-ai-route-default-input-modalities]] 在图片准入点开始读取之后开放了模态(以 inputdefaultInput 的形态,而非裸 Model.input 直通)。成本仍因原有理由保持关闭。

  • 保留单个可变 Models 集合并重新同步。 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:stream() 会在捕获模型与派发模型之间 await 一次凭据。

  • 用「先 dispose 再注册」模拟目录原子替换。 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。

  • 运行时动态 catalog——fetchModelsModelsStore,后台刷新。本次变更拒绝:它把模型列表变成需要缓存、失效与离线路径的外部可变状态,而产品需求是一次性的发现动作、其结果由用户采纳进 settings.yaml。该动作属于配置界面,与之一并暂缓;settings.yaml 始终是「路由服务什么」的唯一真源。

Consequences

配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 settings.yaml 编辑,陈旧的上下文窗口也能就地更正。废弃的 /compat 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。defaultMaxTokens 现在会在部署明确给出时从配置中传入,不会从 catalog 元数据里发明一个上限。

代价是:声明式路由会让 settings.yaml 变长,因为它必须自报端点、协议与模型 id。api 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 /models,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 finish 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。

Testing

tests/catalog.spec.ts 针对本地 mock 服务器端到端覆盖该约定:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 Authorization 标头变通、只走 OAuth 的 catalog 路由用 profile 点名的密钥完成认证而无密钥者保持未配置、改指协议的路由保留其 catalog auth,以及每一种点名路由或模型的解析失败。tests/catalog.spec.ts 还钉住了快照与目录两项约定:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。packages/llm/llm/tests/topology.spec.ts 覆盖 replace——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。tests/sdk-options.spec.ts 把 SDK 边界从已移除的 /compat 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的设计验证角色不变。