Merge commit 'refs/codex/pr1006/master' into worktree/pr1006-merge-20260731
# Conflicts: # docs/architecture.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/headless-agent/cordis.yml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/llm/llm-deepseek/README.i18n.yaml # packages/llm/llm-deepseek/README.md # packages/llm/llm-deepseek/README.zh.md # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-deepseek/src/index.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/subagent/subagent-dsh-sdk/README.i18n.yaml # packages/ui/jsonrpc/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
||||
README.md: 2dbd530ca17ef34787cb4195c04ca85d768980b7
|
||||
README.zh.md: 9928113f5cbfc49887980fde57ad4ee9f37dbd22
|
||||
README.md: f4be9b298c730b7ec0a0faa4470890fe5e3f5af8
|
||||
README.zh.md: 9aa22ba861ee368523b03a5472ea783bbcbbd765
|
||||
|
||||
@@ -10,8 +10,10 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
|
||||
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` 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.
|
||||
- `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.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 +25,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
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.
|
||||
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context`, `defaultMaxTokens`, or `reasoning` fields preserve unknown capacity, provider-owned output defaults, or unavailable reasoning capability. Invalid identity, context, output default, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, `INVALID_MODEL_MAX_TOKENS`, or `INVALID_MODEL_REASONING`.
|
||||
|
||||
`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults` and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
@@ -67,7 +71,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
|
||||
### Real adapters
|
||||
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek-official` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
|
||||
### 公开 API
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
|
||||
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。
|
||||
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。
|
||||
- `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 +25,8 @@
|
||||
|
||||
提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此重读 `listProviders()`/`listModels()`/`listConfigurableProviders()` 而非轮询。观察者故障会被隔离(记录日志、不否决);只有带 `INVARIANT` 码的故障会在扇出后重新抛出。
|
||||
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context`、`defaultMaxTokens` 或 `reasoning` 字段会分别保留未知容量、提供方持有的输出默认值或不可用的推理能力。无效的身份、上下文、输出默认值或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT`、`INVALID_MODEL_MAX_TOKENS` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
|
||||
`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会通过 `adapterDefaults` 报告它填入了哪些 `maxTokens` 和 `reasoningEffort` 字段,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||
@@ -67,7 +71,7 @@
|
||||
|
||||
### 真实适配器
|
||||
|
||||
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
|
||||
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmConfigurableProvider,
|
||||
LlmFailure,
|
||||
LlmModelInfo,
|
||||
LlmResolvedModelInfo,
|
||||
@@ -56,6 +57,17 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
|
||||
/**
|
||||
* The provider topology changed: an adapter registered or unregistered
|
||||
* routes, or the configurable-provider directory gained or lost entries.
|
||||
* This is a payload-free registry notification fired at each commit point
|
||||
* (including registration disposal); consumers re-read `listProviders()`,
|
||||
* `listModels()`, or `listConfigurableProviders()` for the new state.
|
||||
* Observer failures are contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'llm/adapters-updated'(): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,56 +198,159 @@ export abstract class LlmAdapter {
|
||||
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
}
|
||||
|
||||
/**
|
||||
* What {@link LlmService.registerAdapter} returns: the disposer, plus an
|
||||
* atomic route replacement for the same adapter instance.
|
||||
*/
|
||||
export interface AdapterRegistrationHandle {
|
||||
/** Release every route this registration currently holds. */
|
||||
(): void
|
||||
/**
|
||||
* Replace this registration's routes with `providers`, keeping the same
|
||||
* adapter instance. The candidate set is validated in full first — a
|
||||
* conflict with another adapter, an invalid name, or bad provider metadata
|
||||
* throws and leaves the current routes untouched — and the swap itself is
|
||||
* one synchronous section, so no request can observe a gap. An empty array
|
||||
* is legal here (a settings section that emptied holds zero routes while
|
||||
* staying registered), unlike an empty initial registration.
|
||||
*
|
||||
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
|
||||
* has been released: its routes are gone and its disposer has already run,
|
||||
* so anything registered afterwards would have no owner left to release it.
|
||||
* @param providers - the complete next route set for this registration.
|
||||
*/
|
||||
replace(providers: string[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The abstract `llm` service: an adapter registry plus a streaming model-call
|
||||
* surface, interceptable via the `llm/stream` waterfall.
|
||||
*/
|
||||
export class LlmService extends Service {
|
||||
private adapters = new Map<string, AdapterRegistration>()
|
||||
private directory = new Map<string, LlmConfigurableProvider>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'llm')
|
||||
}
|
||||
|
||||
/** Notify topology observers without letting one broken listener veto the commit. */
|
||||
private emitAdaptersUpdated(): void {
|
||||
// Cordis emit uses Array.map: one synchronous throw starves later
|
||||
// listeners. Registry notifications are non-vetoing, so contain each
|
||||
// callback independently; INVARIANT-coded failures still surface.
|
||||
let invariantFailure: unknown
|
||||
for (const listener of this.ctx.events.dispatch('emit', ['llm/adapters-updated']) as Array<() => unknown>) {
|
||||
try {
|
||||
const returned = listener()
|
||||
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
|
||||
// An emit listener may still be an async function; its rejection
|
||||
// cannot reach the synchronous INVARIANT rethrow below, so it is
|
||||
// contained here instead of becoming an unhandled rejection.
|
||||
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
|
||||
this.warnAdaptersListenerFailure(error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
|
||||
invariantFailure ??= error
|
||||
continue
|
||||
}
|
||||
this.warnAdaptersListenerFailure(error)
|
||||
}
|
||||
}
|
||||
if (invariantFailure !== undefined) throw invariantFailure as Error
|
||||
}
|
||||
|
||||
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
||||
private warnAdaptersListenerFailure(error: unknown): void {
|
||||
this.ctx.logger.warn('llm: an llm/adapters-updated listener failed')
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an adapter for the given provider routes. Throws `LlmError` with code
|
||||
* `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).
|
||||
* Disposed with the fiber.
|
||||
* @param providers - every provider route this adapter should serve.
|
||||
* @param adapter - the adapter that streams calls for those providers.
|
||||
* @returns the disposer that unregisters all of them.
|
||||
* @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
|
||||
*/
|
||||
registerAdapter(providers: string[], adapter: LlmAdapter): () => void {
|
||||
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
|
||||
// The routes this registration currently holds; `replace` rewrites it, and
|
||||
// the disposer releases whatever it holds at disposal time.
|
||||
const owned = new Set<string>()
|
||||
// The disposer has run: `owned` being empty cannot say so on its own,
|
||||
// because `replace([])` legally leaves a live registration holding none.
|
||||
let released = false
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
|
||||
const unique = new Set<string>()
|
||||
const registrations: AdapterRegistration[] = []
|
||||
for (const provider of providers) {
|
||||
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
|
||||
if (unique.has(provider) || this.adapters.has(provider)) {
|
||||
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||
}
|
||||
const info = adapter.providerInfo(provider)
|
||||
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
|
||||
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
|
||||
}
|
||||
unique.add(provider)
|
||||
const retryPolicy = adapter.providerRetryPolicy(provider)
|
||||
?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
|
||||
registrations.push({
|
||||
adapter,
|
||||
provider: { id: info.id, name: info.name },
|
||||
retryPolicy,
|
||||
})
|
||||
}
|
||||
for (const registration of registrations) this.adapters.set(registration.provider.id, registration)
|
||||
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
|
||||
yield () => {
|
||||
for (const provider of providers) this.adapters.delete(provider)
|
||||
released = true
|
||||
for (const provider of owned) this.adapters.delete(provider)
|
||||
owned.clear()
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
}.bind(this), 'llm.registerAdapter()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
const handle = (() => void dispose()) as AdapterRegistrationHandle
|
||||
handle.replace = (next: string[]): void => {
|
||||
// Registering here would leak: the effect's disposer already ran, so
|
||||
// nothing remains to release whatever this call would put in the map.
|
||||
if (released) {
|
||||
throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED')
|
||||
}
|
||||
this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned))
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one candidate route set for `adapter`, treating routes this
|
||||
* registration already holds as available. Nothing is mutated: a rejected
|
||||
* candidate leaves the registry exactly as it was.
|
||||
*/
|
||||
private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet<string>): AdapterRegistration[] {
|
||||
const unique = new Set<string>()
|
||||
const registrations: AdapterRegistration[] = []
|
||||
for (const provider of providers) {
|
||||
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
|
||||
if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) {
|
||||
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||
}
|
||||
const info = adapter.providerInfo(provider)
|
||||
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
|
||||
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
|
||||
}
|
||||
unique.add(provider)
|
||||
const retryPolicy = adapter.providerRetryPolicy(provider)
|
||||
?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
|
||||
registrations.push({
|
||||
adapter,
|
||||
provider: { id: info.id, name: info.name },
|
||||
retryPolicy,
|
||||
})
|
||||
}
|
||||
return registrations
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap this registration's routes for the prepared ones in one synchronous
|
||||
* section, so no observer can see the registry between the release and the
|
||||
* re-registration. The route set's one mutation point is also where
|
||||
* `llm/adapters-updated` is published, so a `replace` announces itself
|
||||
* exactly like a first registration.
|
||||
*/
|
||||
private commitRoutes(owned: Set<string>, registrations: readonly AdapterRegistration[]): void {
|
||||
for (const provider of owned) this.adapters.delete(provider)
|
||||
owned.clear()
|
||||
for (const registration of registrations) {
|
||||
this.adapters.set(registration.provider.id, registration)
|
||||
owned.add(registration.provider.id)
|
||||
}
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,6 +361,50 @@ export class LlmService extends Service {
|
||||
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare provider routes an adapter plugin can activate through
|
||||
* configuration. Registration is all-or-nothing: an empty list, invalid
|
||||
* 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 the disposer that withdraws all of them.
|
||||
*/
|
||||
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
if (entries.length === 0) {
|
||||
throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY')
|
||||
}
|
||||
const detached: LlmConfigurableProvider[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.provider.length === 0 || entry.displayName.length === 0 || entry.settingsNs.length === 0) {
|
||||
throw new LlmError('configurable providers need a non-empty provider, displayName, and settingsNs', 'INVALID_DIRECTORY')
|
||||
}
|
||||
if (entry.settingsPath.some(segment => segment.length === 0)) {
|
||||
throw new LlmError(`configurable provider "${entry.provider}" has an empty settingsPath segment`, 'INVALID_DIRECTORY')
|
||||
}
|
||||
if (this.directory.has(entry.provider) || detached.some(seen => seen.provider === entry.provider)) {
|
||||
throw new LlmError(`configurable provider "${entry.provider}" is already declared`, 'DUPLICATE_DIRECTORY')
|
||||
}
|
||||
detached.push({ ...entry, settingsPath: [...entry.settingsPath] })
|
||||
}
|
||||
for (const entry of detached) this.directory.set(entry.provider, entry)
|
||||
this.emitAdaptersUpdated()
|
||||
yield () => {
|
||||
for (const entry of detached) this.directory.delete(entry.provider)
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
}.bind(this), 'llm.registerConfigurableProviders()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* List every declared configurable provider, registered or dormant.
|
||||
* @returns detached directory entries in declaration order.
|
||||
*/
|
||||
listConfigurableProviders(): LlmConfigurableProvider[] {
|
||||
return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the retry policy captured when one provider route was registered.
|
||||
* @param provider - registered provider route to inspect.
|
||||
|
||||
@@ -84,6 +84,21 @@ async function* validateStream(
|
||||
/** Install validation around every provider stream. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true })
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
// A disposer-time emit can outlive the service-store entry during whole-
|
||||
// context teardown; only a live service promises a readable registry.
|
||||
const llm = ctx.get('llm')
|
||||
if (llm === undefined) return
|
||||
for (const provider of llm.listProviders()) {
|
||||
try {
|
||||
llm.providerRetryPolicy(provider.id)
|
||||
} catch {
|
||||
// Reaching here IS the violation: the notification promised a readable
|
||||
// registry, and only that broken promise can make the lookup throw.
|
||||
fail(`llm/adapters-updated fired while provider "${provider.id}" has no readable registration`)
|
||||
}
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,6 +119,26 @@ export interface LlmProviderInfo {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider route an adapter plugin can activate through configuration,
|
||||
* whether or not the route is currently registered. Configuration surfaces
|
||||
* merge this directory with `listProviders()` to offer every configurable
|
||||
* provider alongside its live/dormant state.
|
||||
*/
|
||||
export interface LlmConfigurableProvider {
|
||||
/** Provider route key this entry activates when configured. */
|
||||
provider: string
|
||||
/** Human-readable provider name for configuration surfaces. */
|
||||
displayName: string
|
||||
/** User-settings namespace whose section configures this provider. */
|
||||
settingsNs: string
|
||||
/**
|
||||
* Path from that namespace's section root to this provider's profile
|
||||
* object; empty when the whole section is the profile.
|
||||
*/
|
||||
settingsPath: readonly string[]
|
||||
}
|
||||
|
||||
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
|
||||
export interface LlmModelInfo {
|
||||
/** Provider route that owns this model entry. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmInvariant from '@deepseek-ai/dsh-llm/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -84,3 +84,40 @@ describe('LLM stream invariants', () => {
|
||||
})()).rejects.toThrow('provider failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adapters-updated invariants', () => {
|
||||
class NoopAdapter extends LlmAdapter {
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('not exercised')
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts a coherent registry at every topology notification', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(LlmService)
|
||||
const dispose = ctx.llm.registerAdapter(['coherent'], new NoopAdapter())
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: 'dormant', displayName: 'Dormant', settingsNs: 'ns', settingsPath: [] },
|
||||
])
|
||||
dispose()
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('skips the check when the service store has no llm entry', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('llm/adapters-updated') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports a notification whose registry cannot be re-read', async () => {
|
||||
class BrokenLlm extends LlmService {
|
||||
override providerRetryPolicy(_provider: string): never {
|
||||
throw new Error('registration vanished')
|
||||
}
|
||||
}
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(BrokenLlm)
|
||||
expect(() => ctx.llm.registerAdapter(['ghost'], new NoopAdapter()))
|
||||
.toThrow(/no readable registration/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1436,4 +1436,32 @@ describe('LlmService', () => {
|
||||
disposeAgain()
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses to replace routes on a registration that was already released', async () => {
|
||||
// The leak this prevents: the effect's disposer has run, so a route added
|
||||
// afterwards would sit in the registry with nothing left to release it.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
handle()
|
||||
expect(() => { handle.replace(['leaked']) })
|
||||
.toThrow(/disposed adapter registration cannot replace its routes/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('still allows an empty route set on a live registration', async () => {
|
||||
// `replace([])` is the settings-section-emptied case: legal, and it must
|
||||
// not be mistaken for disposal by the guard above.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
handle.replace([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
handle.replace(['m2'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'm2', name: 'm2' }])
|
||||
handle()
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
181
packages/llm/llm/tests/topology.spec.ts
Normal file
181
packages/llm/llm/tests/topology.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmConfigurableProvider, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class NoopAdapter extends LlmAdapter {
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('not exercised')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function entry(overrides: Partial<LlmConfigurableProvider> = {}): LlmConfigurableProvider {
|
||||
return {
|
||||
provider: 'openai',
|
||||
displayName: 'OpenAI',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('llm/adapters-updated', () => {
|
||||
it('fires at both adapter registration commit points with the registry already readable', async () => {
|
||||
const ctx = await setup()
|
||||
const observed: string[][] = []
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
observed.push(ctx.llm.listProviders().map(provider => provider.id))
|
||||
})
|
||||
const dispose = ctx.llm.registerAdapter(['a', 'b'], new NoopAdapter())
|
||||
expect(observed).toEqual([['a', 'b']])
|
||||
dispose()
|
||||
expect(observed).toEqual([['a', 'b'], []])
|
||||
})
|
||||
|
||||
it('contains a throwing listener without vetoing registration or starving later listeners', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const later = vi.fn()
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
throw new Error('broken observer')
|
||||
})
|
||||
ctx.on('llm/adapters-updated', later)
|
||||
ctx.llm.registerAdapter(['a'], new NoopAdapter())
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['a'])
|
||||
expect(later).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith('llm: an llm/adapters-updated listener failed')
|
||||
})
|
||||
|
||||
it('contains an ASYNC listener rejection instead of leaving it unhandled', async () => {
|
||||
// An emit listener may be an async function; its rejection cannot reach
|
||||
// the synchronous catch, so an uncontained one escapes the process as an
|
||||
// unhandled rejection rather than a warned observer failure.
|
||||
const ctx = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
// Typed as returning unknown so the listener is not a Promise-returning
|
||||
// function type: the point is exactly that an async one may slip in.
|
||||
const rejecting = (): unknown => Promise.reject(new Error('async observer'))
|
||||
ctx.on('llm/adapters-updated', rejecting)
|
||||
ctx.llm.registerAdapter(['a'], new NoopAdapter())
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['a'])
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith('llm: an llm/adapters-updated listener failed')
|
||||
} finally {
|
||||
process.off('unhandledRejection', unhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces a route set in one event, never publishing an empty registry between the two', async () => {
|
||||
// The retry-policy swap in llm-deepseek: disposing and re-registering
|
||||
// would let an observer see the provider disappear and come back.
|
||||
const ctx = await setup()
|
||||
const observed: string[][] = []
|
||||
const registration = ctx.llm.registerAdapter(['a'], new NoopAdapter())
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
observed.push(ctx.llm.listProviders().map(provider => provider.id))
|
||||
})
|
||||
registration.replace(['a'])
|
||||
expect(observed).toEqual([['a']])
|
||||
})
|
||||
|
||||
it('rethrows the first INVARIANT-coded listener failure after notifying the rest', async () => {
|
||||
const ctx = await setup()
|
||||
const later = vi.fn()
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
throw Object.assign(new Error('registry incoherent'), { code: 'INVARIANT' })
|
||||
})
|
||||
ctx.on('llm/adapters-updated', later)
|
||||
expect(() => ctx.llm.registerAdapter(['a'], new NoopAdapter())).toThrow('registry incoherent')
|
||||
expect(later).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('configurable-provider directory', () => {
|
||||
it('registers entries, lists detached copies in order, and fires the topology event', async () => {
|
||||
const ctx = await setup()
|
||||
const events = vi.fn()
|
||||
ctx.on('llm/adapters-updated', events)
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
entry({ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }),
|
||||
entry(),
|
||||
])
|
||||
expect(events).toHaveBeenCalledTimes(1)
|
||||
const listed = ctx.llm.listConfigurableProviders()
|
||||
expect(listed).toEqual([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
{ provider: 'openai', displayName: 'OpenAI', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
])
|
||||
listed[0]!.displayName = 'mutated'
|
||||
;(listed[1]!.settingsPath as string[]).push('mutated')
|
||||
expect(ctx.llm.listConfigurableProviders()[0]!.displayName).toBe('DeepSeek')
|
||||
expect(ctx.llm.listConfigurableProviders()[1]!.settingsPath).toEqual(['providers', 'openai'])
|
||||
})
|
||||
|
||||
it('detaches stored entries from caller-owned objects', async () => {
|
||||
const ctx = await setup()
|
||||
const source = entry()
|
||||
ctx.llm.registerConfigurableProviders([source])
|
||||
source.displayName = 'mutated'
|
||||
expect(ctx.llm.listConfigurableProviders()[0]!.displayName).toBe('OpenAI')
|
||||
})
|
||||
|
||||
it('withdraws every entry when the registration disposes', async () => {
|
||||
const ctx = await setup()
|
||||
const dispose = ctx.llm.registerConfigurableProviders([entry()])
|
||||
const events = vi.fn()
|
||||
ctx.on('llm/adapters-updated', events)
|
||||
dispose()
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
expect(events).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('withdraws entries when the contributing fiber disposes', async () => {
|
||||
const ctx = await setup()
|
||||
const fiber = await ctx.plugin({
|
||||
inject: ['llm'],
|
||||
apply: (child: Context) => {
|
||||
child.llm.registerConfigurableProviders([entry()])
|
||||
},
|
||||
})
|
||||
expect(ctx.llm.listConfigurableProviders()).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an empty registration', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.llm.registerConfigurableProviders([])).toThrow(LlmError)
|
||||
expect(() => ctx.llm.registerConfigurableProviders([])).toThrow(/at least one provider/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[entry({ provider: '' }), /non-empty provider/],
|
||||
[entry({ displayName: '' }), /non-empty provider/],
|
||||
[entry({ settingsNs: '' }), /non-empty provider/],
|
||||
[entry({ settingsPath: ['providers', ''] }), /empty settingsPath segment/],
|
||||
])('rejects invalid entries all-or-nothing', async (invalid, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.llm.registerConfigurableProviders([entry({ provider: 'valid-first' }), invalid])).toThrow(message)
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicates within one registration and across registrations', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.llm.registerConfigurableProviders([entry(), entry()])).toThrow(/already declared/)
|
||||
ctx.llm.registerConfigurableProviders([entry()])
|
||||
expect(() => ctx.llm.registerConfigurableProviders([entry({ displayName: 'Other' }), entry({ provider: 'unseen' })]))
|
||||
.toThrow(/already declared/)
|
||||
expect(ctx.llm.listConfigurableProviders()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user