Merge remote-tracking branch 'github/master' into feat/agent-event-payload

# Conflicts:
#	docs/core-data-structures/core.i18n.yaml
This commit is contained in:
_Kerman
2026-08-06 17:10:01 +08:00
171 changed files with 9052 additions and 950 deletions

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: aea11a0665f1dd4493da8bd09e0fbb974f93afce
README.zh.md: 36b89a50b09385fa1681a25a19c2e1bea1a2bff9
README.md: 00b9ea075af7eb55041d48ceb3a1363e9de90397
README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9

View File

@@ -36,7 +36,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. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, 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 payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. 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.
## Carrier layer (`/client` + root)

View File

@@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `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`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 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` 错误应答这些领域。
## 载体层(`/client` + 根路径)

View File

@@ -2506,6 +2506,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async models(request) {
return ok(request, await buildModelCatalog(ctx))
},
async discoverModels(request, signal) {
const { settingsNs, provider, baseURL, api, apiKey } = request.payload
try {
const models = await ctx.llm.discoverModels(settingsNs, {
...provider === undefined ? {} : { provider },
...baseURL === undefined ? {} : { 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 === undefined ? {} : { baseURL } },
})
}
},
},
events: {

View File

@@ -51,7 +51,7 @@ export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, Too
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
export type { ConfigurableProviderView, LlmApi } from './llm.ts'
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

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,30 @@ 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),
provider: z.string().min(1).optional(),
baseURL: z.string().min(1).optional(),
api: z.string().min(1).optional(),
// Write-only at the host: used for this one interrogation, never stored and
// never returned. It does ride the client's outgoing envelope like every
// other secret-bearing payload (`credentials.set`, `settings.update`), which
// `subscribeEnvelopes()` observers can see — redacting that tap is a
// configuration-plane-wide change, not this method's to make alone.
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,43 @@ 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 answers, and the rest comes from the form. `provider`
* names the route being edited when there is one — an adapter that already
* describes that route answers from its own registry, with better metadata
* and no network call, and needs no endpoint. A route it does not describe is
* asked over the wire, which is what `baseURL`, `api`, and `apiKey` are for.
*
* Nothing is written — the reply is candidates, and only a later
* `settings.mutate` decides what a route serves. `apiKey` is accepted here
* but never stored or returned; a provider whose key is already stored omits
* it and the endpoint answers unauthenticated or refuses.
*/
discoverModels(
request: RpcRequest<{
settingsNs: string
provider?: 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().optional() }) }),
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

@@ -523,11 +523,16 @@ describe('llm domain', () => {
])
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
// Only one namespace can answer an interrogation, so the flag follows the
// entry's namespace rather than being assumed for every row.
ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([]))
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.llm.providers(request({})))
expect(value.providers).toEqual([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false },
// An undeclared live route has no settings address, so nothing can be
// interrogated on its behalf either.
{ provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
])
})
@@ -560,3 +565,108 @@ 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('carries the route being edited so an adapter can answer from its own registry', async () => {
const ctx = await harness()
let probe: unknown
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
probe = request_
return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
})
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
provider: 'deepseek',
})))
// No endpoint at all: a route the adapter already describes needs none.
expect(probe).toEqual({ provider: 'deepseek' })
expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
})
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),