feat(ui-models): tag the provider rows this deployment declared

A row's stored profile could not tell a hand-declared gateway from a
shipped provider whose models someone narrowed — both look identical from
outside the adapter — so the Models page had no way to mark the routes a
deployment added itself.

The directory entry now carries `declared`, answered by the owning adapter
against its own installed catalog, and the page renders a Custom tag from
it. Absence stays "this adapter draws no such distinction" rather than
"shipped", so a route no adapter claims is labelled neither way.

Also records the default-route work's Agent Note and the e2e evidence for
all three changes: the composer switch writing the section, and the Models
page declaring a route with its own reasoning effort.
This commit is contained in:
Yichen Jiang
2026-08-07 13:49:47 +08:00
parent e0f9f7a6e6
commit 9679597204
38 changed files with 479 additions and 56 deletions
+2 -2
View File
@@ -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: af0e952dd8dbd9767b98229ee6b87262007d6738
README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c
README.md: 3eee22198320dbbaf4b53e7d4339f1292f3fa36e
README.zh.md: 3871ab046147f57b442ea8018482080e04567150
+1 -1
View File
@@ -49,7 +49,7 @@ Configure credentials, the model catalog, and deployment-specific transport sett
maxTokens: 4096
```
The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Each entry carries `declared`: whether pi-ai ships nothing under that key. It follows the installed catalog, never the settings document, because narrowing a shipped provider's models stores a profile too and that route is still one pi-ai knows — only the adapter can tell the two apart, which is why the directory answers rather than leaving a surface to infer it. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
## Catalog resolution
+1 -1
View File
@@ -49,7 +49,7 @@
maxTokens: 4096
```
字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。每个条目都带上 `declared`:pi-ai 在这个键下是否什么都没有。它跟随已安装 catalog 而非设置文档,因为收窄一个内置提供方的模型同样会存下 profile,而那条路由仍然是 pi-ai 认识的——只有适配器分得清两者,所以由目录直接给出答案,而不是留给界面去猜。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
## Catalog 解析
+12 -2
View File
@@ -92,11 +92,21 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderPro
function directoryEntries(
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>,
): LlmConfigurableProvider[] {
const catalog = new Set(catalogProviderIds())
const entries = new Map<string, LlmConfigurableProvider>()
const declare = (provider: string, displayName: string): void => {
entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] })
entries.set(provider, {
provider,
displayName,
settingsNs: NS,
settingsPath: ['providers', provider],
// Membership of the installed catalog, not of the settings document:
// narrowing a shipped provider's models stores a profile too, and that
// route is still one pi-ai knows.
declared: !catalog.has(provider),
})
}
for (const provider of catalogProviderIds()) declare(provider, provider)
for (const provider of catalog) declare(provider, provider)
for (const [provider, profile] of profiles) declare(provider, profile.displayName)
return [...entries.values()]
}
+10 -1
View File
@@ -124,13 +124,22 @@ describe('hand-declared providers', () => {
it('joins the configurable-provider directory so a settings surface can reach it', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
const directory = ctx.llm.listConfigurableProviders()
expect(ctx.llm.listConfigurableProviders()).toContainEqual({
expect(directory).toContainEqual({
provider: 'acme-gateway',
displayName: 'Acme Gateway',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'acme-gateway'],
// Nothing in the installed catalog answers for this route, which is what
// configuration surfaces mark as a route this deployment declared.
declared: true,
})
// Membership of the catalog, not of the settings document: a shipped
// provider carries a stored profile the moment anyone corrects it.
expect(directory.filter(entry => entry.declared).map(entry => entry.provider))
.toEqual(['acme-gateway'])
expect(directory.find(entry => entry.provider === 'deepseek')?.declared).toBe(false)
})
it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => {
@@ -68,6 +68,7 @@ describe('request-level dynamic profiles', () => {
displayName: 'openai',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'openai'],
declared: false,
})
await ctx.settings.update(NS, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },