mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
|
||||
2026-08-04-draft-provider-endpoint-interrogation.md: 86b3148626fea90f1d87b80084f7cd4bafeeb1f8
|
||||
2026-08-04-draft-provider-endpoint-interrogation.zh.md: 0f6a63385dc628938c702aca1895b608e4eeaf9a
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Interrogating a draft provider endpoint
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`.
|
||||
|
||||
The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves.
|
||||
|
||||
The awkward part is that the question is about something that does not exist yet. The provider being added has no route, no stored profile, and no stored credential; the endpoint and key are values in a form the user is still typing. Every existing seam operation is keyed by a registered provider route, so none of them can carry this.
|
||||
|
||||
## Decision
|
||||
|
||||
Interrogation is keyed by **settings namespace**, not by provider route:
|
||||
|
||||
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns; `ctx.llm.listModelDiscoveryNamespaces()` lets a surface offer the action only where it works; `ctx.llm.discoverModels(settingsNs, request)` asks. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name.
|
||||
- `LlmModelDiscoveryRequest` carries the draft — `baseURL`, an optional `api`, an optional `apiKey`, and a signal. Nothing in this path reads or writes settings or credentials; the caller owns both.
|
||||
- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
|
||||
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored, logged, or echoed. Every refusal 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.
|
||||
|
||||
`dsh-llm-pi-ai` implements it as a plain `GET {baseURL}/models` for OpenAI-compatible protocols only. Their listing shape is the one a gateway, a self-hosted server, and the official endpoints all agree on, which is the case this action exists for. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` 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. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs.
|
||||
|
||||
### Why not pi-ai's own refresh machinery
|
||||
|
||||
pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `ModelsStore`, and the layer below already builds pi-ai `Provider` objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that `settings.yaml` owns the catalog. It would also have bought nothing: **no built-in pi-ai provider implements `fetchModels`**, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Key interrogation by provider route.** Symmetric with every other seam operation, and it would let the request omit the endpoint. But the case that motivates the feature — adding a provider — has no route, so the operation would only work for providers already configured, which are the ones that need it least.
|
||||
|
||||
**Put the capability on `LlmAdapter`.** Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve.
|
||||
|
||||
**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical.
|
||||
|
||||
**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback.
|
||||
|
||||
**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed.
|
||||
|
||||
## Consequences
|
||||
|
||||
A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber.
|
||||
|
||||
What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 询问草稿中的提供方端点
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-draft-provider-endpoint-interrogation.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models` 上公布了这份列表。
|
||||
|
||||
显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是**只问一次**,其答案由用户采纳进 `settings.yaml`——从而让 `settings.yaml` 始终是唯一决定路由服务什么的东西。
|
||||
|
||||
麻烦之处在于,被问的对象还不存在。正在新增的提供方没有路由、没有已存 profile、也没有已存凭据;端点与密钥都是用户尚在输入的表单值。而现有的每个 seam 操作都以已注册的提供方路由为键,因此没有一个能承载它。
|
||||
|
||||
## Decision
|
||||
|
||||
询问以 **settings namespace** 为键,而不是提供方路由:
|
||||
|
||||
- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力;`ctx.llm.listModelDiscoveryNamespaces()` 让界面只在可用之处提供该动作;`ctx.llm.discoverModels(settingsNs, request)` 发起询问。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。
|
||||
- `LlmModelDiscoveryRequest` 携带草稿——`baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal。这条路径既不读也不写 settings 与 credentials;两者都归调用方所有。
|
||||
- `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。
|
||||
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储、记录或回显。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
|
||||
|
||||
`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。
|
||||
|
||||
### 为什么不用 pi-ai 自己的 refresh 机制
|
||||
|
||||
pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 `ModelsStore`,而下层本来就在构造 pi-ai `Provider` 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的 provider 与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「`settings.yaml` 拥有 catalog」的决定相抵触。而且它什么也换不来:**没有任何一个 pi-ai 内置 provider 实现了 `fetchModels`**,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**以提供方路由为键。** 与其他每个 seam 操作对称,也能让请求省去端点。但催生该功能的场景——新增提供方——没有路由,于是这个操作只对已配置好的提供方可用,而它们恰恰最不需要它。
|
||||
|
||||
**把能力挂在 `LlmAdapter` 上。** 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。
|
||||
|
||||
**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有 secret 跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致。
|
||||
|
||||
**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。
|
||||
|
||||
**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。
|
||||
|
||||
## Consequences
|
||||
|
||||
接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、除 fiber 外没有生命周期。
|
||||
|
||||
代价是:协议层多了第三个承载 secret 的载荷,配置面的只写接口从两个方法变成三个。发现能力按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。
|
||||
@@ -588,7 +588,7 @@ The provider topology changed: an adapter registered or unregistered routes, or
|
||||
'llm/adapters-updated'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts)
|
||||
Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
### `llm/stream` — waterfall
|
||||
|
||||
@@ -612,7 +612,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
|
||||
|
||||
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts)
|
||||
Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
## `session/*`
|
||||
|
||||
|
||||
@@ -834,9 +834,9 @@ listProviders(): LlmProviderInfo[]
|
||||
* entry, or a provider already declared by any registration throws
|
||||
* `LlmError` without registering the rest. Disposed with the fiber.
|
||||
* @param entries - every configurable provider this plugin owns.
|
||||
* @returns a handle that withdraws all of them, and can atomically replace them.
|
||||
* @returns the disposer that withdraws all of them.
|
||||
*/
|
||||
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle
|
||||
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void
|
||||
|
||||
/**
|
||||
* List every declared configurable provider, registered or dormant.
|
||||
@@ -844,6 +844,36 @@ registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): Dire
|
||||
*/
|
||||
listConfigurableProviders(): LlmConfigurableProvider[]
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
|
||||
/**
|
||||
* 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[]>
|
||||
|
||||
/**
|
||||
* Resolve the retry policy captured when one provider route was registered.
|
||||
* @param provider - registered provider route to inspect.
|
||||
@@ -908,9 +938,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
```
|
||||
|
||||
Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts:253`](../../packages/llm/llm/src/index.ts)
|
||||
Source: [`packages/llm/llm/src/index.ts:234`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
## `ctx.permission` — `PermissionService`
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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),
|
||||
|
||||
207
packages/llm/llm-pi-ai/src/discovery.ts
Normal file
207
packages/llm/llm-pi-ai/src/discovery.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
211
packages/llm/llm-pi-ai/tests/discovery.spec.ts
Normal file
211
packages/llm/llm-pi-ai/tests/discovery.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 码的故障会在扇出后重新抛出。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
LlmModelInfo: 'core.md',
|
||||
LlmProviderInfo: 'core.md',
|
||||
LlmConfigurableProvider: 'core.md',
|
||||
LlmModelDiscoveryRequest: 'core.md',
|
||||
LlmDiscoveredModel: 'core.md',
|
||||
ResolvedRetryPolicy: 'llm-streaming.md',
|
||||
Message: 'core.md',
|
||||
MessageSource: 'core.md',
|
||||
|
||||
Reference in New Issue
Block a user