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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 解析
|
||||
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd
|
||||
README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972
|
||||
README.md: 47fb73cd710a269bc0ac768ef475b56c99b98929
|
||||
README.zh.md: b46ecf8ad1904630094b2c2b22ea421a406f4d79
|
||||
|
||||
@@ -13,7 +13,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
|
||||
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber. The handle also carries `replace(entries)`: the candidate set is validated in full before anything moves, so an entry another registration already declares leaves the current set intact, and an empty array is legal there. A plugin whose declared set follows its configuration must use `replace` rather than disposing and re-registering — the latter strands the directory empty whenever the new set is refused.
|
||||
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant.
|
||||
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant. An entry may carry `declared` — whether the owning adapter knows that route only because configuration named it. Only the adapter can answer, so absence means "this adapter draws no such distinction", never "shipped".
|
||||
- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber.
|
||||
- `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works.
|
||||
- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` Ask one endpoint which models it advertises.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
|
||||
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。该句柄还带 `replace(entries)`:候选集合会先被整体校验,因此其中若有条目已被另一个注册声明,当前集合原封不动;此处允许传空数组。声明集合随配置变化的插件必须使用 `replace`,而不是先 dispose 再重新注册——后者会在新集合被拒时让目录整个落空。
|
||||
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。
|
||||
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。条目可携带 `declared`——拥有该路由的适配器是否只因配置点名才知道它。只有适配器能回答,因此缺席意味着「这个适配器不作此区分」,绝不等于「内置」。
|
||||
- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供「询问提供方端点」的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。
|
||||
- `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace,让界面只在可用之处提供该动作。
|
||||
- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` 询问某个端点它公布了哪些模型。
|
||||
|
||||
@@ -137,6 +137,15 @@ export interface LlmConfigurableProvider {
|
||||
* object; empty when the whole section is the profile.
|
||||
*/
|
||||
settingsPath: readonly string[]
|
||||
/**
|
||||
* Whether the owning adapter knows this route only because configuration
|
||||
* declared it — a gateway or self-hosted server it ships nothing about.
|
||||
* Absent means the adapter draws no such distinction; false means it does
|
||||
* and this route is one of its own. Only the adapter can answer: a stored
|
||||
* profile is how a user-added route AND a corrected shipped one both look
|
||||
* from outside.
|
||||
*/
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user