Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.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: 901378a17c1a946715cd7181527fed07cf46e447
|
||||
README.zh.md: 3aed67e518a2e9575954595a8a844bfdf8d6a152
|
||||
README.md: d15b2c996d6d47371a3d6c5542eb5c253029dbae
|
||||
README.zh.md: d965f15298f09ff9c2a953a69346c4e83136934b
|
||||
|
||||
@@ -12,16 +12,21 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
- `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.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. An entry may carry `declared` — whether the owning adapter knows that route only because configuration named it. Only the adapter can answer, so absence means "this adapter draws no such distinction", never "shipped".
|
||||
- `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.
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize adapter-configured call defaults without clamping.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata and adapter-default provenance in one exact-model lookup, then capture its current adapter registration as one cancellable, one-shot call.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata and adapter-default provenance in one exact-model lookup, then capture its current adapter registration and immutable retry policy as one cancellable, one-shot call.
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
|
||||
|
||||
`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`.
|
||||
`LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy.
|
||||
|
||||
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 may still *name* a route it is editing, and an adapter that already describes that route should answer from its own knowledge — better metadata, no network call — which is why `baseURL` is optional and one of the two is required. The request otherwise 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 a request naming neither a route nor an 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`.
|
||||
|
||||
@@ -35,7 +40,6 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `llm/adapters-updated` | emit | Notify consumers to re-read provider and model topology after a registry commit |
|
||||
| `llm/stream` | waterfall | Intercept/wrap every streaming model call for caching, logging, or routing |
|
||||
|
||||
### Extension points
|
||||
@@ -49,7 +53,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
|
||||
|
||||
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
@@ -59,6 +63,10 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
|
||||
|
||||
### API key validation (`api-key.ts`)
|
||||
|
||||
Every adapter that puts a credential in an HTTP header judges it the same way before use. `normalizeApiKey(raw)` trims surrounding whitespace, then accepts any non-empty printable-ASCII value (`/^[\x21-\x7E]+$/`, space excluded) or reports why not as an `ApiKeyRejection` (`'empty'` | `'illegalCharacters'`), both carried in the `ApiKeyCheck` result. Absence is never judged: a caller decides whether a value was supplied before asking, since a profile naming no credential authenticates through the provider's own ambient discovery or OAuth.
|
||||
|
||||
### Classes
|
||||
|
||||
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
|
||||
@@ -69,10 +77,11 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
|
||||
- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
|
||||
- `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default.
|
||||
- `INVALID_CREDENTIAL_CODE` — the provider-neutral code for a credential that was supplied but cannot be used: malformed rather than absent, so the fix is to correct the stored value rather than supply one — the distinction from `MISSING_CREDENTIAL`. Deliberately excluded from the default retryable set, since a malformed credential fails identically on every attempt. `assertUsableApiKey(raw, pkg, ref)` throws `LlmError` with this code, the one shared diagnosis every adapter uses for an unusable stored credential.
|
||||
|
||||
### 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-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.
|
||||
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 and tool arguments remain raw strings. Adapter implementations may throw or emit a failure finish internally; `LlmService` exposes both as a terminal failure finish. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the adapter rationale and [the terminal-failure decision](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md) for the service boundary.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
提供方无关的 LLM(大语言模型)词汇与抽象服务。本包(package)定义 agent loop(智能体循环)、会话日志和每个插件使用的规范语言。
|
||||
提供方无关的 LLM(大语言模型)词汇与抽象服务。本包定义 agent loop(智能体循环)、会话日志和每个插件使用的规范语言。
|
||||
|
||||
## 服务:`LlmService`(ctx key:`llm`)
|
||||
|
||||
@@ -12,16 +12,21 @@
|
||||
|
||||
- `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.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。该句柄还带 `replace(entries)`:候选集合会先被整体校验,因此其中若有条目已被另一个注册声明,当前集合原封不动;此处允许传空数组。声明集合随配置变化的插件必须使用 `replace`,而不是先 dispose 再重新注册——后者会在新集合被拒时让目录整个落空。
|
||||
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。条目可携带 `declared`——拥有该路由的适配器是否只因配置点名才知道它。只有适配器能回答,因此缺席意味着「这个适配器不作此区分」,绝不等于「内置」。
|
||||
- `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)元数据;异步适配器可选地支持取消。
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置、脱耦的上下文元数据与适配器默认值溯源,再将其当前适配器注册捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置、脱耦的上下文元数据与适配器默认值溯源,再将当前适配器注册和不可变重试策略捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。
|
||||
|
||||
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。
|
||||
`LlmService` 将最终适配器选择、同步 dispatch、iterator 构造与迭代中的失败规范化为流协议唯一的终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分增量输出后发生失败时,内容块可能仍未闭合;消费方会丢弃这些不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出,因为它们属于插件或消费方失败,而非模型请求结果。已准备调用会暴露随其确切适配器注册一同捕获的不可变重试策略;完全由 middleware 处理的路由没有服务策略。
|
||||
|
||||
询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器应当用自己的知识作答——元数据更好,且无需联网——这正是 `baseURL` 可选、两者必居其一的原因。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。
|
||||
|
||||
提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
@@ -35,7 +40,6 @@
|
||||
|
||||
| 事件 | 模式 | 用途 |
|
||||
|---|---|---|
|
||||
| `llm/adapters-updated` | emit | 在注册表提交后通知消费方重新读取提供方和模型拓扑 |
|
||||
| `llm/stream` | waterfall | 拦截/包装每次流式模型调用,用于缓存、日志或路由 |
|
||||
|
||||
### 扩展点
|
||||
@@ -49,7 +53,7 @@
|
||||
|
||||
消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带提供方/模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。
|
||||
|
||||
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。
|
||||
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用其 `error` 或 `aborted` 原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。
|
||||
|
||||
### 调用配置(`call-config.ts`)
|
||||
|
||||
@@ -57,7 +61,11 @@
|
||||
|
||||
### 应用归因(`attribution.ts`)
|
||||
|
||||
每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。
|
||||
每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。
|
||||
|
||||
### API 密钥校验(`api-key.ts`)
|
||||
|
||||
每个要把凭据放进 HTTP 标头的适配器,使用前都以同一套规则校验它。`normalizeApiKey(raw)` 先去除首尾空白,再接受任意非空的可打印 ASCII 值(`/^[\x21-\x7E]+$/`,不含空格),否则以 `ApiKeyRejection`(`'empty'` | `'illegalCharacters'`)说明拒绝原因,二者一并包含在 `ApiKeyCheck` 结果中。缺失从不参与校验:调用方会在询问之前自行判断是否提供了值——未点名凭据的 profile 会转由提供方自身的环境发现或 OAuth 完成认证。
|
||||
|
||||
### 类
|
||||
|
||||
@@ -69,10 +77,11 @@
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。
|
||||
- `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。
|
||||
- `EMPTY_RESPONSE_CODE`:两个适配器都使用的提供方无关 code,用于表示退化的提供方生成结果:一个未携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。
|
||||
- `INVALID_CREDENTIAL_CODE`:已提供但无法使用的凭据所用的提供方无关 code——格式错误而非缺失,修复方式是改正已存储的值而非补供一个,这正是它与 `MISSING_CREDENTIAL` 的区别。它被刻意排除在默认可重试集合之外:格式错误的凭据每次尝试都会以同样方式失败。`assertUsableApiKey(raw, pkg, ref)` 会以该 code 抛出 `LlmError`,是每个适配器判定已存储凭据不可用时共用的诊断。
|
||||
|
||||
### 真实适配器
|
||||
|
||||
两个适配器使用不同内部机制实现 `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)。
|
||||
两个适配器使用不同内部机制实现 `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` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现在内部可以抛出异常或发出失败 finish;`LlmService` 会将两者都暴露为终止失败 finish。适配器理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,67 +1,40 @@
|
||||
/**
|
||||
* Private provider-failure tagging shared by `LlmService` and its consumers.
|
||||
* Normalization for values thrown by a final LLM adapter boundary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/adapter-failure
|
||||
*/
|
||||
|
||||
import { HarnessError } from './error.ts'
|
||||
import type { LlmFailure, StreamChunk } from './types.ts'
|
||||
import type { ResolvedRetryPolicy } from './retry-policy.ts'
|
||||
|
||||
/** Call-local facts captured when one model call enters its final adapter boundary. */
|
||||
export interface AdapterFailureScope {
|
||||
/** Errors and normalized facts proven to originate in this call's final adapter boundary. */
|
||||
readonly failures: WeakMap<Error, LlmFailure>
|
||||
/** Immutable policy of the exact adapter registration selected for this call. */
|
||||
retryPolicy?: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */
|
||||
const adapterFailureScopes = new WeakMap<AsyncIterable<StreamChunk>, AdapterFailureScope>()
|
||||
import type { LlmFailure } from './types.ts'
|
||||
|
||||
/**
|
||||
* Bind one call's adapter-failure scope to a unique returned stream handle.
|
||||
* @param stream - the waterfall-selected stream for this call.
|
||||
* @param failures - errors tagged by this call's final adapter boundary.
|
||||
* @returns a unique stream handle that delegates iteration to `stream`.
|
||||
* Detach serializable provider facts from a value thrown by an adapter.
|
||||
* @param value - arbitrary value thrown during adapter dispatch or iteration.
|
||||
* @returns immutable provider-neutral facts suitable for a terminal finish chunk.
|
||||
* @internal
|
||||
*/
|
||||
export function bindAdapterFailureScope(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
failures: AdapterFailureScope,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const call = {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
return stream[Symbol.asyncIterator]()
|
||||
},
|
||||
}
|
||||
adapterFailureScopes.set(call, failures)
|
||||
return call
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve an adapter's Error identity while tagging its provider origin.
|
||||
* @param failures - the call-local final-adapter failure scope.
|
||||
* @param value - arbitrary value thrown by adapter dispatch or iteration.
|
||||
* @returns the original Error, or a coded Error wrapping a non-Error throw.
|
||||
* @internal
|
||||
*/
|
||||
export function markLlmAdapterFailure(
|
||||
failures: AdapterFailureScope,
|
||||
value: unknown,
|
||||
): Error & { code?: string } {
|
||||
export function normalizeLlmFailure(value: unknown): LlmFailure {
|
||||
const error = value instanceof Error
|
||||
? value as Error & { code?: string }
|
||||
: new HarnessError(String(value), 'UNKNOWN', { cause: value })
|
||||
? value
|
||||
: new HarnessError(thrownMessage(value), 'UNKNOWN', { cause: value })
|
||||
// Cross-package copies preserve own data but not class identity. Trust the
|
||||
// carried facts only when both own properties agree after validation.
|
||||
const carried = ownFailureSnapshot(error)
|
||||
const failure = carried !== undefined && carried.code === ownErrorCode(error) ? carried : Object.freeze({
|
||||
if (carried !== undefined && carried.code === ownErrorCode(error)) return carried
|
||||
return Object.freeze({
|
||||
message: errorMessage(error),
|
||||
code: harnessErrorCode(error),
|
||||
})
|
||||
failures.failures.set(error, failure)
|
||||
return error
|
||||
}
|
||||
|
||||
/** Render a non-Error throw without letting hostile coercion escape normalization. */
|
||||
function thrownMessage(value: unknown): string {
|
||||
try {
|
||||
const message = String(value)
|
||||
return message.length > 0 ? message : 'LLM adapter failed'
|
||||
} catch (_hostileThrownValue) {
|
||||
return 'LLM adapter failed'
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a foreign error's own data-backed `code` without invoking accessors. */
|
||||
@@ -129,46 +102,3 @@ function errorMessage(error: Error): string {
|
||||
function harnessErrorCode(error: Error): string {
|
||||
return error instanceof HarnessError ? error.code : 'UNKNOWN'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure came from final adapter dispatch, iterator construction,
|
||||
* or iteration for the call represented by the exact returned stream handle.
|
||||
* @param stream - the exact stream returned by the model call being classified.
|
||||
* @param value - arbitrary failure caught by a model-call consumer.
|
||||
* @returns true only for errors tagged at that call's final adapter boundary.
|
||||
*/
|
||||
export function isLlmAdapterFailure(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
value: unknown,
|
||||
): value is Error & { code?: string } {
|
||||
const failures = adapterFailureScopes.get(stream)
|
||||
return value instanceof Error && failures !== undefined && failures.failures.has(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve normalized provider facts only for an Error tagged by this exact
|
||||
* model call's final adapter boundary.
|
||||
* @param stream - the exact stream returned to the consumer.
|
||||
* @param value - the caught failure.
|
||||
* @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures.
|
||||
*/
|
||||
export function llmFailureOf(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
value: unknown,
|
||||
): LlmFailure | undefined {
|
||||
const failures = adapterFailureScopes.get(stream)
|
||||
return value instanceof Error ? failures?.failures.get(value) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the retry policy of the exact registration selected at this call's
|
||||
* final adapter boundary. The policy remains available after that registration
|
||||
* is disposed or replaced; absence means no final adapter served the call.
|
||||
* @param stream - the exact stream returned by the model call.
|
||||
* @returns the immutable serving-registration policy, or `undefined`.
|
||||
*/
|
||||
export function llmRetryPolicyOf(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
): ResolvedRetryPolicy | undefined {
|
||||
return adapterFailureScopes.get(stream)?.retryPolicy
|
||||
}
|
||||
|
||||
41
packages/llm/llm/src/api-key.ts
Normal file
41
packages/llm/llm/src/api-key.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The one definition of a well-formed provider API key, shared by every
|
||||
* adapter that puts one in an HTTP header.
|
||||
* @module @deepseek-ai/dsh-llm/api-key
|
||||
*/
|
||||
|
||||
/**
|
||||
* Characters an HTTP header value carries verbatim and every known provider
|
||||
* key uses: printable ASCII, space excluded. A key outside this set cannot
|
||||
* reach any provider — `fetch` refuses to build the header — so this is a
|
||||
* transport invariant rather than one provider's policy. Latin-1 is excluded
|
||||
* deliberately: a header could carry it, but no provider issues it, and
|
||||
* admitting it trades a local explained refusal for an opaque 401.
|
||||
*/
|
||||
const LEGAL_API_KEY = /^[\x21-\x7E]+$/
|
||||
|
||||
/** Why a supplied API key cannot be used. */
|
||||
export type ApiKeyRejection = 'empty' | 'illegalCharacters'
|
||||
|
||||
/** The verdict on one supplied API key. */
|
||||
export type ApiKeyCheck =
|
||||
| { readonly ok: true; readonly value: string }
|
||||
| { readonly ok: false; readonly reason: ApiKeyRejection }
|
||||
|
||||
/**
|
||||
* Judge one *supplied* API key, trimming surrounding whitespace first.
|
||||
*
|
||||
* Trimming is silent because a padded key has one unambiguous reading; every
|
||||
* other defect is reported. Absence is a configuration state this function
|
||||
* never sees — a profile naming no credential authenticates through the
|
||||
* provider's own ambient discovery or OAuth — so callers decide whether a
|
||||
* value was supplied before asking.
|
||||
* @param raw - the key exactly as configured, stored, or typed.
|
||||
* @returns the trimmed key, or why it cannot be used.
|
||||
*/
|
||||
export function normalizeApiKey(raw: string): ApiKeyCheck {
|
||||
const value = raw.trim()
|
||||
if (value.length === 0) return { ok: false, reason: 'empty' }
|
||||
if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' }
|
||||
return { ok: true, value }
|
||||
}
|
||||
@@ -127,11 +127,15 @@ export class BlockAssembler {
|
||||
|
||||
/**
|
||||
* Assemble all blocks seen so far, in stream order.
|
||||
* @returns one block per seen index; an open block assembles from its
|
||||
* accumulated deltas (an unknown block type never closed by `block-end` throws).
|
||||
* @returns one block per seen index, except that max-token truncation drops
|
||||
* tool calls that cannot be executed safely; an open block assembles from
|
||||
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
|
||||
*/
|
||||
blocks(): ContentBlock[] {
|
||||
return this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
const blocks = this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
return this.finish.kind === 'max-tokens'
|
||||
? blocks.filter(block => block.type !== 'tool-call')
|
||||
: blocks
|
||||
}
|
||||
|
||||
/** Usage from the `usage` chunk; undefined until one arrives. */
|
||||
|
||||
@@ -38,6 +38,15 @@ export const QUOTA_EXCEEDED_CODE = 'QUOTA'
|
||||
*/
|
||||
export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
|
||||
|
||||
/**
|
||||
* Canonical provider-neutral code for a credential that was supplied but
|
||||
* cannot be used — malformed rather than absent. Distinct from
|
||||
* `MISSING_CREDENTIAL` because the fix differs: correct the stored value
|
||||
* rather than supply one. Deliberately outside the default retryable set —
|
||||
* a malformed credential fails identically on every attempt.
|
||||
*/
|
||||
export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL'
|
||||
|
||||
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
|
||||
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
|
||||
String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]`
|
||||
@@ -93,7 +102,8 @@ export function isQuotaExceededError(detail: string): boolean {
|
||||
/**
|
||||
* Render a thrown value with its full `cause` chain and AggregateError
|
||||
* members, so transport wrappers like undici's `TypeError: fetch failed`
|
||||
* surface the underlying failure instead of masking it. Diagnostic-surface
|
||||
* surface the underlying failure instead of masking it. Plain structured
|
||||
* failures render their own data-backed `message`. Diagnostic-surface
|
||||
* rendering only (messages, notices, logs) — never parse the result; route on
|
||||
* {@link HarnessError.code}.
|
||||
* @param value - the caught value (`unknown` in catch clauses).
|
||||
@@ -109,7 +119,15 @@ export function errorChain(value: unknown): string {
|
||||
if (path.has(current)) return '<circular cause>'
|
||||
path.add(current)
|
||||
try {
|
||||
if (!(current instanceof Error)) return String(current)
|
||||
if (!(current instanceof Error)) {
|
||||
if (typeof current === 'object' && current !== null) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(current, 'message')
|
||||
if (descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string') {
|
||||
return descriptor.value
|
||||
}
|
||||
}
|
||||
return String(current)
|
||||
}
|
||||
const message = current.message === '' ? current.name : current.message
|
||||
const members = current instanceof AggregateError && current.errors.length > 0
|
||||
? ` [${current.errors.map(render).join('; ')}]`
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmConfigurableProvider,
|
||||
LlmDiscoveredModel,
|
||||
LlmFailure,
|
||||
LlmModelContext,
|
||||
LlmModelDiscoveryRequest,
|
||||
LlmModelInfo,
|
||||
LlmResolvedModelInfo,
|
||||
LlmProviderInfo,
|
||||
@@ -24,14 +26,15 @@ import type { ResolvedRetryPolicy } from './retry-policy.ts'
|
||||
import type { ProviderRequestId } from './brand.ts'
|
||||
import { callConfigEquals, deepFreeze } from './call-config.ts'
|
||||
import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts'
|
||||
import type { AdapterFailureScope } from './adapter-failure.ts'
|
||||
import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts'
|
||||
import { normalizeLlmFailure } from './adapter-failure.ts'
|
||||
import { normalizeApiKey } from './api-key.ts'
|
||||
|
||||
export * from './attribution.ts'
|
||||
export * from './brand.ts'
|
||||
export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
export * from './api-key.ts'
|
||||
export * from './types.ts'
|
||||
export * from './content.ts'
|
||||
export * from './message.ts'
|
||||
@@ -39,7 +42,6 @@ export * from './retry-policy.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
|
||||
export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
|
||||
export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -124,10 +126,47 @@ export class LlmError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one supplied credential, or refuse it as unusable.
|
||||
*
|
||||
* A stored key arrives from the credentials seam, a `.env` line, or a shell
|
||||
* export, all of which pick up surrounding whitespace, so trimming is silent.
|
||||
* Anything else fails here rather than inside `fetch`, whose ByteString
|
||||
* refusal names a UTF-16 code point instead of the setting to change. The key
|
||||
* never enters the message: `ref` names where to fix it, and echoing any part
|
||||
* of a secret into a log or a UI is the failure this diagnosis avoids.
|
||||
*
|
||||
* Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate
|
||||
* module stays dependency-free; both adapters share this one diagnosis instead
|
||||
* of keeping near-identical local copies.
|
||||
* @param raw - the credential exactly as supplied.
|
||||
* @param pkg - the refusing package name, prefixed to the diagnostic.
|
||||
* @param ref - the credential reference the value resolved through.
|
||||
* @returns the trimmed, usable key.
|
||||
*/
|
||||
export function assertUsableApiKey(raw: string, pkg: string, ref: string): string {
|
||||
const checked = normalizeApiKey(raw)
|
||||
if (checked.ok) return checked.value
|
||||
// The Models page is named as the writer it usually is, not as the only one:
|
||||
// the same value can arrive from a hand-edited .env or a shell export in a
|
||||
// composition that mounts no credentials seam at all, where directing the
|
||||
// user to a page that deployment does not serve would be a dead end.
|
||||
throw new LlmError(
|
||||
checked.reason === 'empty'
|
||||
? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key`
|
||||
+ ' (the web Models page writes it) or export it in the launching environment'
|
||||
: `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;`
|
||||
+ ` set ${ref} to the raw key alone (the web Models page writes it)`,
|
||||
INVALID_CREDENTIAL_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
/** One model call whose config and adapter registration were resolved together. */
|
||||
export interface PreparedLlmCall {
|
||||
/** Detached, deep-frozen config with any adapter-owned default materialized. */
|
||||
readonly config: LlmCallConfig
|
||||
/** Immutable retry policy captured with the adapter registration. */
|
||||
readonly retryPolicy: ResolvedRetryPolicy
|
||||
/** Detached context metadata resolved with the registration-bound call. */
|
||||
readonly context?: LlmModelContext
|
||||
/** Config fields materialized by the captured adapter rather than proposed by the caller. */
|
||||
@@ -227,6 +266,27 @@ export interface AdapterRegistrationHandle {
|
||||
replace(providers: string[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A live configurable-provider registration, disposable and atomically
|
||||
* replaceable — the directory counterpart of {@link AdapterRegistrationHandle}.
|
||||
*/
|
||||
export interface DirectoryRegistrationHandle {
|
||||
/** Withdraw every entry this registration currently holds. */
|
||||
(): void
|
||||
/**
|
||||
* Replace this registration's entries with `entries`. The candidate set is
|
||||
* validated in full first — an entry another registration already declares,
|
||||
* a duplicate within the set, or invalid metadata throws and leaves the
|
||||
* current entries untouched — and the swap is one synchronous section, so no
|
||||
* reader observes a gap. An empty array is legal here, unlike an empty
|
||||
* initial registration.
|
||||
*
|
||||
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
|
||||
* has been disposed.
|
||||
*/
|
||||
replace(entries: readonly LlmConfigurableProvider[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The abstract `llm` service: an adapter registry plus a streaming model-call
|
||||
* surface, interceptable via the `llm/stream` waterfall.
|
||||
@@ -234,6 +294,10 @@ export interface AdapterRegistrationHandle {
|
||||
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')
|
||||
@@ -372,34 +436,61 @@ export class LlmService extends Service {
|
||||
* 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.
|
||||
* @returns a handle that withdraws all of them, and can atomically replace 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')
|
||||
}
|
||||
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle {
|
||||
let held: LlmConfigurableProvider[] = []
|
||||
let disposed = false
|
||||
/**
|
||||
* Validate a candidate set in full against everything this registration
|
||||
* does not already hold, then publish it. Nothing is written until the
|
||||
* whole set passes, so a refused candidate leaves the current entries in
|
||||
* place — the property that makes `replace` a swap rather than a
|
||||
* delete-then-add that can strand the directory empty.
|
||||
*/
|
||||
const commit = (candidates: readonly LlmConfigurableProvider[]): void => {
|
||||
const detached: LlmConfigurableProvider[] = []
|
||||
for (const entry of entries) {
|
||||
const own = new Set(held.map(entry => entry.provider))
|
||||
for (const entry of candidates) {
|
||||
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)) {
|
||||
if ((this.directory.has(entry.provider) && !own.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 held) this.directory.delete(entry.provider)
|
||||
for (const entry of detached) this.directory.set(entry.provider, entry)
|
||||
held = detached
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
commit(entries)
|
||||
yield () => {
|
||||
for (const entry of detached) this.directory.delete(entry.provider)
|
||||
disposed = true
|
||||
for (const entry of held) this.directory.delete(entry.provider)
|
||||
held = []
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
}.bind(this), 'llm.registerConfigurableProviders()')
|
||||
return () => void dispose()
|
||||
|
||||
const handle = ((): void => void dispose()) as DirectoryRegistrationHandle
|
||||
handle.replace = (next: readonly LlmConfigurableProvider[]): void => {
|
||||
if (disposed) {
|
||||
throw new LlmError('this configurable-provider registration was disposed', 'REGISTRATION_DISPOSED')
|
||||
}
|
||||
commit(next)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,6 +501,73 @@ 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
// One of the two identifies what to describe: a route the adapter knows, or
|
||||
// an endpoint to ask. Neither leaves nothing to answer about.
|
||||
if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) {
|
||||
throw new LlmError('model discovery needs a provider route or a 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.
|
||||
@@ -646,12 +804,19 @@ export class LlmService extends Service {
|
||||
let dispatched = false
|
||||
return Object.freeze({
|
||||
config: resolvedConfig,
|
||||
retryPolicy: registration.retryPolicy,
|
||||
adapterDefaults,
|
||||
...context === undefined ? {} : { context },
|
||||
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
|
||||
if (dispatched) {
|
||||
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
|
||||
}
|
||||
if (!callConfigEquals(options, resolvedConfig)) {
|
||||
throw new LlmError(
|
||||
'prepared LLM call config changed before adapter dispatch',
|
||||
'INVALID_PREPARED_CALL',
|
||||
)
|
||||
}
|
||||
dispatched = true
|
||||
return this.streamWithRegistration(options, { registration, config: resolvedConfig })
|
||||
},
|
||||
@@ -681,22 +846,17 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Final adapter boundary. It tags only failures from adapter selection,
|
||||
* synchronous dispatch, iterator construction, or iteration while preserving
|
||||
* the original Error object. Middleware outside this generator remains
|
||||
* distinguishable as plugin work. An iteration failure skips adapter cleanup
|
||||
* so it cannot suppress the primary provider error. A downstream close awaits
|
||||
* adapter cleanup, whose failures remain ordinary untagged work.
|
||||
* Final adapter boundary. Adapter selection, dispatch, iterator construction,
|
||||
* and iteration failures become one terminal failure chunk. Middleware and
|
||||
* downstream consumer failures remain thrown plugin or consumer errors.
|
||||
*/
|
||||
private async * adapterStream(
|
||||
options: GenerateOptions,
|
||||
failures: AdapterFailureScope,
|
||||
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
let iterator: AsyncIterator<StreamChunk>
|
||||
try {
|
||||
const registration = prepared?.registration ?? this.registration(options.provider)
|
||||
failures.retryPolicy = registration.retryPolicy
|
||||
const resolvedConfig = prepared === undefined
|
||||
? (await this.resolveCallFor(registration, options, options.signal)).config
|
||||
: prepared.config
|
||||
@@ -715,32 +875,34 @@ export class LlmService extends Service {
|
||||
const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter))
|
||||
iterator = stream[Symbol.asyncIterator]()
|
||||
} catch (error: unknown) {
|
||||
throw markLlmAdapterFailure(failures, error)
|
||||
yield adapterFailureChunk(error, options.signal)
|
||||
return
|
||||
}
|
||||
|
||||
let completed = false
|
||||
let iterationFailed = false
|
||||
try {
|
||||
while (true) {
|
||||
let value: StreamChunk
|
||||
let item: { done: true } | { done: false; value: StreamChunk }
|
||||
try {
|
||||
const item = await iterator.next()
|
||||
if (item.done) {
|
||||
completed = true
|
||||
return
|
||||
}
|
||||
value = item.value
|
||||
const next = await iterator.next()
|
||||
item = next.done
|
||||
? { done: true }
|
||||
: { done: false, value: next.value }
|
||||
} catch (error: unknown) {
|
||||
iterationFailed = true
|
||||
throw markLlmAdapterFailure(failures, error)
|
||||
completed = true
|
||||
yield adapterFailureChunk(error, options.signal)
|
||||
return
|
||||
}
|
||||
if (item.done) {
|
||||
completed = true
|
||||
return
|
||||
}
|
||||
// End the adapter-owned try before yielding: consumer/middleware
|
||||
// failures resumed into this generator must remain untagged.
|
||||
yield value
|
||||
// failures resumed into this generator must remain thrown.
|
||||
yield item.value
|
||||
}
|
||||
} finally {
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the iteration catch sets its latch before entering finally.
|
||||
if (!completed && !iterationFailed) {
|
||||
if (!completed) {
|
||||
const close = iterator.return?.bind(iterator)
|
||||
if (close) await close()
|
||||
}
|
||||
@@ -748,15 +910,13 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one model call as raw chunks (token-level deltas). Throws
|
||||
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
|
||||
* `options.provider`. Replay state is retained only when the same adapter
|
||||
* instance owns its historical provider and the target provider. Final
|
||||
* adapter selection remains fixed through asynchronous exact-model resolution
|
||||
* and dispatch. Selection, dispatch, and iteration failures retain their
|
||||
* original Error identity and are tagged in a call-local scope for narrow
|
||||
* agent-loop request recovery; middleware and nested-call failures remain
|
||||
* untagged for the outer call.
|
||||
* Stream one model call as raw chunks (token-level deltas). Replay state is
|
||||
* retained only when the same adapter instance owns its historical provider
|
||||
* and the target provider. Final adapter selection remains fixed through
|
||||
* asynchronous exact-model resolution and dispatch. Adapter selection,
|
||||
* dispatch, and iteration failures become terminal `error` or `aborted`
|
||||
* finish chunks; middleware, nested-call, cleanup, and consumer failures
|
||||
* remain thrown.
|
||||
* @param options - the full request; `options.provider` selects the adapter.
|
||||
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
|
||||
*/
|
||||
@@ -768,14 +928,23 @@ export class LlmService extends Service {
|
||||
options: GenerateOptions,
|
||||
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const failures: AdapterFailureScope = { failures: new WeakMap<Error, LlmFailure>() }
|
||||
const stream = this.ctx.waterfall(
|
||||
return this.ctx.waterfall(
|
||||
this,
|
||||
'llm/stream',
|
||||
options,
|
||||
() => this.adapterStream(options, failures, prepared),
|
||||
() => this.adapterStream(options, prepared),
|
||||
)
|
||||
return bindAdapterFailureScope(stream, failures)
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one adapter throw into the stream protocol's terminal outcome. */
|
||||
function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {
|
||||
const failure = normalizeLlmFailure(error)
|
||||
return {
|
||||
type: 'finish',
|
||||
reason: signal?.aborted || failure.code === 'ABORTED'
|
||||
? { kind: 'aborted', failure }
|
||||
: { kind: 'error', failure },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ async function* validateStream(
|
||||
usageSeen = true
|
||||
break
|
||||
case 'finish':
|
||||
if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`)
|
||||
if (open.size > 0 && chunk.reason.kind !== 'error' && chunk.reason.kind !== 'aborted') {
|
||||
fail(`LLM stream finished with ${open.size} open block(s)`)
|
||||
}
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -29,17 +29,99 @@ export interface ToolMessageSource {
|
||||
callId: CallId
|
||||
}
|
||||
|
||||
/**
|
||||
* What SHAPE of information a producer-supplied context carries, declared by
|
||||
* the producer beside its provenance.
|
||||
*
|
||||
* `MessageSource.kind` answers *who produced this*; `form` answers *what kind
|
||||
* of thing it is*, and the two axes are deliberately independent — several
|
||||
* producers share one form (three snapshot producers today), and one producer
|
||||
* may emit more than one form over a session.
|
||||
*
|
||||
* The vocabulary is SEMANTIC, never visual: a value states that the content is
|
||||
* a file's instructions or a catalog of available items, and a consumer decides
|
||||
* what that looks like. Colors, icons, ordering, and collapse defaults are the
|
||||
* consumer's business and must not enter this union. It grows one value at a
|
||||
* time as producers gain the structured fields their form needs; an absent or
|
||||
* unknown value is the documented default, presented as opaque content.
|
||||
*/
|
||||
export type ContextForm =
|
||||
/** Instructions read out of workspace files the model is expected to follow. */
|
||||
| 'instructions'
|
||||
/** A catalog of items available in this session, republished as it changes. */
|
||||
| 'catalog'
|
||||
/** Current state, where a later snapshot from the same producer supersedes an earlier one. */
|
||||
| 'snapshot'
|
||||
/** A one-off account of something that just happened; it supersedes nothing. */
|
||||
| 'notice'
|
||||
/** A message another agent addressed to this one. */
|
||||
| 'relay'
|
||||
/** Material lifted out of another session's log, possibly reduced on the way in. */
|
||||
| 'recall'
|
||||
|
||||
/** One named contribution to a `snapshot`-form context, in assembly order. */
|
||||
export interface ContextSnapshotSection {
|
||||
/** The contributing subsystem's name. */
|
||||
readonly name: string
|
||||
/** That contribution's model-facing text, exactly as assembled. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer-declared {@link ContextForm} and the fields that form requires,
|
||||
* mixed into the source shapes that carry one.
|
||||
*
|
||||
* Discriminated by `form` so a producer cannot declare a shape without the
|
||||
* facts that shape is presented from: a `notice` must record its one-line
|
||||
* account, a `snapshot` its sections. Omitting `form` stays valid — an
|
||||
* undeclared context is the documented default.
|
||||
*/
|
||||
export type ContextFormed =
|
||||
| { readonly form?: never }
|
||||
| { readonly form: 'instructions' }
|
||||
| { readonly form: 'catalog' }
|
||||
| {
|
||||
readonly form: 'snapshot'
|
||||
/** The named contributions this snapshot assembled, in order. */
|
||||
readonly sections: readonly ContextSnapshotSection[]
|
||||
}
|
||||
| {
|
||||
readonly form: 'notice'
|
||||
/** One-line account of what happened, shown without expanding the row. */
|
||||
readonly summary: string
|
||||
}
|
||||
| { readonly form: 'relay' }
|
||||
| { readonly form: 'recall' }
|
||||
|
||||
/**
|
||||
* Where a message (or injected content) came from.
|
||||
* Merge-extensible sum type — plugins add their own `kind`s.
|
||||
*/
|
||||
export interface MessageSourceMap {
|
||||
user: { kind: 'user' }
|
||||
plugin: { kind: 'plugin'; plugin: string }
|
||||
plugin: { kind: 'plugin'; plugin: string } & ContextFormed
|
||||
model: ModelMessageSource
|
||||
tool: ToolMessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound for a `notice` summary. The account rides a collapsed transcript row
|
||||
* and is committed to the durable log, while its inputs — task labels, goal
|
||||
* objectives, tool arguments — are caller text with no length of their own.
|
||||
*/
|
||||
export const CONTEXT_SUMMARY_MAX_CHARS = 120
|
||||
|
||||
/**
|
||||
* Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
|
||||
* @param summary - the producer's one-line account, of any length.
|
||||
* @returns the account, ellipsized when it exceeds the bound.
|
||||
*/
|
||||
export function boundContextSummary(summary: string): string {
|
||||
return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
|
||||
? summary
|
||||
: `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`
|
||||
}
|
||||
|
||||
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
|
||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||
|
||||
|
||||
@@ -160,6 +160,58 @@ export interface LlmConfigurableProvider {
|
||||
* object; empty when the whole section is the profile.
|
||||
*/
|
||||
settingsPath: readonly string[]
|
||||
/**
|
||||
* Whether the owning adapter knows this route only because configuration
|
||||
* declared it — a gateway or self-hosted server it ships nothing about.
|
||||
* Absent means the adapter draws no such distinction; false means it does
|
||||
* and this route is one of its own. Only the adapter can answer: a stored
|
||||
* profile is how a user-added route AND a corrected shipped one both look
|
||||
* from outside.
|
||||
*/
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* Route the draft is editing, when it edits an existing one. A route whose
|
||||
* adapter already knows its models answers from that knowledge instead of
|
||||
* asking the endpoint — the adapter's own registry is the better answer, and
|
||||
* it costs no network call.
|
||||
*/
|
||||
provider?: string
|
||||
/**
|
||||
* Endpoint to interrogate. Optional because a route the adapter already
|
||||
* describes needs none; a route it does not must supply one.
|
||||
*/
|
||||
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. */
|
||||
@@ -217,8 +269,9 @@ export interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
||||
* assembled block. Adapters emit usage before the terminal finish and nothing
|
||||
* afterward; tool arguments remain raw JSON strings. Failures either throw or
|
||||
* end with `error`/`aborted`, and consumers must handle both paths.
|
||||
* afterward; tool arguments remain raw JSON strings. An adapter implementation
|
||||
* may throw, but `LlmService.stream()` normalizes that failure to a terminal
|
||||
* `error` or `aborted` finish before exposing it to consumers.
|
||||
*/
|
||||
export type StreamChunk =
|
||||
| { type: 'block-start'; index: number; blockType: ContentBlockType }
|
||||
|
||||
68
packages/llm/llm/tests/adapter-failure.spec.ts
Normal file
68
packages/llm/llm/tests/adapter-failure.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeLlmFailure } from '../src/adapter-failure.ts'
|
||||
|
||||
describe('adapter failure normalization', () => {
|
||||
it('contains hostile non-Error coercion', () => {
|
||||
const thrown = { [Symbol.toPrimitive]: () => { throw new Error('coercion failed') } }
|
||||
expect(normalizeLlmFailure(thrown)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('normalizes empty primitive throws and data descriptors without values', () => {
|
||||
expect(normalizeLlmFailure('')).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
|
||||
expect(normalizeLlmFailure(null)).toEqual({ message: 'null', code: 'UNKNOWN' })
|
||||
|
||||
const error = new Error('provider failed')
|
||||
Object.defineProperty(error, 'failure', { get: () => ({ message: 'ignored', code: 'IGNORED' }) })
|
||||
Object.defineProperty(error, 'code', { get: () => 'IGNORED' })
|
||||
expect(normalizeLlmFailure(error)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
|
||||
|
||||
const accessorCode = Object.assign(new Error('provider failed'), {
|
||||
failure: { message: 'provider failed', code: 'FOREIGN' },
|
||||
})
|
||||
Object.defineProperty(accessorCode, 'code', { get: () => 'FOREIGN' })
|
||||
expect(normalizeLlmFailure(accessorCode)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
|
||||
|
||||
const primitiveFailure = Object.assign(new Error('provider failed'), {
|
||||
failure: null,
|
||||
code: 'FOREIGN',
|
||||
})
|
||||
expect(normalizeLlmFailure(primitiveFailure)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('contains hostile Error property reflection', () => {
|
||||
const withFailure = new Error('provider failed') as Error & { failure: unknown; code: string }
|
||||
withFailure.failure = { message: 'provider failed', code: 'FOREIGN' }
|
||||
withFailure.code = 'FOREIGN'
|
||||
const hostileCode = new Proxy(withFailure, {
|
||||
getOwnPropertyDescriptor(target, property) {
|
||||
if (property === 'code') throw new Error('code descriptor failed')
|
||||
return Reflect.getOwnPropertyDescriptor(target, property)
|
||||
},
|
||||
})
|
||||
expect(normalizeLlmFailure(hostileCode)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
|
||||
|
||||
const hostileFailure = new Proxy(new Error('provider failed'), {
|
||||
getOwnPropertyDescriptor() { throw new Error('failure descriptor failed') },
|
||||
})
|
||||
expect(normalizeLlmFailure(hostileFailure)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('rejects malformed or accessor-backed failure snapshots', () => {
|
||||
const malformed = new Error('provider failed') as Error & { failure: unknown; code: string }
|
||||
malformed.failure = { message: 'provider failed', code: 'FOREIGN', requestId: '' }
|
||||
malformed.code = 'FOREIGN'
|
||||
expect(normalizeLlmFailure(malformed)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
|
||||
|
||||
const accessorBacked = new Error('provider failed') as Error & { failure: unknown }
|
||||
accessorBacked.failure = Object.defineProperty({}, 'message', {
|
||||
get() { throw new Error('failure getter failed') },
|
||||
})
|
||||
expect(normalizeLlmFailure(accessorBacked)).toEqual({ message: 'provider failed', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('falls back when an Error message accessor throws', () => {
|
||||
const error = new Error('provider failed')
|
||||
Object.defineProperty(error, 'message', { get() { throw new Error('message getter failed') } })
|
||||
expect(normalizeLlmFailure(error)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
|
||||
})
|
||||
})
|
||||
70
packages/llm/llm/tests/api-key.spec.ts
Normal file
70
packages/llm/llm/tests/api-key.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertUsableApiKey, INVALID_CREDENTIAL_CODE, normalizeApiKey } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
describe('normalizeApiKey', () => {
|
||||
it('accepts a printable-ASCII key unchanged', () => {
|
||||
expect(normalizeApiKey('sk-0123456789abcdef')).toEqual({ ok: true, value: 'sk-0123456789abcdef' })
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace before judging', () => {
|
||||
expect(normalizeApiKey(' sk-abc\t\n')).toEqual({ ok: true, value: 'sk-abc' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['spaces only', ' '],
|
||||
['a tab only', '\t'],
|
||||
])('rejects %s as empty', (_label, raw) => {
|
||||
expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'empty' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an emoji', 'sk-\u{1F600}abc'],
|
||||
['CJK text', 'sk-你好'],
|
||||
['full-width punctuation', 'sk-abc,'],
|
||||
['an interior space', 'sk-abc def'],
|
||||
['a C0 control character', 'sk-abc\x01'],
|
||||
['a latin-1 character', 'sk-café'],
|
||||
])('rejects %s as illegal characters', (_label, raw) => {
|
||||
expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'illegalCharacters' })
|
||||
})
|
||||
|
||||
it('accepts the printable-ASCII boundary characters', () => {
|
||||
expect(normalizeApiKey('!~')).toEqual({ ok: true, value: '!~' })
|
||||
})
|
||||
|
||||
it('publishes a code distinct from a missing credential', () => {
|
||||
expect(INVALID_CREDENTIAL_CODE).toBe('INVALID_CREDENTIAL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertUsableApiKey', () => {
|
||||
it('returns the trimmed key when it is usable', () => {
|
||||
expect(assertUsableApiKey(' sk-abc ', 'llm-deepseek', 'DEEPSEEK_API_KEY')).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('refuses a blank stored credential, naming the reference', () => {
|
||||
expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY'))
|
||||
.toThrow(/llm-deepseek: the API key resolved from DEEPSEEK_API_KEY is blank/)
|
||||
})
|
||||
|
||||
it('refuses an unusable stored credential with the invalid-credential code', () => {
|
||||
try {
|
||||
assertUsableApiKey('sk-\u{1F600}', 'llm-pi-ai', 'ACME_API_KEY')
|
||||
expect.fail('an illegal key must throw')
|
||||
} catch (error) {
|
||||
expect((error as { code: string }).code).toBe(INVALID_CREDENTIAL_CODE)
|
||||
expect((error as Error).message).toContain('llm-pi-ai')
|
||||
expect((error as Error).message).toContain('ACME_API_KEY')
|
||||
}
|
||||
})
|
||||
|
||||
it('never echoes the key it refuses', () => {
|
||||
try {
|
||||
assertUsableApiKey('sk-\u{1F600}supersecret', 'llm-deepseek', 'DEEPSEEK_API_KEY')
|
||||
expect.fail('an illegal key must throw')
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('supersecret')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -6,11 +6,8 @@ import LlmService, {
|
||||
HarnessError,
|
||||
isContextWindowExceededError,
|
||||
isQuotaExceededError,
|
||||
isLlmAdapterFailure,
|
||||
LlmAdapter,
|
||||
LlmError,
|
||||
llmFailureOf,
|
||||
llmRetryPolicyOf,
|
||||
ProviderRequestId,
|
||||
ReasoningEffortId,
|
||||
resolveRetryPolicy,
|
||||
@@ -95,6 +92,12 @@ const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of stream) chunks.push(chunk)
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe('LlmService', () => {
|
||||
it('recognizes structured and model-capacity context-window overflow details', () => {
|
||||
expect(isContextWindowExceededError('context_length_exceeded maximum context length')).toBe(true)
|
||||
@@ -142,6 +145,8 @@ describe('LlmService', () => {
|
||||
|
||||
it('errorChain survives non-Error values, hostile coercion, and circular causes', () => {
|
||||
expect(errorChain('plain string')).toBe('plain string')
|
||||
expect(errorChain({ message: 'structured provider failure', code: 'SERVER' }))
|
||||
.toBe('structured provider failure')
|
||||
expect(errorChain({ toString: () => { throw new Error('hostile') } })).toBe('<unrenderable value>')
|
||||
const circular = new Error('outer')
|
||||
circular.cause = circular
|
||||
@@ -219,69 +224,61 @@ describe('LlmService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the serving registration policy on an in-flight call after route replacement', async () => {
|
||||
it('keeps a prepared registration and retry policy after route replacement', async () => {
|
||||
const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy')
|
||||
const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy')
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const failure = new LlmError('old route failed', 'AUTH')
|
||||
const oldAdapter = new class extends LlmAdapter {
|
||||
const oldFailure = new LlmError('old route failed', 'AUTH')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const disposeOld = ctx.llm.registerAdapter(['route'], new class extends ThrowingAdapter {
|
||||
override providerRetryPolicy(): typeof oldPolicy {
|
||||
return oldPolicy
|
||||
}
|
||||
}(oldFailure))
|
||||
const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
throw failure
|
||||
}
|
||||
}()
|
||||
const newAdapter = new class extends ScriptedAdapter {
|
||||
disposeOld()
|
||||
ctx.llm.registerAdapter(['route'], new class extends ScriptedAdapter {
|
||||
override providerRetryPolicy(): typeof newPolicy {
|
||||
return newPolicy
|
||||
}
|
||||
}(SCRIPT)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const disposeOld = ctx.llm.registerAdapter(['route'], oldAdapter)
|
||||
const stream = ctx.llm.stream({ provider: 'route', model: 'model', messages: [] })
|
||||
const outcome = (async (): Promise<unknown> => {
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
await entered.promise
|
||||
}(SCRIPT))
|
||||
|
||||
disposeOld()
|
||||
ctx.llm.registerAdapter(['route'], newAdapter)
|
||||
release.resolve(undefined)
|
||||
|
||||
expect(await outcome).toBe(failure)
|
||||
expect(llmRetryPolicyOf(stream)).toBe(oldPolicy)
|
||||
const chunks = await collect(prepared.stream({ ...prepared.config, messages: [] }))
|
||||
expect(chunks.at(-1)).toEqual({
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { message: 'old route failed', code: 'AUTH' },
|
||||
},
|
||||
})
|
||||
expect(prepared.retryPolicy).toBe(oldPolicy)
|
||||
expect(ctx.llm.providerRetryPolicy('route')).toBe(newPolicy)
|
||||
})
|
||||
|
||||
it('throws NO_ADAPTER for unregistered providers', async () => {
|
||||
it('normalizes an unregistered provider to a terminal failure', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const stream = ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _ of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(LlmError)
|
||||
expect((caught as LlmError).code).toBe('NO_ADAPTER')
|
||||
expect((caught as LlmError).message).toContain('no adapter registered')
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
|
||||
expect(llmRetryPolicyOf(stream)).toBeUndefined()
|
||||
|
||||
const chunks = await collect(ctx.llm.stream({
|
||||
provider: 'nope',
|
||||
model: 'any-model',
|
||||
messages: [],
|
||||
}))
|
||||
|
||||
const finish = chunks.at(-1)
|
||||
expect(finish).toMatchObject({
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { code: 'NO_ADAPTER' },
|
||||
},
|
||||
})
|
||||
if (finish?.type !== 'finish' || finish.reason.kind !== 'error') throw new Error('expected error finish')
|
||||
expect(finish.reason.failure.message).toContain('no adapter registered')
|
||||
})
|
||||
|
||||
it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => {
|
||||
it.each(['done', 'value'] as const)('normalizes a throwing IteratorResult.%s getter', async (field) => {
|
||||
const original = new LlmError(`${field} getter failed`, 'RESULT_GETTER_FAILED')
|
||||
const result = field === 'done' ? {} : { done: false }
|
||||
Object.defineProperty(result, field, { get: () => { throw original } })
|
||||
@@ -297,31 +294,30 @@ describe('LlmService', () => {
|
||||
})
|
||||
const adapter = new class extends LlmAdapter {
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
return iterator
|
||||
},
|
||||
}
|
||||
return { [Symbol.asyncIterator]: () => iterator }
|
||||
}
|
||||
}()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], adapter)
|
||||
ctx.llm.registerAdapter(['test'], adapter)
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
const chunks = await collect(ctx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
}))
|
||||
|
||||
expect(caught).toBe(original)
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
|
||||
expect(chunks.at(-1)).toEqual({
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { message: `${field} getter failed`, code: 'RESULT_GETTER_FAILED' },
|
||||
},
|
||||
})
|
||||
expect(cleanupLookups).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['dispatch', 'iterator'] as const)('tags synchronous adapter %s failures without replacing their Error', async (boundary) => {
|
||||
it.each(['dispatch', 'iterator'] as const)('normalizes synchronous adapter %s failures', async (boundary) => {
|
||||
const original = new LlmError(`${boundary} failed`, 'BOUNDARY_FAILED')
|
||||
const adapter = new class extends LlmAdapter {
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
@@ -331,339 +327,63 @@ describe('LlmService', () => {
|
||||
}()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], adapter)
|
||||
ctx.llm.registerAdapter(['test'], adapter)
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
const chunks = await collect(ctx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
}))
|
||||
|
||||
expect(caught).toBe(original)
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
|
||||
expect(llmFailureOf(stream, caught)).toEqual({
|
||||
message: `${boundary} failed`,
|
||||
code: 'BOUNDARY_FAILED',
|
||||
expect(chunks.at(-1)).toEqual({
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { message: `${boundary} failed`, code: 'BOUNDARY_FAILED' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps structured provider facts beside a frozen third-party Error', async () => {
|
||||
const original = new LlmError('provider busy', 'RATE_LIMIT', {
|
||||
it('preserves structured LlmError facts in the terminal failure', async () => {
|
||||
const failure = new LlmError('provider busy', 'RATE_LIMIT', {
|
||||
status: 429,
|
||||
providerRetryAfterMs: 1_500,
|
||||
requestId: ProviderRequestId('req-7'),
|
||||
})
|
||||
Object.freeze(original)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
ctx.llm.registerAdapter(['test'], new ThrowingAdapter(failure))
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
const chunks = await collect(ctx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
}))
|
||||
|
||||
expect(caught).toBe(original)
|
||||
expect(llmFailureOf(stream, caught)).toEqual({
|
||||
message: 'provider busy',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 1_500,
|
||||
requestId: ProviderRequestId('req-7'),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not trust retry facts carried by an unknown third-party Error', async () => {
|
||||
const carried = { message: 'busy', code: 'SERVER', status: 503 }
|
||||
const original = Object.assign(new Error('busy'), { failure: carried })
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
const facts = llmFailureOf(stream, original)
|
||||
carried.status = 500
|
||||
|
||||
expect(facts).toEqual({ message: 'busy', code: 'UNKNOWN' })
|
||||
expect(Object.isFrozen(facts)).toBe(true)
|
||||
expect(facts).not.toBe(carried)
|
||||
})
|
||||
|
||||
it('keeps validated failure facts across package copies with matching own codes', async () => {
|
||||
const original = Object.assign(new Error('provider busy'), {
|
||||
code: 'RATE_LIMIT',
|
||||
failure: {
|
||||
message: 'provider busy',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 1_500,
|
||||
requestId: 'req-cross-copy',
|
||||
expect(chunks.at(-1)).toEqual({
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: {
|
||||
message: 'provider busy',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 1_500,
|
||||
requestId: ProviderRequestId('req-7'),
|
||||
},
|
||||
},
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
expect(llmFailureOf(stream, original)).toEqual({
|
||||
message: 'provider busy',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 1_500,
|
||||
requestId: 'req-cross-copy',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an unknown SDK Error exact without trusting its private code or accessors', async () => {
|
||||
const original = Object.assign(new Error('socket closed'), { code: 'ECONNRESET' })
|
||||
Object.defineProperty(original, 'failure', {
|
||||
get() { throw new Error('SDK failure accessor must not run') },
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
|
||||
expect(original.code).toBe('ECONNRESET')
|
||||
expect(llmFailureOf(stream, original)).toEqual({ message: 'socket closed', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('keeps an SDK Error exact when its message accessor is hostile', async () => {
|
||||
const original = Object.defineProperty(new Error(), 'message', {
|
||||
get() { throw new Error('SDK message accessor trap') },
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('keeps an SDK Error exact without trusting accessor-backed carried facts', async () => {
|
||||
const original = Object.assign(new Error('busy'), {
|
||||
failure: { message: 'busy', code: 'SERVER', status: 503 },
|
||||
})
|
||||
Object.defineProperty(original, 'code', {
|
||||
get() { throw new Error('SDK code accessor must not escape') },
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('does not trust carried facts matched only by an inherited code', async () => {
|
||||
class InheritedCodeError extends Error {
|
||||
get code(): string { return 'SERVER' }
|
||||
}
|
||||
const original = Object.assign(new InheritedCodeError('busy'), {
|
||||
failure: { message: 'busy', code: 'SERVER', status: 503 },
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('keeps an SDK Error exact when code descriptor inspection is trapped', async () => {
|
||||
const target = Object.assign(new Error('busy'), {
|
||||
code: 'SERVER',
|
||||
failure: { message: 'busy', code: 'SERVER', status: 503 },
|
||||
})
|
||||
const original = new Proxy(target, {
|
||||
getOwnPropertyDescriptor(value, property) {
|
||||
if (property === 'code') throw new Error('SDK code descriptor trap')
|
||||
return Reflect.getOwnPropertyDescriptor(value, property)
|
||||
},
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
expect(llmFailureOf(stream, original)).toEqual({ message: 'busy', code: 'UNKNOWN' })
|
||||
})
|
||||
|
||||
it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => {
|
||||
const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), {
|
||||
getOwnPropertyDescriptor(target, property) {
|
||||
if (property === 'failure') throw new Error('SDK descriptor trap')
|
||||
return Reflect.getOwnPropertyDescriptor(target, property)
|
||||
},
|
||||
})
|
||||
const throwingFacts = Object.create(null) as Record<string, unknown>
|
||||
Object.defineProperty(throwingFacts, 'message', {
|
||||
get() { throw new Error('SDK fact getter trap') },
|
||||
})
|
||||
const carrying = (message: string, failure: unknown): HarnessError => Object.defineProperty(
|
||||
new HarnessError(message, 'SERVER'),
|
||||
'failure',
|
||||
{ value: failure },
|
||||
)
|
||||
const factGetter = carrying('fact getter failed', throwingFacts)
|
||||
const malformed = carrying('malformed facts', { message: 'provider busy', code: 'SERVER', requestId: 1 })
|
||||
const primitive = carrying('primitive facts', 1)
|
||||
const nullFacts = carrying('null facts', null)
|
||||
const mismatched = carrying('mismatched facts', { message: 'busy', code: 'RATE_LIMIT' })
|
||||
|
||||
for (const [original, expectedMessage] of [
|
||||
[propertyTrap, 'descriptor trapped'],
|
||||
[factGetter, 'fact getter failed'],
|
||||
[malformed, 'malformed facts'],
|
||||
[primitive, 'primitive facts'],
|
||||
[nullFacts, 'null facts'],
|
||||
[mismatched, 'mismatched facts'],
|
||||
] as const) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
expect(llmFailureOf(stream, original)).toEqual({ message: expectedMessage, code: 'SERVER' })
|
||||
}
|
||||
})
|
||||
|
||||
it('retains a stable code from a HarnessError without requiring LlmError facts', async () => {
|
||||
const original = new HarnessError('stable adapter failure', 'ADAPTER_STABLE')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
|
||||
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toBe(original)
|
||||
expect(llmFailureOf(stream, original)).toEqual({
|
||||
message: 'stable adapter failure',
|
||||
code: 'ADAPTER_STABLE',
|
||||
})
|
||||
expect(llmFailureOf(stream, 'not an Error')).toBeUndefined()
|
||||
expect(llmFailureOf({ [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator]() }, original)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a nested adapter failure scoped to the nested model call', async () => {
|
||||
const original = new LlmError('nested provider failed', 'NESTED_FAILED')
|
||||
const outer = new RecordingAdapter(SCRIPT)
|
||||
const nested = new ThrowingAdapter(original)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['outer'], outer)
|
||||
ctx.llm.registerAdapter(['nested'], nested)
|
||||
let nestedStream: AsyncIterable<StreamChunk> | undefined
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
if (options.provider !== 'outer') return next()
|
||||
return (async function* () {
|
||||
nestedStream = ctx.llm.stream({ provider: 'nested', model: 'nested', messages: [] })
|
||||
yield * nestedStream
|
||||
})()
|
||||
})
|
||||
|
||||
const outerStream = ctx.llm.stream({ provider: 'outer', model: 'outer', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _chunk of outerStream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBe(original)
|
||||
expect(nestedStream).toBeDefined()
|
||||
expect(isLlmAdapterFailure(nestedStream!, caught)).toBe(true)
|
||||
expect(isLlmAdapterFailure(outerStream, caught)).toBe(false)
|
||||
expect(outer.lastOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps call scopes distinct when middleware reuses an iterable', async () => {
|
||||
const firstFailure = new LlmError('first provider failed', 'FIRST_FAILED')
|
||||
const secondFailure = new LlmError('second provider failed', 'SECOND_FAILED')
|
||||
const delegates: AsyncIterable<StreamChunk>[] = []
|
||||
const shared: AsyncIterable<StreamChunk> = {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
const delegate = delegates.shift()
|
||||
if (delegate === undefined) throw new Error('shared stream has no call delegate')
|
||||
return delegate[Symbol.asyncIterator]()
|
||||
},
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['first'], new ThrowingAdapter(firstFailure))
|
||||
ctx.llm.registerAdapter(['second'], new ThrowingAdapter(secondFailure))
|
||||
ctx.on('llm/stream', (_options, next) => {
|
||||
delegates.push(next())
|
||||
return shared
|
||||
})
|
||||
|
||||
const firstStream = ctx.llm.stream({ provider: 'first', model: 'first', messages: [] })
|
||||
const secondStream = ctx.llm.stream({ provider: 'second', model: 'second', messages: [] })
|
||||
const catchFailure = async (stream: AsyncIterable<StreamChunk>): Promise<unknown> => {
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
return new Error('expected adapter to fail')
|
||||
}
|
||||
|
||||
expect(firstStream).not.toBe(secondStream)
|
||||
const firstCaught = await catchFailure(firstStream)
|
||||
expect(firstCaught).toBe(firstFailure)
|
||||
expect(isLlmAdapterFailure(firstStream, firstCaught)).toBe(true)
|
||||
expect(isLlmAdapterFailure(secondStream, firstCaught)).toBe(false)
|
||||
const secondCaught = await catchFailure(secondStream)
|
||||
expect(secondCaught).toBe(secondFailure)
|
||||
expect(isLlmAdapterFailure(secondStream, secondCaught)).toBe(true)
|
||||
expect(isLlmAdapterFailure(firstStream, secondCaught)).toBe(false)
|
||||
expect(delegates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('propagates a rejected next promptly without awaiting a non-settling return', async () => {
|
||||
const original = new LlmError('provider failed', 'PROVIDER_FAILED')
|
||||
let cleanupCalls = 0
|
||||
it('normalizes arbitrary adapter rejections without throwing them downstream', async () => {
|
||||
const adapter = new class extends LlmAdapter {
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
return {
|
||||
next: () => Promise.reject(original),
|
||||
return: () => {
|
||||
cleanupCalls += 1
|
||||
return new Promise<IteratorResult<StreamChunk>>(() => {})
|
||||
},
|
||||
// Third-party adapters can reject with arbitrary values.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
next: () => Promise.reject('plain provider failure'),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -671,30 +391,73 @@ describe('LlmService', () => {
|
||||
}()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], adapter)
|
||||
ctx.llm.registerAdapter(['test'], adapter)
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
|
||||
const failure = (async (): Promise<unknown> => {
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
return new Error('expected adapter iteration to fail')
|
||||
})()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<Error>((resolve) => {
|
||||
timer = setTimeout(() => { resolve(new Error('adapter failure did not settle promptly')) }, 100)
|
||||
const chunks = await collect(ctx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
}))
|
||||
|
||||
expect(chunks.at(-1)).toEqual({
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { message: 'plain provider failure', code: 'UNKNOWN' },
|
||||
},
|
||||
})
|
||||
const caught = await Promise.race([failure, timeout])
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
|
||||
expect(caught).toBe(original)
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
|
||||
expect(cleanupCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('awaits one adapter return on downstream close and leaves its rejection unclassified', async () => {
|
||||
it('maps adapter failure to aborted when the request signal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('cancelled')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test'], new ThrowingAdapter(new Error('stopped')))
|
||||
|
||||
const chunks = await collect(ctx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
signal: controller.signal,
|
||||
}))
|
||||
|
||||
expect(chunks.at(-1)).toMatchObject({
|
||||
type: 'finish',
|
||||
reason: { kind: 'aborted', failure: { message: 'stopped' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves middleware and consumer failures thrown', async () => {
|
||||
const middlewareFailure = new Error('middleware failed')
|
||||
const middlewareCtx = new Context()
|
||||
await middlewareCtx.plugin(LlmService)
|
||||
middlewareCtx.llm.registerAdapter(['test'], new ScriptedAdapter(SCRIPT))
|
||||
middlewareCtx.on('llm/stream', () => (async function* () {
|
||||
throw middlewareFailure
|
||||
})())
|
||||
await expect(collect(middlewareCtx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
}))).rejects.toBe(middlewareFailure)
|
||||
|
||||
const consumerFailure = new Error('consumer failed')
|
||||
const consumerCtx = new Context()
|
||||
await consumerCtx.plugin(LlmService)
|
||||
consumerCtx.llm.registerAdapter(['test'], new ScriptedAdapter(SCRIPT))
|
||||
await expect((async () => {
|
||||
for await (const _chunk of consumerCtx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
})) {
|
||||
throw consumerFailure
|
||||
}
|
||||
})()).rejects.toBe(consumerFailure)
|
||||
})
|
||||
|
||||
it('awaits adapter cleanup on downstream close and leaves cleanup failure thrown', async () => {
|
||||
const cleanup = new Error('cleanup failed')
|
||||
let cleanupCalls = 0
|
||||
const adapter = new class extends LlmAdapter {
|
||||
@@ -714,22 +477,19 @@ describe('LlmService', () => {
|
||||
}()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], adapter)
|
||||
ctx.llm.registerAdapter(['test'], adapter)
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _chunk of stream) break
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBe(cleanup)
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(false)
|
||||
await expect((async () => {
|
||||
for await (const _chunk of ctx.llm.stream({
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
messages: [],
|
||||
})) break
|
||||
})()).rejects.toBe(cleanup)
|
||||
expect(cleanupCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('allows downstream close when the adapter iterator has no return method', async () => {
|
||||
it('allows downstream close when an adapter iterator has no return method', async () => {
|
||||
const adapter = new class extends LlmAdapter {
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return {
|
||||
@@ -741,66 +501,9 @@ describe('LlmService', () => {
|
||||
}()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], adapter)
|
||||
ctx.llm.registerAdapter(['test'], adapter)
|
||||
|
||||
let chunks = 0
|
||||
for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) {
|
||||
chunks += 1
|
||||
break
|
||||
}
|
||||
|
||||
expect(chunks).toBe(1)
|
||||
})
|
||||
|
||||
it('normalizes and tags non-Error adapter failures once', async () => {
|
||||
const adapter = new class extends LlmAdapter {
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
// Third-party adapters can reject with arbitrary values.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
return { next: () => Promise.reject('plain provider failure') }
|
||||
},
|
||||
}
|
||||
}
|
||||
}()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], adapter)
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(HarnessError)
|
||||
expect(caught).toMatchObject({ code: 'UNKNOWN', cause: 'plain provider failure' })
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not tag a failure thrown downstream while consuming adapter output', async () => {
|
||||
const downstream = new Error('consumer failed')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
|
||||
let caught: unknown
|
||||
try {
|
||||
for await (const _chunk of stream) throw downstream
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBe(downstream)
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(false)
|
||||
expect(isLlmAdapterFailure(new ScriptedAdapter(SCRIPT).stream({
|
||||
provider: 'unbound', model: 'unbound', messages: [],
|
||||
}), caught)).toBe(false)
|
||||
expect(isLlmAdapterFailure(stream, 'consumer failed')).toBe(false)
|
||||
for await (const _chunk of ctx.llm.stream({ provider: 'test', model: 'test', messages: [] })) break
|
||||
})
|
||||
|
||||
it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => {
|
||||
@@ -1140,19 +843,34 @@ describe('LlmService', () => {
|
||||
expect(Object.isFrozen(prepared.config)).toBe(true)
|
||||
expect(Object.isFrozen(prepared.adapterDefaults)).toBe(true)
|
||||
expect(prepared.adapterDefaults).toEqual({ reasoningEffort: true })
|
||||
const stream = prepared.stream({
|
||||
expect(() => prepared.stream({
|
||||
...prepared.config,
|
||||
model: 'other',
|
||||
messages: [],
|
||||
})
|
||||
|
||||
await expect((async () => {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
})()).rejects.toMatchObject({ code: 'INVALID_PREPARED_CALL' })
|
||||
})).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' }))
|
||||
await collect(prepared.stream({
|
||||
...prepared.config,
|
||||
messages: [],
|
||||
}))
|
||||
expect(() => prepared.stream({
|
||||
...prepared.config,
|
||||
messages: [],
|
||||
})).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' }))
|
||||
|
||||
const late = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
|
||||
const lateOptions = { ...late.config, messages: [] }
|
||||
const lateStream = late.stream(lateOptions)
|
||||
lateOptions.model = 'other'
|
||||
expect(await collect(lateStream)).toContainEqual({
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: {
|
||||
message: 'prepared LLM call config changed before adapter dispatch',
|
||||
code: 'INVALID_PREPARED_CALL',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses one exact-model lookup for prepared config and context metadata', async () => {
|
||||
|
||||
@@ -170,6 +170,32 @@ describe('configurable-provider directory', () => {
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('replaces its entries atomically, keeping the old set when a candidate collides', async () => {
|
||||
const ctx = await setup()
|
||||
const handle = ctx.llm.registerConfigurableProviders([entry(), entry({ provider: 'second' })])
|
||||
ctx.llm.registerConfigurableProviders([entry({ provider: 'owned-elsewhere' })])
|
||||
|
||||
// A candidate another registration already declares refuses the whole swap.
|
||||
expect(() =>{ handle.replace([entry({ provider: 'owned-elsewhere' })]) }).toThrow(/already declared/)
|
||||
expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort())
|
||||
.toEqual(['owned-elsewhere', 'second', entry().provider].sort())
|
||||
|
||||
// Its own entries are not "already declared" against itself, so a swap that
|
||||
// keeps one and drops another lands whole.
|
||||
handle.replace([entry({ displayName: 'Renamed' })])
|
||||
expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort())
|
||||
.toEqual(['owned-elsewhere', entry().provider].sort())
|
||||
expect(ctx.llm.listConfigurableProviders().find(view => view.provider === entry().provider)?.displayName)
|
||||
.toBe('Renamed')
|
||||
|
||||
// An empty replace is legal, unlike an empty initial registration.
|
||||
handle.replace([])
|
||||
expect(ctx.llm.listConfigurableProviders().map(view => view.provider)).toEqual(['owned-elsewhere'])
|
||||
|
||||
handle()
|
||||
expect(() =>{ handle.replace([entry()]) }).toThrow(/was disposed/)
|
||||
})
|
||||
|
||||
it('rejects duplicates within one registration and across registrations', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.llm.registerConfigurableProviders([entry(), entry()])).toThrow(/already declared/)
|
||||
@@ -179,3 +205,64 @@ 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)
|
||||
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' })
|
||||
|
||||
// Disposal is observed through the offer itself, which is the only thing
|
||||
// the registration ever produced.
|
||||
dispose()
|
||||
await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' }))
|
||||
.rejects.toThrow(/no model discovery is registered/)
|
||||
})
|
||||
|
||||
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/)
|
||||
// The refused second registration left the first one serving.
|
||||
await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' }))
|
||||
.resolves.toEqual([])
|
||||
})
|
||||
|
||||
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' })
|
||||
await expect(ctx.llm.discoverModels('llm-example', { provider: '', baseURL: '' }))
|
||||
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
|
||||
await expect(ctx.llm.discoverModels('llm-example', {}))
|
||||
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
|
||||
// Naming a route alone is enough: the adapter may know it without an endpoint.
|
||||
await expect(ctx.llm.discoverModels('llm-example', { provider: 'known-route' })).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user