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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'>>>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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() }) }),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user