feat(llm): interrogate a draft provider endpoint for its models

Once a pi-ai route became a declaration rather than a catalog lookup,
adding an OpenAI-compatible gateway meant knowing its model ids up
front. Most such endpoints publish that list at `GET /models`, but no
seam operation could ask: every one is keyed by a registered provider
route, and the provider being added has no route, no stored profile,
and no stored credential — the endpoint and key are values in a form.

Interrogation is therefore keyed by settings namespace, which a
configuration surface already holds from the configurable-provider
directory. `registerModelDiscovery` offers it per namespace,
`discoverModels` asks, and the request carries the draft itself. The
reply is candidates, not a catalog: every field but the id is optional
because most listings disclose nothing else, and adopting one is a
settings write like any other. Nothing here reads or writes settings or
credentials, so `settings.yaml` still decides what a route serves.

`llm.discoverModels` carries the same draft over the wire. Its apiKey is
the third and last payload a secret may ride, and it is never stored,
logged, or echoed; every refusal folds into `model-discovery-failed`,
naming the endpoint asked but never the credential offered.

The pi-ai side is a plain GET for OpenAI-compatible protocols only —
their listing shape is the one gateways, self-hosted servers, and the
official endpoints agree on. Others say so, sending the user to
hand-entry rather than reporting a guessed shape as an empty provider.
The reply is read under a four-megabyte ceiling held on the bytes
actually received, because the endpoint is a URL the user typed.
This commit is contained in:
Yichen Jiang
2026-08-04 10:14:46 +08:00
parent 9948a37cbc
commit ecee93ec26
34 changed files with 985 additions and 18 deletions

View File

@@ -2444,6 +2444,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
],
}),
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
// The fixture endpoint is imaginary, so the interrogation answers the
// catalog it already serves — enough for a surface to exercise adopting
// candidates without a reachable provider.
discoverModels: request => ok(request, {
models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
}),
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
@@ -2561,6 +2567,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'credentials.unset': return this.api.credentials.unset(request)
case 'llm.providers': return this.api.llm.providers(request)
case 'llm.models': return this.api.llm.models(request)
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
}
}

View File

@@ -197,6 +197,7 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

View File

@@ -232,6 +232,7 @@ export class FakeApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

View File

@@ -428,6 +428,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'listConfigurableProviders(): LlmConfigurableProvider[]',
jsDoc: '/**\n * List every declared configurable provider, registered or dormant.\n * @returns detached directory entries in declaration order.\n */',
},
{
signature: 'registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>, ): () => void',
jsDoc: '/**\n * Offer to interrogate provider endpoints on behalf of the settings\n * namespace this plugin owns. The namespace is the key because that is what\n * a configuration surface already holds from the configurable-provider\n * directory, and because a provider being *added* has no route to name yet.\n * Disposed with the fiber.\n * @param settingsNs - the namespace whose profiles this discovery serves.\n * @param discover - interrogates one endpoint; must honor `request.signal`.\n * @returns the disposer that withdraws the offer.\n */',
},
{
signature: 'listModelDiscoveryNamespaces(): string[]',
jsDoc: '/**\n * List the settings namespaces that can interrogate a provider endpoint, so\n * a surface can offer the action only where it will work.\n * @returns the namespaces in registration order.\n */',
},
{
signature: 'async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise<LlmDiscoveredModel[]>',
jsDoc: '/**\n * Interrogate one provider endpoint for the models it advertises. The\n * request describes a draft, not a stored route, so nothing here reads or\n * writes settings or credentials — the caller owns both, and the reply is\n * candidate metadata a surface may offer for adoption.\n * @param settingsNs - namespace whose registered discovery serves this draft.\n * @param request - the endpoint, protocol, and one-shot credential to use.\n * @returns the advertised models, deduplicated in endpoint order.\n */',
},
{
signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy',
jsDoc: '/**\n * Resolve the retry policy captured when one provider route was registered.\n * @param provider - registered provider route to inspect.\n * @returns the provider-owned policy, with normal defaults already resolved.\n */',
@@ -2097,6 +2109,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'LlmConfigurableProvider',
declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n}',
},
{
name: 'LlmDiscoveredModel',
declaration: 'export interface LlmDiscoveredModel {\n id: string;\n name?: string;\n contextWindow?: number;\n maxTokens?: number;\n}',
},
{
name: 'LlmFailure',
declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}',
@@ -2105,6 +2121,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'LlmModelContext',
declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}',
},
{
name: 'LlmModelDiscoveryRequest',
declaration: 'export interface LlmModelDiscoveryRequest {\n baseURL: string;\n api?: string;\n apiKey?: string;\n signal?: AbortSignal;\n}',
},
{
name: 'LlmModelInfo',
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',

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/host/apiproxy/README.md
README.md: b0364161a30c42e1fbb1f3bb67e73a15c83c0e3c
README.zh.md: 29d4678edcecbab0795760c25f4a286bfac9dc1b
README.md: 633c8fe39d989802e8debc350279137b66979593
README.zh.md: 5e5102840319900f6607acc17288882ccf3c0075

View File

@@ -38,7 +38,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, while `skill.list` resolves the project root from the session header without touching the Agent registry. `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third and last payload a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and is never stored, logged, or echoed. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).

View File

@@ -38,7 +38,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和 skill技能目录。`command.*` 寻址普通会话的 Agent并在需要时恢复冷态普通会话`skill.list` 则从会话头解析项目根目录,不触碰 Agent 注册表。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带生成的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`),且绝不被存储、记录或回显。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list``ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。

View File

@@ -2588,6 +2588,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async models(request) {
return ok(request, await buildModelCatalog(ctx))
},
async discoverModels(request, signal) {
const { settingsNs, baseURL, api, apiKey } = request.payload
try {
const models = await ctx.llm.discoverModels(settingsNs, {
baseURL,
...api === undefined ? {} : { api },
...apiKey === undefined ? {} : { apiKey },
...signal === undefined ? {} : { signal },
})
return ok(request, { models })
} catch (error: unknown) {
// Every failure here is the user's next move, not a transport fault:
// a wrong endpoint, a rejected key, or a protocol with no listing all
// end at the same place — fill the models in by hand. The details
// repeat only what the caller already sent, never the credential.
return err(request, {
code: 'model-discovery-failed',
message: error instanceof Error ? error.message : String(error),
details: { settingsNs, baseURL },
})
}
},
},
events: {

View File

@@ -6,7 +6,7 @@
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { ConfigurableProviderView } from './llm.ts'
import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts'
import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts'
/** ConfigurableProviderView row of llm.providers. */
@@ -34,3 +34,27 @@ export const llmModelsValueSchema = z.object({
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>
/** DiscoveredModelView row of llm.discoverModels. */
export const discoveredModelViewSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
contextWindow: z.number().int().positive().optional(),
maxTokens: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<DiscoveredModelView>>
/** llm.discoverModels request payload. */
export const llmDiscoverModelsRequestSchema = z.object({
settingsNs: z.string().min(1),
baseURL: z.string().min(1),
api: z.string().min(1).optional(),
// Write-only: the host uses it for this one interrogation and never stores,
// logs, or returns it. Kept out of any redacted echo for the same reason
// `credentials.set` never reads a value back.
apiKey: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'llm.discoverModels'>>>
/** llm.discoverModels response value. */
export const llmDiscoverModelsValueSchema = z.object({
models: z.array(discoveredModelViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'llm.discoverModels'>>>

View File

@@ -40,4 +40,38 @@ export interface LlmApi {
* failures ride `failures` without failing the sound groups.
*/
models(request: RpcRequest<{}>): Promise<RpcResponse<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }>>
/**
* Interrogate a provider endpoint the configuration surface is still
* drafting, and return the models it advertises for the user to adopt.
*
* The payload is the draft, not a stored route: `settingsNs` selects the
* adapter family that knows how to read the listing, and the endpoint,
* protocol, and key come from the form. Nothing is written — the reply is
* candidates, and only a later `settings.mutate` decides what a route
* serves. `apiKey` is therefore accepted here but never stored, logged, or
* echoed back; a provider whose key is already stored omits it and the
* endpoint answers unauthenticated or refuses.
*/
discoverModels(
request: RpcRequest<{
settingsNs: string
baseURL: string
api?: string
apiKey?: string
}>,
signal?: AbortSignal,
): Promise<RpcResponse<{ models: DiscoveredModelView[] }>>
}
/** Wire view of one model an interrogated endpoint advertises. */
export interface DiscoveredModelView {
/** Model id the endpoint accepts. */
id: string
/** Human-readable name when the endpoint supplies one. */
name?: string
/** Maximum combined request and response context, when disclosed. */
contextWindow?: number
/** Maximum output tokens, when disclosed. */
maxTokens?: number
}

View File

@@ -66,6 +66,7 @@ export interface RpcMethodMap {
'credentials.unset': CredentialsApi['unset']
'llm.providers': LlmApi['providers']
'llm.models': LlmApi['models']
'llm.discoverModels': LlmApi['discoverModels']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

View File

@@ -55,6 +55,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string() }) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),

View File

@@ -70,6 +70,15 @@ export interface RpcErrorDetailsMap {
'settings-conflict': { ns: string; expected: number; actual: number }
/** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */
'credential-rejected': { ref: string }
/**
* Interrogating a draft provider endpoint did not produce a model listing:
* no adapter family serves the namespace, the protocol has no listing this
* build can read, or the endpoint was unreachable, refused the credential,
* or answered with something else. The message is the adapter's own text —
* it is what the form shows before falling back to hand-entry — and the
* details name the endpoint asked, never the credential offered.
*/
'model-discovery-failed': { settingsNs: string; baseURL: string }
'title-invalid': { sessionId: SessionId }
'fork-unavailable': { sessionId: SessionId }
'subagent-parent-unavailable': { parentSessionId: SessionId }

View File

@@ -55,7 +55,7 @@ import {
import {
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
} from '../api/credentials.schema.ts'
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentListValueSchema,
@@ -146,6 +146,7 @@ export interface IApiClient {
llm: {
providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>>
models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.discoverModels'>>>
}
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
@@ -200,6 +201,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'credentials.unset': credentialsUnsetValueSchema,
'llm.providers': llmProvidersValueSchema,
'llm.models': llmModelsValueSchema,
'llm.discoverModels': llmDiscoverModelsValueSchema,
}
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -467,6 +469,7 @@ export abstract class AbstractApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: (payload, signal) => this.callUnary('llm.providers', payload, signal),
models: (payload, signal) => this.callUnary('llm.models', payload, signal),
discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal),
}
readonly events: IApiClient['events'] = {

View File

@@ -57,7 +57,7 @@ import {
import {
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
} from '../api/credentials.schema.ts'
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentListRequestSchema,
@@ -125,6 +125,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) },
'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) },
'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) },
'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */

View File

@@ -560,3 +560,89 @@ describe('llm domain', () => {
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
})
})
describe('llm.discoverModels', () => {
it('carries a draft to its namespace and returns candidates without storing anything', async () => {
const ctx = await harness()
const seen: unknown[] = []
ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => {
seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey })
return Promise.resolve([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
})
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})))
expect(value.models).toEqual([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
expect(seen).toEqual([{
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
}])
// Interrogating a draft is a read: no namespace gained a section, and no
// credential reference was written.
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.not.toContain('llm-pi-ai')
})
it('omits a credential and protocol the draft does not name', async () => {
const ctx = await harness()
let probe: unknown
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
probe = request_
return Promise.resolve([])
})
const api = createApiProxy(ctx, DEFAULTS)
expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
})))
// Absent fields stay absent rather than crossing as explicit undefined:
// the adapter distinguishes "no protocol named" from "protocol undefined".
expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' })
})
it('reports a failed interrogation as the form\'s next move, naming no credential', async () => {
const ctx = await harness()
ctx.llm.registerModelDiscovery('llm-pi-ai', () =>
Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key')))
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
apiKey: 'wrong',
})))
expect(error.code).toBe('model-discovery-failed')
expect(error.message).toContain('answered 401; check the API key')
expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' })
expect(JSON.stringify(error)).not.toContain('wrong')
})
it('reports a namespace no adapter family serves', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.llm.discoverModels(request({
settingsNs: 'llm-deepseek',
baseURL: 'https://api.deepseek.com',
})))
expect(error.code).toBe('model-discovery-failed')
expect(error.message).toContain('no model discovery is registered')
})
})

View File

@@ -112,6 +112,7 @@ function scriptedApi(overrides: {
llm: {
providers: r => ok(r, { providers: [] }),
models: r => ok(r, { groups: [], failures: [] }),
discoverModels: err,
...overrides.llm,
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
@@ -694,6 +695,7 @@ describe('config unary surface', () => {
llm: {
providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
},
})
const c = client(api)
@@ -719,16 +721,31 @@ describe('config unary surface', () => {
expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
const models = await c.llm.models({})
expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
const discovered = await c.llm.discoverModels({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})
expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
expect(seen.map(call => call.method)).toEqual([
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.providers', 'llm.models',
'llm.providers', 'llm.models', 'llm.discoverModels',
])
expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(seen[4]?.payload)
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
// The draft crosses whole, credential included: the host needs it for this
// one interrogation and stores none of it.
expect(seen[10]?.payload).toEqual({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})
})
it('rejects an invalid credential reference name at the carrier boundary', async () => {

View File

@@ -253,6 +253,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async models(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } }
},
async discoverModels(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } }
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),

View File

@@ -0,0 +1,207 @@
/**
* One-shot interrogation of a provider endpoint's model listing, serving the
* configuration surface's "fetch available models" action.
*
* This is deliberately *not* a catalog refresh. Nothing here is stored: the
* request carries a draft the user is still editing — an endpoint and a
* credential neither of which may exist in `settings.yaml` yet — and the reply
* is candidate metadata the surface offers for adoption. `settings.yaml`
* remains the only thing that decides what a route serves.
*
* Only OpenAI-compatible protocols are interrogated. Their listing is the one
* shape a gateway, a self-hosted server, and the official endpoints all agree
* on, which is the case this action exists for; every other protocol reports
* that it cannot be interrogated so the surface falls back to hand-entry
* rather than guessing a response shape.
*
* @module dsh-llm-pi-ai/discovery
*/
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
/**
* Protocols whose model listing this module can read. Every entry speaks
* OpenAI's `GET /models` shape; pi-ai's other protocols are absent because a
* wrong guess at their response shape would be reported as an empty provider
* rather than as the gap it is.
*/
const LISTABLE_PROTOCOLS: ReadonlySet<string> = new Set([
'azure-openai-responses',
'openai-codex-responses',
'openai-completions',
'openai-responses',
])
/**
* Endpoint replies larger than this are refused. The endpoint is whatever URL
* the user typed, so the ceiling holds on the bytes actually read rather than
* on the length the server claims — the same two-stage shape `dsh-web-fetch`
* uses for its own caller-supplied URLs, except that a truncated model listing
* is not parseable, so overflow rejects instead of truncating.
*/
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
/** One entry of an OpenAI-compatible `GET /models` reply. */
interface ListingEntry {
id?: unknown
/** Common gateway extensions; absent from the official listings. */
name?: unknown
display_name?: unknown
context_window?: unknown
context_length?: unknown
max_tokens?: unknown
max_output_tokens?: unknown
}
/** A positive integer field of a listing entry, or `undefined` when absent or unusable. */
function capacity(...candidates: readonly unknown[]): number | undefined {
for (const candidate of candidates) {
if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) return candidate
}
return undefined
}
/** A non-empty string field of a listing entry, or `undefined`. */
function label(...candidates: readonly unknown[]): string | undefined {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.length > 0) return candidate
}
return undefined
}
/**
* Join the endpoint base with the listing path. The base is treated as a
* prefix rather than a URL to resolve against, so a deployment path such as
* `https://gateway.example/openai/v1` keeps its segments instead of losing
* them to `URL` resolution.
*/
function listingUrl(baseURL: string): string {
return `${baseURL.replace(/\/+$/, '')}/models`
}
/**
* Read a reply body, refusing one that outgrows the ceiling. A declared length
* is checked first so an honest server is turned away without transferring
* anything; the accumulated total is what actually enforces the bound, because
* a server that under-declares (or streams) tells us nothing up front.
*/
async function readBounded(response: Response, url: string): Promise<string> {
const oversized = (): LlmError =>
new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, 'DISCOVERY_FAILED')
const declared = Number(response.headers.get('content-length') ?? Number.NaN)
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
await response.body?.cancel()
throw oversized()
}
/* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */
if (response.body === null) return ''
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) throw oversized()
chunks.push(value)
}
} finally {
/* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */
await reader.cancel().catch(() => {
// Cancel after a drained read, or after this function walked away from
// an oversized one, is cleanup; the reply is already decided either way.
})
}
const body = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(body)
}
/**
* Read one OpenAI-compatible listing reply. Entries without a usable id are
* skipped rather than failing the whole interrogation: a single malformed row
* should not deny the user the rest of a working endpoint's catalog.
*/
function readListing(body: unknown): LlmDiscoveredModel[] {
const data = (body as { data?: unknown } | null)?.data
if (!Array.isArray(data)) {
throw new LlmError(
'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand',
'DISCOVERY_FAILED',
)
}
const models: LlmDiscoveredModel[] = []
for (const raw of data) {
const entry = raw as ListingEntry | null
const id = label(entry?.id)
if (id === undefined) continue
const name = label(entry?.name, entry?.display_name)
const contextWindow = capacity(entry?.context_window, entry?.context_length)
const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens)
models.push({
id,
...name === undefined ? {} : { name },
...contextWindow === undefined ? {} : { contextWindow },
...maxTokens === undefined ? {} : { maxTokens },
})
}
return models
}
/**
* Interrogate one draft provider endpoint for the models it advertises.
* @param request - the endpoint, protocol, and one-shot credential to use.
* @returns the advertised models in endpoint order.
* @throws LlmError when the protocol has no readable listing, the endpoint
* refuses or fails the request, or the reply is not a model listing.
*/
export async function discoverModels(
request: LlmModelDiscoveryRequest,
): Promise<readonly LlmDiscoveredModel[]> {
const api = request.api ?? 'openai-completions'
if (!LISTABLE_PROTOCOLS.has(api)) {
throw new LlmError(
`pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`,
'DISCOVERY_UNSUPPORTED',
)
}
const url = listingUrl(request.baseURL)
let response: Response
try {
response = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json',
...request.apiKey === undefined ? {} : { authorization: `Bearer ${request.apiKey}` },
...attributionHeaders(),
},
...request.signal === undefined ? {} : { signal: request.signal },
})
} catch (error: unknown) {
if (request.signal?.aborted) {
throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
}
throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error })
}
if (!response.ok) {
throw new LlmError(
`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`,
'DISCOVERY_FAILED',
)
}
const text = await readBounded(response, url)
let body: unknown
try {
body = JSON.parse(text)
} catch (error: unknown) {
throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error })
}
return readListing(body)
}

View File

@@ -50,6 +50,7 @@ import { PiAiAdapter } from './adapter.ts'
import { catalogProviderIds } from './catalog.ts'
import { assertServiceable, Config, resolveProfiles } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
import { discoverModels } from './discovery.ts'
export { PiAiAdapter } from './adapter.ts'
export type { PiAiAdapterOptions } from './adapter.ts'
@@ -176,6 +177,10 @@ export function apply(ctx: Context, config: Config): void {
directoryFacts = entries
}
ensureDirectory()
// Interrogating an endpoint is a configuration-time action over a draft, so
// it is offered for the whole namespace rather than per route: the provider
// a surface is adding does not exist yet.
ctx.llm.registerModelDiscovery(NS, discoverModels)
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below. A bare
// mount (zero routes) is the dormant posture: nothing registers until a

View File

@@ -0,0 +1,211 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
})
interface ListingServer {
url: string
paths: string[]
headers: IncomingMessage['headers'][]
}
/**
* A stand-in provider that answers one scripted `GET /models`. `chunks` writes
* without a declared length, which is how a real streamed reply arrives.
*/
async function listingServer(behavior: {
status?: number
body?: string
chunks?: string[]
}): Promise<ListingServer> {
const paths: string[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
paths.push(request.url ?? '')
headers.push(request.headers)
if (behavior.chunks !== undefined) {
// No declared length: the ceiling has to hold on what is read.
response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' })
for (const chunk of behavior.chunks) response.write(chunk)
response.end()
return
}
const body = behavior.body ?? '{}'
response.writeHead(behavior.status ?? 200, {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(body)),
})
response.end(body)
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}`, paths, headers }
}
/** A bare dormant mount: discovery is offered whether or not a route exists. */
async function harness(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {})
return ctx
}
describe('draft-provider model discovery', () => {
it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => {
const server = await listingServer({
body: JSON.stringify({
data: [
{ id: 'acme-large', display_name: 'Acme Large', context_length: 65_536, max_output_tokens: 4096 },
{ id: 'acme-small' },
],
}),
})
const ctx = await harness()
const models = await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/v1`, apiKey: 'probe-key' })
expect(models).toEqual([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
expect(server.paths).toEqual(['/v1/models'])
expect(server.headers[0]?.authorization).toBe('Bearer probe-key')
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
})
it('keeps a deployment path instead of resolving it away', async () => {
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
const ctx = await harness()
await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/openai/v1/` })
expect(server.paths).toEqual(['/openai/v1/models'])
})
it('offers no credential when the draft names none', async () => {
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
const ctx = await harness()
await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })
expect(server.headers[0]?.authorization).toBeUndefined()
})
it('drops unusable rows rather than failing the whole listing', async () => {
const server = await listingServer({
body: JSON.stringify({
data: [
{ id: 'good' },
{ id: '' },
{ name: 'no id at all' },
null,
{ id: 'good' },
{ id: 'zero-capacity', context_length: 0, max_tokens: -1 },
],
}),
})
const ctx = await harness()
expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
.toEqual([{ id: 'good' }, { id: 'zero-capacity' }])
})
it('points at the credential for a rejected one, and only then', async () => {
const ctx = await harness()
for (const status of [401, 403]) {
const refused = await listingServer({ status, body: '{"error":"nope"}' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: refused.url, apiKey: 'wrong' }))
.rejects.toThrow(new RegExp(`answered ${status}; check the API key`))
}
// A server fault is not a credential problem, so it must not send the user
// off to re-check a key that is fine.
const broken = await listingServer({ status: 500, body: '{"error":"boom"}' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url, apiKey: 'fine' }))
.rejects.toThrow(/answered 500$/)
})
it('reports a reply that is not a model listing', async () => {
const server = await listingServer({ body: '{"models":[]}' })
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
.rejects.toThrow(/no "data" array; enter this provider's models by hand/)
const broken = await listingServer({ body: 'not json at all' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url }))
.rejects.toThrow(/did not answer with JSON/)
})
it('refuses an oversized reply, whether its length is declared or streamed', async () => {
const ctx = await harness()
// Just over the four-megabyte ceiling, as one padded model row.
const oversized = `{"data":[{"id":"m","pad":"${'x'.repeat(4 * 1024 * 1024)}"}]}`
const declared = await listingServer({ body: oversized })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: declared.url }))
.rejects.toThrow(/answered with more than 4194304 bytes/)
// A streamed reply declares no length, so the ceiling has to hold on the
// body the harness actually read.
const streamed = await listingServer({ chunks: ['{"data":[{"id":"m","pad":"', 'x'.repeat(4 * 1024 * 1024), '"}]}'] })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: streamed.url }))
.rejects.toThrow(/answered with more than 4194304 bytes/)
})
it('reports an unreachable endpoint instead of an empty catalog', async () => {
const ctx = await harness()
// Port 9 is the discard service: nothing accepts a connection there.
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'http://127.0.0.1:9/v1' }))
.rejects.toMatchObject({ code: 'DISCOVERY_FAILED' })
})
it('says which protocols it cannot interrogate rather than guessing a shape', async () => {
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', {
baseURL: 'https://gateway.example/v1',
api: 'anthropic-messages',
})).rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' })
})
it('honors caller cancellation', async () => {
const ctx = await harness()
const aborted = AbortSignal.abort('test cancellation')
await expect(ctx.llm.discoverModels('llm-pi-ai', {
baseURL: 'http://127.0.0.1:9/v1',
signal: aborted,
})).rejects.toMatchObject({ code: 'ABORTED' })
})
it('is offered for the namespace, and refuses one it does not serve', async () => {
const ctx = await harness()
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai'])
await expect(ctx.llm.discoverModels('llm-deepseek', { baseURL: 'https://api.deepseek.com' }))
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: '' }))
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
})
it('withdraws the offer when the plugin unloads', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmPiAi, {})
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai'])
await fiber.dispose()
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual([])
})
})

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/README.md
README.md: e09ec685ed0ab1e2492749237c277a874eb3b246
README.zh.md: ca98e875a90eb16e32bc405d77cd5b2b56644180
README.md: 60cc94b6375030955136b4efaf969b69bca2530a
README.zh.md: 5b24a1e311c37d13dc4f287e5ae4b57efe00e4e6

View File

@@ -14,6 +14,9 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `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.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.
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved.
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
@@ -23,6 +26,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and an empty namespace or endpoint fails with `INVALID_DISCOVERY`.
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
Every topology commit point — adapter routes registering or disposing, directory entries appearing or withdrawing — emits the payload-free `llm/adapters-updated` event after the mutation, so consumers re-read `listProviders()`/`listModels()`/`listConfigurableProviders()` instead of polling. Observer failures are contained (logged, non-vetoing); only `INVARIANT`-coded failures rethrow after the fan-out.

View File

@@ -14,6 +14,9 @@
- `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.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[]>` 询问某个端点它公布了哪些模型。
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份以及可用上下文、输出默认值和推理reasoning元数据异步适配器可选地支持取消。
@@ -23,6 +26,8 @@
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure``llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`
询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials回复是界面可供用户采纳的候选元数据而不是已注册的 catalog。`LlmDiscoveredModel``id` 外每个字段都是可选的,因为大多数提供方列表只公布 id采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,空 namespace 或空端点以 `INVALID_DISCOVERY` 失败。
提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER``INVALID_CATALOG` 失败。
每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此重读 `listProviders()`/`listModels()`/`listConfigurableProviders()` 而非轮询。观察者故障会被隔离(记录日志、不否决);只有带 `INVARIANT` 码的故障会在扇出后重新抛出。

View File

@@ -10,8 +10,10 @@ import { Context, Service } from 'cordis'
import type {
GenerateOptions,
LlmConfigurableProvider,
LlmDiscoveredModel,
LlmFailure,
LlmModelContext,
LlmModelDiscoveryRequest,
LlmModelInfo,
LlmResolvedModelInfo,
LlmProviderInfo,
@@ -253,6 +255,10 @@ export interface DirectoryRegistrationHandle {
export class LlmService extends Service {
private adapters = new Map<string, AdapterRegistration>()
private directory = new Map<string, LlmConfigurableProvider>()
private discoveries = new Map<
string,
(request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>
>()
constructor(ctx: Context) {
super(ctx, 'llm')
@@ -456,6 +462,80 @@ export class LlmService extends Service {
return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))
}
/**
* Offer to interrogate provider endpoints on behalf of the settings
* namespace this plugin owns. The namespace is the key because that is what
* a configuration surface already holds from the configurable-provider
* directory, and because a provider being *added* has no route to name yet.
* Disposed with the fiber.
* @param settingsNs - the namespace whose profiles this discovery serves.
* @param discover - interrogates one endpoint; must honor `request.signal`.
* @returns the disposer that withdraws the offer.
*/
registerModelDiscovery(
settingsNs: string,
discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>,
): () => void {
const dispose = this.ctx.effect(function* (this: LlmService) {
if (settingsNs.length === 0) {
throw new LlmError('model discovery needs a non-empty settings namespace', 'INVALID_DISCOVERY')
}
if (this.discoveries.has(settingsNs)) {
throw new LlmError(`model discovery for "${settingsNs}" is already registered`, 'DUPLICATE_DISCOVERY')
}
this.discoveries.set(settingsNs, discover)
yield () => {
this.discoveries.delete(settingsNs)
}
}.bind(this), 'llm.registerModelDiscovery()')
return () => void dispose()
}
/**
* List the settings namespaces that can interrogate a provider endpoint, so
* a surface can offer the action only where it will work.
* @returns the namespaces in registration order.
*/
listModelDiscoveryNamespaces(): string[] {
return [...this.discoveries.keys()]
}
/**
* Interrogate one provider endpoint for the models it advertises. The
* request describes a draft, not a stored route, so nothing here reads or
* writes settings or credentials — the caller owns both, and the reply is
* candidate metadata a surface may offer for adoption.
* @param settingsNs - namespace whose registered discovery serves this draft.
* @param request - the endpoint, protocol, and one-shot credential to use.
* @returns the advertised models, deduplicated in endpoint order.
*/
async discoverModels(
settingsNs: string,
request: LlmModelDiscoveryRequest,
): Promise<LlmDiscoveredModel[]> {
const discover = this.discoveries.get(settingsNs)
if (discover === undefined) {
throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY')
}
if (request.baseURL.length === 0) {
throw new LlmError('model discovery needs a non-empty baseURL', 'INVALID_DISCOVERY')
}
const discovered = await discover(request)
const seen = new Set<string>()
const models: LlmDiscoveredModel[] = []
for (const model of discovered) {
if (typeof model.id !== 'string' || model.id.length === 0 || seen.has(model.id)) continue
seen.add(model.id)
models.push({
id: model.id,
...model.name === undefined ? {} : { name: model.name },
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
})
}
return models
}
/**
* Resolve the retry policy captured when one provider route was registered.
* @param provider - registered provider route to inspect.

View File

@@ -139,6 +139,39 @@ export interface LlmConfigurableProvider {
settingsPath: readonly string[]
}
/**
* One interrogation of a provider endpoint that configuration has not stored
* yet. Configuration surfaces send the draft a user is still editing, so the
* request carries the endpoint and credential directly instead of naming a
* route: a provider being added has no route to name.
*/
export interface LlmModelDiscoveryRequest {
/** Endpoint to interrogate. */
baseURL: string
/** Wire protocol the endpoint speaks, when the draft names one. */
api?: string
/** Credential for this interrogation alone; the harness never stores it. */
apiKey?: string
/** Caller cancellation; implementations must settle promptly after it aborts. */
signal?: AbortSignal
}
/**
* One model an endpoint reports about itself. Every field but the id is
* optional because most provider listings disclose an id and nothing else;
* a surface adopting one of these still owes the capacities its adapter needs.
*/
export interface LlmDiscoveredModel {
/** Model id the endpoint accepts. */
id: string
/** Human-readable name when the endpoint supplies one. */
name?: string
/** Maximum combined request and response context, when disclosed. */
contextWindow?: number
/** Maximum output tokens, when disclosed. */
maxTokens?: number
}
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
export interface LlmModelInfo {
/** Provider route that owns this model entry. */

View File

@@ -205,3 +205,55 @@ describe('configurable-provider directory', () => {
expect(ctx.llm.listConfigurableProviders()).toHaveLength(1)
})
})
describe('model discovery registry', () => {
it('offers one interrogation per settings namespace and disposes with its fiber', async () => {
const ctx = await setup()
const discover = vi.fn(() => Promise.resolve([{ id: 'from-endpoint' }]))
const dispose = ctx.llm.registerModelDiscovery('llm-example', discover)
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-example'])
await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' }))
.resolves.toEqual([{ id: 'from-endpoint' }])
expect(discover).toHaveBeenCalledWith({ baseURL: 'https://gateway.example/v1' })
dispose()
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual([])
})
it('rejects an unnamed namespace and a second registration of the same one', async () => {
const ctx = await setup()
const discover = (): Promise<never[]> => Promise.resolve([])
expect(() => ctx.llm.registerModelDiscovery('', discover)).toThrow(/non-empty settings namespace/)
ctx.llm.registerModelDiscovery('llm-example', discover)
expect(() => ctx.llm.registerModelDiscovery('llm-example', discover)).toThrow(/already registered/)
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-example'])
})
it('normalizes what an interrogation returns without inventing capacities', async () => {
const ctx = await setup()
ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([
{ id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 },
{ id: '' },
{ id: 'keep' },
{ id: 'bare' },
] as never))
expect(await ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })).toEqual([
{ id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 },
{ id: 'bare' },
])
})
it('refuses a namespace nothing serves and a draft with no endpoint', async () => {
const ctx = await setup()
ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([]))
await expect(ctx.llm.discoverModels('llm-absent', { baseURL: 'https://gateway.example/v1' }))
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
await expect(ctx.llm.discoverModels('llm-example', { baseURL: '' }))
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
})
})