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:
Tianyi Cui
2026-07-31 01:55:19 +08:00
664 changed files with 19076 additions and 2562 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: 2dbd530ca17ef34787cb4195c04ca85d768980b7
README.zh.md: 9928113f5cbfc49887980fde57ad4ee9f37dbd22
README.md: f4be9b298c730b7ec0a0faa4470890fe5e3f5af8
README.zh.md: 9aa22ba861ee368523b03a5472ea783bbcbbd765

View File

@@ -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

View File

@@ -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)。
## 模型体验

View File

@@ -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.

View File

@@ -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 })
}
/**

View File

@@ -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. */

View File

@@ -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/)
})
})

View File

@@ -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([])
})
})

View 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)
})
})