Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/cordis-catalog/events.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/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/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/llm/llm/src/types.ts
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-deepseek/README.md
|
||||
README.md: ab44b61e300ca65cc4dd3507ad7262cd08edcfce
|
||||
README.zh.md: 4ecaf361fdb396f9f8079476240b5e9353a73f5e
|
||||
README.md: 020aa65073495526be3f32912b7cd06667c52a2e
|
||||
README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
DeepSeek chat-completions adapter for the harness LLM seam: direct `fetch` + SSE (framed by `eventsource-parser`) translating the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol.
|
||||
|
||||
A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design.
|
||||
A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package owns the `deepseek-official` provider route — deliberately distinct from pi-ai's catalog name `deepseek`, so one composition can mount both DeepSeek paths side by side; registering another adapter for `deepseek-official` itself still throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire serialization, SSE parsing, and chunk translation helpers are not part of that root contract.
|
||||
|
||||
@@ -19,6 +19,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
maxTokens: 256000 # optional positive per-request output cap; this is the default
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
retryPolicy: # optional; omission uses bounded normal defaults
|
||||
mode: always # normal | always
|
||||
@@ -26,18 +27,20 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
|
||||
defaultContextWindow: 1000000 # optional positive-integer fallback; this is the default
|
||||
models: # optional; defaults to V4 Flash and V4 Pro
|
||||
- id: deepseek-v4-flash
|
||||
name: DeepSeek-V4-Flash
|
||||
- id: private-reasoner
|
||||
description: Company-hosted reasoning model
|
||||
contextWindow: 64000
|
||||
contextWindow: 512000
|
||||
```
|
||||
|
||||
The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 256,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`.
|
||||
|
||||
The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O.
|
||||
|
||||
@@ -52,7 +55,9 @@ Connection facts are not frozen at load. `resolveAdapterOptions` is the one expl
|
||||
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
|
||||
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy.
|
||||
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.
|
||||
|
||||
The plugin also declares its route in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`): provider `deepseek-official`, settings namespace `llm-deepseek`, empty settings path — the whole section is the profile. Configuration surfaces use that entry to offer this adapter alongside dormant pi-ai providers.
|
||||
|
||||
## App attribution
|
||||
|
||||
@@ -107,7 +112,7 @@ Loop-retained response blocks append to the next request and preserve its earlie
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
|
||||
- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface.
|
||||
- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`.
|
||||
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
|
||||
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
|
||||
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE(Server-Sent Events,由 `eventsource-parser` 分帧),将官方协议格式(wire format;真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。
|
||||
|
||||
同一 seam 的第二个基于库的实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包(package)始终负责 `deepseek` 提供方路由;在同一上下文中装载 `provider: deepseek` 的 pi-ai profile 会按设计抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
同一 seam 的第二个基于库的实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包(package)拥有 `deepseek-official` 提供方路由——刻意区别于 pi-ai 的 catalog 名称 `deepseek`,因此同一组合可以并排挂载两条 DeepSeek 路径;而为 `deepseek-official` 本身注册另一个适配器仍会抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
|
||||
包根入口导出 Cordis 插件契约与 `DeepSeekAdapter`;协议序列化、SSE 解析与分片转换 helper 不属于该根契约。
|
||||
|
||||
@@ -19,6 +19,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
maxTokens: 256000 # optional positive per-request output cap; this is the default
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
retryPolicy: # optional; omission uses bounded normal defaults
|
||||
mode: always # normal | always
|
||||
@@ -26,18 +27,20 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
|
||||
defaultContextWindow: 1000000 # optional positive-integer fallback; this is the default
|
||||
models: # optional; defaults to V4 Flash and V4 Pro
|
||||
- id: deepseek-v4-flash
|
||||
name: DeepSeek-V4-Flash
|
||||
- id: private-reasoner
|
||||
description: Company-hosted reasoning model
|
||||
contextWindow: 64000
|
||||
contextWindow: 512000
|
||||
```
|
||||
|
||||
该插件注册唯一提供方路由 `deepseek`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 256,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
|
||||
该插件注册唯一提供方路由 `deepseek-official`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
|
||||
|
||||
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时,`context` 字段缺失但不会使路由失效。因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
|
||||
`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。
|
||||
|
||||
同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||
|
||||
@@ -52,7 +55,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
|
||||
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。
|
||||
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
|
||||
|
||||
该插件还会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`)中声明自己的路由:提供方为 `deepseek-official`,settings namespace 为 `llm-deepseek`,settings path 为空——整个分节就是 profile。配置界面借助该条目,把本适配器与休眠的 pi-ai 提供方一并呈现。
|
||||
|
||||
## 应用归因
|
||||
|
||||
@@ -107,7 +112,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
|
||||
- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。
|
||||
- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**:`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`。
|
||||
- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
|
||||
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
|
||||
- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。
|
||||
|
||||
@@ -56,8 +56,10 @@ export interface DeepSeekConnectionOptions {
|
||||
apiKeyEnv: CredentialRef
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults: RequestDefaults
|
||||
/** Default per-request output cap; explicit request values win. */
|
||||
maxTokens: number
|
||||
/** Positive context capacity used when the selected model has no exact value. */
|
||||
defaultContextWindow?: number
|
||||
defaultContextWindow: number
|
||||
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
|
||||
models: readonly DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding. */
|
||||
@@ -81,6 +83,10 @@ export interface DeepSeekAdapterOptions {
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
/** Default combined request/response context capacity. */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 1_000_000
|
||||
/** Default per-request output-token cap. */
|
||||
export const DEFAULT_MAX_TOKENS = 256_000
|
||||
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
|
||||
const OFF_REASONING_EFFORT = ReasoningEffortId('off')
|
||||
const HIGH_REASONING_EFFORT = ReasoningEffortId('high')
|
||||
@@ -179,7 +185,8 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
...configured === undefined
|
||||
? { provider, id: model, name: model, inputModalities: ['text' as const] }
|
||||
: modelInfo(provider, configured),
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
context: { contextWindow },
|
||||
defaultMaxTokens: connection.maxTokens,
|
||||
...connection.defaults.thinking === 'disabled'
|
||||
? {
|
||||
reasoning: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Register a {@link DeepSeekAdapter} for the `deepseek` provider route on
|
||||
* Register a {@link DeepSeekAdapter} for the `deepseek-official` provider route on
|
||||
* `ctx.llm`, with connection facts resolved per request instead of frozen at
|
||||
* load: the plugin layers its `cordis.yml` entry config under the optional
|
||||
* `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API
|
||||
@@ -18,10 +18,20 @@ import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
||||
DeepSeekAdapter,
|
||||
} from './adapter.ts'
|
||||
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
|
||||
|
||||
export { DeepSeekAdapter } from './adapter.ts'
|
||||
export {
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
||||
DeepSeekAdapter,
|
||||
} from './adapter.ts'
|
||||
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
|
||||
export type { RequestDefaults } from './serialize.ts'
|
||||
export type * from './types.ts'
|
||||
@@ -32,11 +42,11 @@ export const inject = ['llm']
|
||||
const NS = settingsNamespace('llm-deepseek')
|
||||
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
|
||||
/** The single provider route this plugin owns. */
|
||||
const PROVIDER = 'deepseek'
|
||||
const PROVIDER = 'deepseek-official'
|
||||
|
||||
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 },
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -58,7 +68,9 @@ export interface Config {
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Default thinking effort (default `high`); `off` disables thinking per request. */
|
||||
reasoningEffort?: 'off' | 'high' | 'max'
|
||||
/** Positive context capacity used when the selected model has no exact value. */
|
||||
/** Default per-request output cap (default 256,000); explicit request values win. */
|
||||
maxTokens?: number
|
||||
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
|
||||
defaultContextWindow?: number
|
||||
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
|
||||
models?: DeepSeekCatalogModel[]
|
||||
@@ -77,11 +89,12 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV),
|
||||
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
||||
baseURL: z.string(),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
reasoningEffort: z.union(['off', 'high', 'max']),
|
||||
defaultContextWindow: z.number().step(1).min(1),
|
||||
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
|
||||
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
||||
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
||||
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
||||
retryPolicy: RetryPolicySchema,
|
||||
@@ -141,6 +154,10 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
&& (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
|
||||
throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
|
||||
}
|
||||
if (config.maxTokens !== undefined
|
||||
&& (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
|
||||
throw new Error('llm-deepseek: maxTokens must be a positive safe integer')
|
||||
}
|
||||
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
|
||||
if (!Number.isFinite(streamIdleTimeoutMs)
|
||||
|| streamIdleTimeoutMs <= 0
|
||||
@@ -157,9 +174,8 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
},
|
||||
...config.defaultContextWindow === undefined
|
||||
? {}
|
||||
: { defaultContextWindow: config.defaultContextWindow },
|
||||
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
models: resolveModels(config.models),
|
||||
streamIdleTimeoutMs,
|
||||
retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'),
|
||||
@@ -215,18 +231,22 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
|
||||
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
|
||||
])
|
||||
// Route effects bind to this apply fiber via the stable `ctx` reference,
|
||||
// even when a swap runs inside the scoped settings callback below.
|
||||
let disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
|
||||
const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
|
||||
let registeredPolicy = options().retryPolicy
|
||||
const ensureRegistrationFacts = (): void => {
|
||||
const policy = options().retryPolicy
|
||||
if (deepEqualJson(policy, registeredPolicy)) return
|
||||
// The registry captures the retry policy at registration, so it is the one
|
||||
// fact per-request resolution cannot refresh: swap the registration in one
|
||||
// synchronous section (same adapter instance, no NO_ADAPTER window).
|
||||
disposeRoute()
|
||||
disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
|
||||
// fact per-request resolution cannot refresh. `replace` re-reads it in one
|
||||
// synchronous registry section: disposing and re-registering instead would
|
||||
// publish an empty route set between the two, and an observer that reacted
|
||||
// to it would see this provider disappear and come back.
|
||||
registration.replace([PROVIDER])
|
||||
registeredPolicy = policy
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,10 @@ export function serializeMessages(messages: Message[]): WireMessage[] {
|
||||
* @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
|
||||
* @returns the chat-completions request body.
|
||||
*/
|
||||
export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest {
|
||||
export function serializeRequest(
|
||||
options: GenerateOptions,
|
||||
defaults: RequestDefaults = {},
|
||||
): WireRequest {
|
||||
const messages: WireMessage[] = []
|
||||
if (options.system !== undefined) {
|
||||
messages.push({ role: 'system', content: options.system })
|
||||
@@ -178,7 +181,7 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
|
||||
: {},
|
||||
...tools !== undefined && tools.length > 0 ? { tools } : {},
|
||||
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
||||
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},
|
||||
...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
|
||||
...options.stop !== undefined ? { stop: options.stop } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
|
||||
const ctx = await harness(FLASH, { thinking: 'disabled' })
|
||||
const kinds: string[] = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: FLASH,
|
||||
messages: ask('Count from 1 to 5, digits only.'),
|
||||
maxTokens: 50,
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
// The wire request carried the auth header contents we configured.
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
model: 'deepseek-v4-flash',
|
||||
max_tokens: 256_000,
|
||||
reasoning_effort: 'high',
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
@@ -78,7 +79,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
|
||||
const kinds: string[] = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
@@ -167,6 +168,20 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the configured maxTokens default and preserves an explicit request cap', async () => {
|
||||
const server = await mockServer([
|
||||
{ kind: 'sse', events: textEvents },
|
||||
{ kind: 'sse', events: textEvents },
|
||||
])
|
||||
const ctx = await harness(server.url, { maxTokens: 32_000 })
|
||||
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 })
|
||||
|
||||
expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 })
|
||||
expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 })
|
||||
})
|
||||
|
||||
it('publishes only off and omits the wire effort when thinking is disabled', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url, { thinking: 'disabled' })
|
||||
@@ -182,7 +197,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
thinking: { type: 'disabled' },
|
||||
})
|
||||
expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash'))
|
||||
.resolves.toMatchObject({
|
||||
reasoning: {
|
||||
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
|
||||
@@ -213,7 +228,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' })
|
||||
|
||||
const stream = adapter.stream({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: ReasoningEffortId(effort),
|
||||
messages: [createUserMessage({
|
||||
@@ -420,7 +435,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
)
|
||||
try {
|
||||
const iterate = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
|
||||
}
|
||||
await expect(iterate()).rejects.toThrow(/no response body/)
|
||||
} finally {
|
||||
@@ -452,7 +467,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
const pending = (async () => {
|
||||
const chunks = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
signal: controller.signal,
|
||||
@@ -472,7 +487,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
const adapter = adapterOf({ baseURL: 'https://example.invalid' })
|
||||
try {
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
|
||||
}
|
||||
await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause })
|
||||
} finally {
|
||||
@@ -489,7 +504,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
const adapter = adapterOf({ baseURL: 'https://example.invalid' })
|
||||
try {
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
|
||||
}
|
||||
await expect(drain()).rejects.toMatchObject({
|
||||
message: 'DeepSeek API request to https://example.invalid failed',
|
||||
@@ -519,7 +534,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 })
|
||||
try {
|
||||
const drain = (async () => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
|
||||
})()
|
||||
const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -554,9 +569,16 @@ describe('plugin registration and config', () => {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
})
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([{
|
||||
provider: 'deepseek-official',
|
||||
displayName: 'DeepSeek',
|
||||
settingsNs: 'llm-deepseek',
|
||||
settingsPath: [],
|
||||
}])
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('registers retryPolicy from the provider config', async () => {
|
||||
@@ -571,7 +593,7 @@ describe('plugin registration and config', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({
|
||||
mode: 'always',
|
||||
initialDelayMs: 25,
|
||||
maxDelayMs: 100,
|
||||
@@ -583,17 +605,18 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
])
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash'))
|
||||
.resolves.toMatchObject({
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek-V4-Flash',
|
||||
context: { contextWindow: 256_000 },
|
||||
context: { contextWindow: 1_000_000 },
|
||||
defaultMaxTokens: 256_000,
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('off'), name: 'Off' },
|
||||
@@ -613,7 +636,7 @@ describe('plugin registration and config', () => {
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
reasoningEffort: effort,
|
||||
})
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through'))
|
||||
.resolves.toMatchObject({
|
||||
reasoning: {
|
||||
efforts: [
|
||||
@@ -635,7 +658,7 @@ describe('plugin registration and config', () => {
|
||||
thinking: 'disabled',
|
||||
reasoningEffort: 'off',
|
||||
})
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through'))
|
||||
.resolves.toMatchObject({
|
||||
reasoning: {
|
||||
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
|
||||
@@ -669,7 +692,7 @@ describe('plugin registration and config', () => {
|
||||
|
||||
it('accepts disabled thinking with off at the resolver boundary', async () => {
|
||||
const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' })
|
||||
await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({
|
||||
await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({
|
||||
reasoning: {
|
||||
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
|
||||
defaultEffort: ReasoningEffortId('off'),
|
||||
@@ -681,9 +704,9 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -703,19 +726,22 @@ describe('plugin registration and config', () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] },
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] },
|
||||
])
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 32_000 } })
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'private-reasoner'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner'))
|
||||
.resolves.toMatchObject({
|
||||
name: 'Private Reasoner',
|
||||
description: 'Higher reasoning budget',
|
||||
})
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'arbitrary-unlisted'))
|
||||
.resolves.not.toHaveProperty('context')
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted'))
|
||||
.resolves.toMatchObject({
|
||||
context: { contextWindow: 1_000_000 },
|
||||
defaultMaxTokens: 256_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses exact model capacity before the adapter-wide default', async () => {
|
||||
@@ -731,11 +757,11 @@ describe('plugin registration and config', () => {
|
||||
],
|
||||
})
|
||||
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'inherits-default'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'inherits-default'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 256_000 } })
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'exact-override'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 64_000 } })
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through'))
|
||||
.resolves.toMatchObject({ context: { contextWindow: 256_000 } })
|
||||
})
|
||||
|
||||
@@ -747,7 +773,7 @@ describe('plugin registration and config', () => {
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [],
|
||||
})
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([])
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -797,13 +823,30 @@ describe('plugin registration and config', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid adapter-wide maxTokens %s',
|
||||
async (maxTokens) => {
|
||||
expect(() => resolveAdapterOptions({ maxTokens }))
|
||||
.toThrow(/maxTokens must be a positive safe integer/)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
maxTokens,
|
||||
})).rejects.toThrow(/maxTokens/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => {
|
||||
@@ -813,8 +856,8 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
||||
// First-boot onboarding: the route registers so models stay discoverable;
|
||||
// only the request itself needs a key.
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
||||
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
||||
// The guidance leads with the credential store — the path that keeps the
|
||||
@@ -870,7 +913,7 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
// Registration succeeds; no call is made (would hit api.deepseek.com).
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('adapter is constructible directly for embedding over the shared resolver', async () => {
|
||||
@@ -878,7 +921,7 @@ describe('plugin registration and config', () => {
|
||||
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
|
||||
// Direct embedding shares the plugin's one resolve step, so it advertises
|
||||
// the same default catalog instead of a divergent empty one.
|
||||
await expect(adapter.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resolves connection facts and the credential exactly once per stream call', async () => {
|
||||
@@ -887,7 +930,7 @@ describe('plugin registration and config', () => {
|
||||
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
|
||||
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
|
||||
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
|
||||
|
||||
expect(options).toHaveBeenCalledTimes(1)
|
||||
expect(resolveApiKey).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface AssembledResult {
|
||||
|
||||
export async function assemble(ctx: Context, options: Omit<GenerateOptions, 'provider'> & { provider?: string }): Promise<AssembledResult> {
|
||||
const assembler = new BlockAssembler()
|
||||
const request = { provider: 'deepseek', ...options }
|
||||
const request = { provider: 'deepseek-official', ...options }
|
||||
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk)
|
||||
return {
|
||||
message: assembler.message({
|
||||
|
||||
@@ -106,27 +106,36 @@ describe('request-level dynamic configuration', () => {
|
||||
const dir = await home()
|
||||
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
||||
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'settings-model', name: 'From Settings' },
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('re-registers the route in place when the captured retry policy changes', async () => {
|
||||
it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => {
|
||||
const dir = await home()
|
||||
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
|
||||
// Observing the topology event, not just the end state: disposing and
|
||||
// re-registering also lands on the right final registry, but publishes an
|
||||
// empty route set in between, so an observer sees the provider disappear.
|
||||
const observed: string[][] = []
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
observed.push(ctx.llm.listProviders().map(provider => provider.id))
|
||||
})
|
||||
|
||||
await ctx.settings.update(NS, {
|
||||
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({
|
||||
mode: 'always',
|
||||
initialDelayMs: 25,
|
||||
maxDelayMs: 100,
|
||||
jitterRatio: 0.2,
|
||||
})
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
expect(observed).toEqual([['deepseek-official']])
|
||||
})
|
||||
|
||||
it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => {
|
||||
@@ -136,10 +145,10 @@ describe('request-level dynamic configuration', () => {
|
||||
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
|
||||
// schema and fail the explicit resolve step.
|
||||
await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
||||
await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'recovered', name: 'recovered' },
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'recovered', name: 'recovered', inputModalities: ['text'] },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-ll
|
||||
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
|
||||
|
||||
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
|
||||
return { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], ...overrides }
|
||||
return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides }
|
||||
}
|
||||
|
||||
describe('serializeMessages', () => {
|
||||
|
||||
@@ -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-pi-ai/README.md
|
||||
README.md: a0ba058b7aa23a9bfb50cdde97ba3f9dbe9437ae
|
||||
README.zh.md: b09d09890cf457980845ffc9c5cc4fbde5cca75d
|
||||
README.md: 7f6483568aec75fdfa457d21d370b289116a4bf4
|
||||
README.zh.md: accd69ff5e687d76a1ff552972bf60455307d567
|
||||
|
||||
@@ -35,7 +35,7 @@ Configure credentials and deployment-specific transport settings per provider, k
|
||||
X-Deployment: production
|
||||
```
|
||||
|
||||
Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
|
||||
Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), so configuration surfaces can offer the full catalog before any route exists. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
|
||||
|
||||
## Dynamic configuration (settings + credentials)
|
||||
|
||||
@@ -114,7 +114,7 @@ Recorded response content appends to the next request and does not invalidate it
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer.
|
||||
- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface.
|
||||
- **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work).
|
||||
- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint.
|
||||
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
|
||||
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
X-Deployment: production
|
||||
```
|
||||
|
||||
每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
|
||||
每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,因此配置界面可以在任何路由存在之前就提供完整 catalog。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
|
||||
|
||||
## 动态配置(settings + credentials)
|
||||
|
||||
@@ -114,7 +114,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。
|
||||
- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。
|
||||
- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。
|
||||
- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
|
||||
|
||||
@@ -77,7 +77,7 @@ const thinkingBudgets = z.object({
|
||||
|
||||
const profile = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string(),
|
||||
apiKeyEnv: z.string().role('credential-ref'),
|
||||
baseURL: z.string(),
|
||||
headers: z.dict(z.string()),
|
||||
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
@@ -115,6 +116,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
resolveApiKey,
|
||||
resolveAttachments: () => ctx.get('attachments'),
|
||||
})
|
||||
// The full installed catalog is configurable from the moment the plugin
|
||||
// mounts — dormant or not — so configuration surfaces can offer every
|
||||
// pi-ai provider before any route exists.
|
||||
ctx.llm.registerConfigurableProviders(getBuiltinProviders().map(provider => ({
|
||||
provider,
|
||||
displayName: provider,
|
||||
settingsNs: NS,
|
||||
settingsPath: ['providers', provider],
|
||||
})))
|
||||
// Route effects bind to this apply fiber via the stable `ctx` reference,
|
||||
// even when a swap runs inside the scoped settings callback below. A bare
|
||||
// mount (zero routes) is the dormant posture: nothing registers until a
|
||||
|
||||
@@ -155,7 +155,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
|
||||
|
||||
const prompt = ask('Reply with exactly the word: pong')
|
||||
const [fromDeepSeek, fromPiAi] = await Promise.all([
|
||||
assemble(deepseekCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
|
||||
assemble(deepseekCtx, { provider: 'deepseek-official', model: FLASH, messages: prompt, maxTokens: 50 }),
|
||||
assemble(piCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
|
||||
])
|
||||
expect(blockKinds(fromPiAi)).toEqual(blockKinds(fromDeepSeek))
|
||||
|
||||
@@ -59,6 +59,16 @@ describe('request-level dynamic profiles', () => {
|
||||
const ctx = await boot(dir, {})
|
||||
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
// Dormant ≠ invisible: every installed catalog provider is configurable
|
||||
// before any route exists, each addressed inside the providers dict.
|
||||
const directory = ctx.llm.listConfigurableProviders()
|
||||
expect(directory.length).toBeGreaterThan(30)
|
||||
expect(directory).toContainEqual({
|
||||
provider: 'openai',
|
||||
displayName: 'openai',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
})
|
||||
await ctx.settings.update(NS, {
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
const port = await unusedPort()
|
||||
context = await harness(`http://127.0.0.1:${port}`, { initialDelayMs: 100 })
|
||||
const agent = context.agentLoop.create(SessionId('wire-refused'), {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'mock-model',
|
||||
})
|
||||
let recoveryServer: Promise<MockLlmServer> | undefined
|
||||
@@ -128,7 +128,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
})
|
||||
context = await harness(server.baseURL)
|
||||
const agent = context.agentLoop.create(SessionId(`wire-${behavior}`), {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'mock-model',
|
||||
})
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
})
|
||||
context = await harness(server.baseURL)
|
||||
const agent = context.agentLoop.create(SessionId('wire-empty'), {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'mock-model',
|
||||
})
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
})
|
||||
context = await harness(server.baseURL)
|
||||
const agent = context.agentLoop.create(SessionId('wire-partial-eof'), {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'mock-model',
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
// the stalled attempt and the mock server's immediate successful response.
|
||||
context = await harness(server.baseURL, { streamIdleTimeoutMs: 1_000 })
|
||||
const agent = context.agentLoop.create(SessionId('wire-stall'), {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'mock-model',
|
||||
})
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
|
||||
})
|
||||
context = await harness(server.baseURL)
|
||||
const agent = context.agentLoop.create(SessionId('wire-exhausted'), {
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'mock-model',
|
||||
})
|
||||
|
||||
|
||||
@@ -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: 5fcec61daaec89dd6a7a34d438bb2be2a1894bc7
|
||||
README.zh.md: f43f698884bec6b3d35ff343bdc8bf3839cb91ff
|
||||
README.md: ea7247385a91d33b72710839c28a67d747c76821
|
||||
README.zh.md: b736b2409fc8c0f3dacbb6936bab9302e1bf9064
|
||||
|
||||
@@ -12,10 +12,12 @@ 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.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 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 an adapter-configured default without clamping.
|
||||
- `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 and capture its current adapter registration 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`.
|
||||
|
||||
@@ -23,19 +25,22 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`.
|
||||
Every topology commit point — adapter routes registering or disposing, directory entries appearing or withdrawing — emits the payload-free `llm/adapters-updated` event after the mutation, so consumers re-read `listProviders()`/`listModels()`/`listConfigurableProviders()` instead of polling. Observer failures are contained (logged, non-vetoing); only `INVARIANT`-coded failures rethrow after the fan-out.
|
||||
|
||||
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context`, `defaultMaxTokens`, or `reasoning` fields preserve unknown capacity, provider-owned output defaults, or unavailable reasoning capability. Invalid identity, context, output default, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, `INVALID_MODEL_MAX_TOKENS`, or `INVALID_MODEL_REASONING`.
|
||||
|
||||
`defaultMaxTokens` is an adapter-configured per-request output cap, not a model hard limit. `resolveCallConfig()` materializes it only when the request omits `maxTokens`; an explicit cap wins. Reasoning identifiers are opaque adapter-owned strings rather than a core enum: the same resolution accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally reports which `maxTokens` and `reasoningEffort` fields it materialized in `adapterDefaults` and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
|
||||
### Events
|
||||
|
||||
| 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
|
||||
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata.
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, an output default, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity, output default, or reasoning metadata.
|
||||
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead.
|
||||
|
||||
### Messages (`message.ts`) and content blocks (`types.ts`)
|
||||
@@ -48,7 +53,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates and defaults it under the turn signal, and the loop logs the effective value before using the prepared call's registration-bound stream. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
|
||||
`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value plus adapter-default provenance before using the prepared call's registration-bound stream. The next proposal omits marked defaults so a changed route resolves its own values; unmarked explicit fields persist. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
@@ -67,7 +72,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
|
||||
### Real adapters
|
||||
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek-official` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
|
||||
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。
|
||||
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。
|
||||
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。
|
||||
- `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.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。
|
||||
|
||||
@@ -23,19 +25,22 @@
|
||||
|
||||
提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此重读 `listProviders()`/`listModels()`/`listConfigurableProviders()` 而非轮询。观察者故障会被隔离(记录日志、不否决);只有带 `INVARIANT` 码的故障会在扇出后重新抛出。
|
||||
|
||||
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context`、`defaultMaxTokens` 或 `reasoning` 字段会分别保留未知容量、提供方持有的输出默认值或不可用的推理能力。无效的身份、上下文、输出默认值或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT`、`INVALID_MODEL_MAX_TOKENS` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
|
||||
`defaultMaxTokens` 是适配器配置的单次请求输出上限,不是模型硬上限。仅当请求省略 `maxTokens` 时,`resolveCallConfig()` 才会填入该值;显式上限优先。推理标识符是由适配器持有的不透明字符串,而非核心枚举:同一次解析只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会通过 `adapterDefaults` 报告它填入了哪些 `maxTokens` 和 `reasoningEffort` 字段,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||
|
||||
### 事件
|
||||
|
||||
| 事件 | 模式 | 用途 |
|
||||
|---|---|---|
|
||||
| `llm/adapters-updated` | emit | 在注册表提交后通知消费方重新读取提供方和模型拓扑 |
|
||||
| `llm/stream` | waterfall | 拦截/包装每次流式模型调用,用于缓存、日志或路由 |
|
||||
|
||||
### 扩展点
|
||||
|
||||
- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方持有的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。
|
||||
- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方持有的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量、输出默认值或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量、输出默认值或推理元数据。
|
||||
- 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出分片后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`。
|
||||
|
||||
### 消息(`message.ts`)与内容块(`types.ts`)
|
||||
@@ -48,7 +53,7 @@
|
||||
|
||||
### 调用配置(`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验并填入默认值,loop 随后记录生效值,再使用已准备调用中与注册绑定的流。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。
|
||||
`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值及适配器默认值来源,再使用已准备调用中与注册绑定的流。下一次提议会省略带标记的默认值,使变更后的路由解析自身的值;未带标记的显式字段会保留。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。
|
||||
|
||||
### 应用归因(`attribution.ts`)
|
||||
|
||||
@@ -67,7 +72,7 @@
|
||||
|
||||
### 真实适配器
|
||||
|
||||
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
|
||||
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek-official` 路由使用直接 fetch 加 `eventsource-parser` SSE(Server-Sent Events)分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@ export interface LlmCallConfig {
|
||||
stop?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective config fields supplied by exact-model adapter resolution rather
|
||||
* than by the caller's request proposal.
|
||||
*/
|
||||
export interface LlmCallConfigAdapterDefaults {
|
||||
reasoningEffort?: true
|
||||
maxTokens?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-wise equality over {@link LlmCallConfig} — the comparison a caller
|
||||
* runs to decide whether a proposed configuration is a real change (worth a
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmConfigurableProvider,
|
||||
LlmFailure,
|
||||
LlmModelInfo,
|
||||
LlmResolvedModelInfo,
|
||||
@@ -21,7 +22,7 @@ import { resolveRetryPolicy } from './retry-policy.ts'
|
||||
import type { ResolvedRetryPolicy } from './retry-policy.ts'
|
||||
import type { ProviderRequestId } from './brand.ts'
|
||||
import { callConfigEquals, deepFreeze } from './call-config.ts'
|
||||
import type { LlmCallConfig } 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'
|
||||
@@ -36,7 +37,7 @@ export * from './message.ts'
|
||||
export * from './retry-policy.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
|
||||
export type { LlmCallConfig } from './call-config.ts'
|
||||
export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
|
||||
export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -58,6 +59,17 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
|
||||
/**
|
||||
* The provider topology changed: an adapter registered or unregistered
|
||||
* routes, or the configurable-provider directory gained or lost entries.
|
||||
* This is a payload-free registry notification fired at each commit point
|
||||
* (including registration disposal); consumers re-read `listProviders()`,
|
||||
* `listModels()`, or `listConfigurableProviders()` for the new state.
|
||||
* Observer failures are contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'llm/adapters-updated'(): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +127,8 @@ export class LlmError extends HarnessError {
|
||||
export interface PreparedLlmCall {
|
||||
/** Detached, deep-frozen config with any adapter-owned default materialized. */
|
||||
readonly config: LlmCallConfig
|
||||
/** Config fields materialized by the captured adapter rather than proposed by the caller. */
|
||||
readonly adapterDefaults: LlmCallConfigAdapterDefaults
|
||||
/**
|
||||
* Dispatch this call once through the registration captured during
|
||||
* preparation. The request's call-config fields must match {@link config};
|
||||
@@ -168,7 +182,7 @@ export abstract class LlmAdapter {
|
||||
* @param model - exact model id passed to {@link GenerateOptions.model}.
|
||||
* @param _signal - cancellation for this exact-model lookup; asynchronous
|
||||
* implementations must settle promptly after it aborts.
|
||||
* @returns provider/model identity plus any context and reasoning metadata.
|
||||
* @returns provider/model identity plus any context, call-default, and reasoning metadata.
|
||||
*/
|
||||
resolveModel(
|
||||
provider: string,
|
||||
@@ -216,11 +230,46 @@ export interface AdapterRegistrationHandle {
|
||||
*/
|
||||
export class LlmService extends Service {
|
||||
private adapters = new Map<string, AdapterRegistration>()
|
||||
private directory = new Map<string, LlmConfigurableProvider>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'llm')
|
||||
}
|
||||
|
||||
/** Notify topology observers without letting one broken listener veto the commit. */
|
||||
private emitAdaptersUpdated(): void {
|
||||
// Cordis emit uses Array.map: one synchronous throw starves later
|
||||
// listeners. Registry notifications are non-vetoing, so contain each
|
||||
// callback independently; INVARIANT-coded failures still surface.
|
||||
let invariantFailure: unknown
|
||||
for (const listener of this.ctx.events.dispatch('emit', ['llm/adapters-updated']) as Array<() => unknown>) {
|
||||
try {
|
||||
const returned = listener()
|
||||
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
|
||||
// An emit listener may still be an async function; its rejection
|
||||
// cannot reach the synchronous INVARIANT rethrow below, so it is
|
||||
// contained here instead of becoming an unhandled rejection.
|
||||
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
|
||||
this.warnAdaptersListenerFailure(error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
|
||||
invariantFailure ??= error
|
||||
continue
|
||||
}
|
||||
this.warnAdaptersListenerFailure(error)
|
||||
}
|
||||
}
|
||||
if (invariantFailure !== undefined) throw invariantFailure as Error
|
||||
}
|
||||
|
||||
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
||||
private warnAdaptersListenerFailure(error: unknown): void {
|
||||
this.ctx.logger.warn('llm: an llm/adapters-updated listener failed')
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an adapter for the given provider routes. Throws `LlmError` with code
|
||||
* `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).
|
||||
@@ -243,6 +292,7 @@ export class LlmService extends Service {
|
||||
released = true
|
||||
for (const provider of owned) this.adapters.delete(provider)
|
||||
owned.clear()
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
}.bind(this), 'llm.registerAdapter()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
@@ -291,7 +341,9 @@ export class LlmService extends Service {
|
||||
/**
|
||||
* Swap this registration's routes for the prepared ones in one synchronous
|
||||
* section, so no observer can see the registry between the release and the
|
||||
* re-registration.
|
||||
* re-registration. The route set's one mutation point is also where
|
||||
* `llm/adapters-updated` is published, so a `replace` announces itself
|
||||
* exactly like a first registration.
|
||||
*/
|
||||
private commitRoutes(owned: Set<string>, registrations: readonly AdapterRegistration[]): void {
|
||||
for (const provider of owned) this.adapters.delete(provider)
|
||||
@@ -300,6 +352,7 @@ export class LlmService extends Service {
|
||||
this.adapters.set(registration.provider.id, registration)
|
||||
owned.add(registration.provider.id)
|
||||
}
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,6 +363,50 @@ export class LlmService extends Service {
|
||||
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare provider routes an adapter plugin can activate through
|
||||
* configuration. Registration is all-or-nothing: an empty list, invalid
|
||||
* entry, or a provider already declared by any registration throws
|
||||
* `LlmError` without registering the rest. Disposed with the fiber.
|
||||
* @param entries - every configurable provider this plugin owns.
|
||||
* @returns the disposer that withdraws all of them.
|
||||
*/
|
||||
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
if (entries.length === 0) {
|
||||
throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY')
|
||||
}
|
||||
const detached: LlmConfigurableProvider[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.provider.length === 0 || entry.displayName.length === 0 || entry.settingsNs.length === 0) {
|
||||
throw new LlmError('configurable providers need a non-empty provider, displayName, and settingsNs', 'INVALID_DIRECTORY')
|
||||
}
|
||||
if (entry.settingsPath.some(segment => segment.length === 0)) {
|
||||
throw new LlmError(`configurable provider "${entry.provider}" has an empty settingsPath segment`, 'INVALID_DIRECTORY')
|
||||
}
|
||||
if (this.directory.has(entry.provider) || detached.some(seen => seen.provider === entry.provider)) {
|
||||
throw new LlmError(`configurable provider "${entry.provider}" is already declared`, 'DUPLICATE_DIRECTORY')
|
||||
}
|
||||
detached.push({ ...entry, settingsPath: [...entry.settingsPath] })
|
||||
}
|
||||
for (const entry of detached) this.directory.set(entry.provider, entry)
|
||||
this.emitAdaptersUpdated()
|
||||
yield () => {
|
||||
for (const entry of detached) this.directory.delete(entry.provider)
|
||||
this.emitAdaptersUpdated()
|
||||
}
|
||||
}.bind(this), 'llm.registerConfigurableProviders()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* List every declared configurable provider, registered or dormant.
|
||||
* @returns detached directory entries in declaration order.
|
||||
*/
|
||||
listConfigurableProviders(): LlmConfigurableProvider[] {
|
||||
return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the retry policy captured when one provider route was registered.
|
||||
* @param provider - registered provider route to inspect.
|
||||
@@ -407,6 +504,14 @@ export class LlmService extends Service {
|
||||
// Capability metadata rides through: an explicit modality omission is
|
||||
// negative capability downstream preflights act on (image admission).
|
||||
const inputModalities = this.detachedModalities(resolved.inputModalities)
|
||||
const defaultMaxTokens = resolved.defaultMaxTokens
|
||||
if (defaultMaxTokens !== undefined
|
||||
&& (!Number.isSafeInteger(defaultMaxTokens) || defaultMaxTokens <= 0)) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid default maxTokens for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_MAX_TOKENS',
|
||||
)
|
||||
}
|
||||
const info: LlmResolvedModelInfo = {
|
||||
provider,
|
||||
id: model,
|
||||
@@ -414,6 +519,7 @@ export class LlmService extends Service {
|
||||
...resolved.description === undefined ? {} : { description: resolved.description },
|
||||
...inputModalities === undefined ? {} : { inputModalities },
|
||||
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
|
||||
...defaultMaxTokens === undefined ? {} : { defaultMaxTokens },
|
||||
}
|
||||
const reasoning = resolved.reasoning
|
||||
if (reasoning === undefined) return info
|
||||
@@ -462,7 +568,7 @@ export class LlmService extends Service {
|
||||
|
||||
/**
|
||||
* Validate a conversation call config against its exact model capability and
|
||||
* materialize an adapter-configured default. Unsupported explicit efforts
|
||||
* materialize adapter-configured defaults. Unsupported explicit efforts
|
||||
* reject before provider I/O; no clamping or aliasing is performed. This
|
||||
* standalone query does not bind a later dispatch; use {@link prepareCall}
|
||||
* when logging and streaming must share one adapter registration.
|
||||
@@ -479,8 +585,12 @@ export class LlmService extends Service {
|
||||
config: LlmCallConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmCallConfig> {
|
||||
const reasoning = (await this.resolveModelInfoFor(registration, config.model, signal)).reasoning
|
||||
const requested = config.reasoningEffort
|
||||
const info = await this.resolveModelInfoFor(registration, config.model, signal)
|
||||
const defaulted = config.maxTokens === undefined && info.defaultMaxTokens !== undefined
|
||||
? { ...config, maxTokens: info.defaultMaxTokens }
|
||||
: config
|
||||
const reasoning = info.reasoning
|
||||
const requested = defaulted.reasoningEffort
|
||||
if (reasoning === undefined) {
|
||||
if (requested !== undefined) {
|
||||
throw new LlmError(
|
||||
@@ -488,17 +598,17 @@ export class LlmService extends Service {
|
||||
'UNSUPPORTED_REASONING_EFFORT',
|
||||
)
|
||||
}
|
||||
return config
|
||||
return defaulted
|
||||
}
|
||||
const effective = requested ?? reasoning.defaultEffort
|
||||
if (effective === undefined) return config
|
||||
if (effective === undefined) return defaulted
|
||||
if (!reasoning.efforts.some(effort => effort.id === effective)) {
|
||||
throw new LlmError(
|
||||
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`,
|
||||
'UNSUPPORTED_REASONING_EFFORT',
|
||||
)
|
||||
}
|
||||
return requested === effective ? config : { ...config, reasoningEffort: effective }
|
||||
return requested === effective ? defaulted : { ...defaulted, reasoningEffort: effective }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,12 +621,20 @@ export class LlmService extends Service {
|
||||
*/
|
||||
async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall> {
|
||||
const registration = this.registration(config.provider)
|
||||
const resolvedConfig = deepFreeze(structuredClone(
|
||||
await this.resolveCallConfigFor(registration, config, signal),
|
||||
))
|
||||
const resolved = await this.resolveCallConfigFor(registration, config, signal)
|
||||
const resolvedConfig = deepFreeze(structuredClone(resolved))
|
||||
const adapterDefaults = deepFreeze<LlmCallConfigAdapterDefaults>({
|
||||
...config.reasoningEffort === undefined && resolved.reasoningEffort !== undefined
|
||||
? { reasoningEffort: true }
|
||||
: {},
|
||||
...config.maxTokens === undefined && resolved.maxTokens !== undefined
|
||||
? { maxTokens: true }
|
||||
: {},
|
||||
})
|
||||
let dispatched = false
|
||||
return Object.freeze({
|
||||
config: resolvedConfig,
|
||||
adapterDefaults,
|
||||
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
|
||||
if (dispatched) {
|
||||
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
|
||||
|
||||
@@ -84,6 +84,21 @@ async function* validateStream(
|
||||
/** Install validation around every provider stream. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true })
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
// A disposer-time emit can outlive the service-store entry during whole-
|
||||
// context teardown; only a live service promises a readable registry.
|
||||
const llm = ctx.get('llm')
|
||||
if (llm === undefined) return
|
||||
for (const provider of llm.listProviders()) {
|
||||
try {
|
||||
llm.providerRetryPolicy(provider.id)
|
||||
} catch {
|
||||
// Reaching here IS the violation: the notification promised a readable
|
||||
// registry, and only that broken promise can make the lookup throw.
|
||||
fail(`llm/adapters-updated fired while provider "${provider.id}" has no readable registration`)
|
||||
}
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,6 +142,26 @@ export interface ModelModalityMap {
|
||||
/** Any declared provider model modality. */
|
||||
export type ModelModality = ModelModalityMap[keyof ModelModalityMap]
|
||||
|
||||
/**
|
||||
* One provider route an adapter plugin can activate through configuration,
|
||||
* whether or not the route is currently registered. Configuration surfaces
|
||||
* merge this directory with `listProviders()` to offer every configurable
|
||||
* provider alongside its live/dormant state.
|
||||
*/
|
||||
export interface LlmConfigurableProvider {
|
||||
/** Provider route key this entry activates when configured. */
|
||||
provider: string
|
||||
/** Human-readable provider name for configuration surfaces. */
|
||||
displayName: string
|
||||
/** User-settings namespace whose section configures this provider. */
|
||||
settingsNs: string
|
||||
/**
|
||||
* Path from that namespace's section root to this provider's profile
|
||||
* object; empty when the whole section is the profile.
|
||||
*/
|
||||
settingsPath: readonly string[]
|
||||
}
|
||||
|
||||
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
|
||||
export interface LlmModelInfo {
|
||||
/** Provider route that owns this model entry. */
|
||||
@@ -187,6 +207,8 @@ export interface LlmModelReasoningInfo {
|
||||
export interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
/** Provider-owned context capacity when known. */
|
||||
context?: LlmModelContext
|
||||
/** Adapter-configured per-request output cap materialized when callers omit one. */
|
||||
defaultMaxTokens?: number
|
||||
/** Adapter-owned selectable reasoning levels when exposed. */
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmInvariant from '@deepseek-ai/dsh-llm/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -84,3 +84,40 @@ describe('LLM stream invariants', () => {
|
||||
})()).rejects.toThrow('provider failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adapters-updated invariants', () => {
|
||||
class NoopAdapter extends LlmAdapter {
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('not exercised')
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts a coherent registry at every topology notification', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(LlmService)
|
||||
const dispose = ctx.llm.registerAdapter(['coherent'], new NoopAdapter())
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: 'dormant', displayName: 'Dormant', settingsNs: 'ns', settingsPath: [] },
|
||||
])
|
||||
dispose()
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('skips the check when the service store has no llm entry', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('llm/adapters-updated') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports a notification whose registry cannot be re-read', async () => {
|
||||
class BrokenLlm extends LlmService {
|
||||
override providerRetryPolicy(_provider: string): never {
|
||||
throw new Error('registration vanished')
|
||||
}
|
||||
}
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(BrokenLlm)
|
||||
expect(() => ctx.llm.registerAdapter(['ghost'], new NoopAdapter()))
|
||||
.toThrow(/no readable registration/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ class CatalogAdapter extends ScriptedAdapter {
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
private readonly contexts: Readonly<Record<string, LlmModelContext>> = {},
|
||||
private readonly reasoning: Readonly<Record<string, LlmModelReasoningInfo>> = {},
|
||||
private readonly defaultMaxTokens: Readonly<Record<string, number>> = {},
|
||||
) {
|
||||
super(SCRIPT)
|
||||
}
|
||||
@@ -82,6 +83,7 @@ class CatalogAdapter extends ScriptedAdapter {
|
||||
name: model,
|
||||
...this.contexts[model] === undefined ? {} : { context: this.contexts[model] },
|
||||
...this.reasoning[model] === undefined ? {} : { reasoning: this.reasoning[model] },
|
||||
...this.defaultMaxTokens[model] === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens[model] },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -925,7 +927,12 @@ describe('LlmService', () => {
|
||||
{ id: 'route', name: 'Route' },
|
||||
[],
|
||||
{},
|
||||
{ model: source },
|
||||
{
|
||||
model: source,
|
||||
providerDefault: {
|
||||
efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }],
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
const resolved = await ctx.llm.resolveModelInfo('route', 'model')
|
||||
@@ -939,8 +946,54 @@ describe('LlmService', () => {
|
||||
})
|
||||
const explicit = { provider: 'route', model: 'model', reasoningEffort: ReasoningEffortId('ultra') }
|
||||
await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit)
|
||||
const providerDefault = { provider: 'route', model: 'providerDefault' }
|
||||
await expect(ctx.llm.resolveCallConfig(providerDefault)).resolves.toBe(providerDefault)
|
||||
})
|
||||
|
||||
it('materializes an adapter-owned maxTokens default while preserving an explicit cap', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
|
||||
{ id: 'route', name: 'Route' },
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{ model: 256_000 },
|
||||
))
|
||||
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toMatchObject({
|
||||
defaultMaxTokens: 256_000,
|
||||
})
|
||||
await expect(ctx.llm.resolveCallConfig({ provider: 'route', model: 'model' })).resolves.toEqual({
|
||||
provider: 'route',
|
||||
model: 'model',
|
||||
maxTokens: 256_000,
|
||||
})
|
||||
const preparedDefault = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
|
||||
expect(preparedDefault.adapterDefaults).toEqual({ maxTokens: true })
|
||||
const explicit = { provider: 'route', model: 'model', maxTokens: 8_192 }
|
||||
await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit)
|
||||
const preparedExplicit = await ctx.llm.prepareCall(explicit)
|
||||
expect(preparedExplicit.adapterDefaults).toEqual({})
|
||||
})
|
||||
|
||||
it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid adapter-owned default maxTokens %s',
|
||||
async (defaultMaxTokens) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new class extends ScriptedAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, defaultMaxTokens })
|
||||
}
|
||||
}(SCRIPT)
|
||||
ctx.llm.registerAdapter(['route'], adapter)
|
||||
|
||||
await expect(ctx.llm.resolveModelInfo('route', 'model'))
|
||||
.rejects.toMatchObject({ code: 'INVALID_MODEL_MAX_TOKENS' })
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
[{ efforts: [] }, 'empty effort list'],
|
||||
[{ efforts: [{ id: '', name: 'Empty' }] }, 'empty id'],
|
||||
@@ -1085,6 +1138,8 @@ describe('LlmService', () => {
|
||||
ctx.llm.registerAdapter(['route'], adapter)
|
||||
const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
|
||||
expect(Object.isFrozen(prepared.config)).toBe(true)
|
||||
expect(Object.isFrozen(prepared.adapterDefaults)).toBe(true)
|
||||
expect(prepared.adapterDefaults).toEqual({ reasoningEffort: true })
|
||||
const stream = prepared.stream({
|
||||
...prepared.config,
|
||||
model: 'other',
|
||||
|
||||
181
packages/llm/llm/tests/topology.spec.ts
Normal file
181
packages/llm/llm/tests/topology.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmConfigurableProvider, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class NoopAdapter extends LlmAdapter {
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('not exercised')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function entry(overrides: Partial<LlmConfigurableProvider> = {}): LlmConfigurableProvider {
|
||||
return {
|
||||
provider: 'openai',
|
||||
displayName: 'OpenAI',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('llm/adapters-updated', () => {
|
||||
it('fires at both adapter registration commit points with the registry already readable', async () => {
|
||||
const ctx = await setup()
|
||||
const observed: string[][] = []
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
observed.push(ctx.llm.listProviders().map(provider => provider.id))
|
||||
})
|
||||
const dispose = ctx.llm.registerAdapter(['a', 'b'], new NoopAdapter())
|
||||
expect(observed).toEqual([['a', 'b']])
|
||||
dispose()
|
||||
expect(observed).toEqual([['a', 'b'], []])
|
||||
})
|
||||
|
||||
it('contains a throwing listener without vetoing registration or starving later listeners', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const later = vi.fn()
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
throw new Error('broken observer')
|
||||
})
|
||||
ctx.on('llm/adapters-updated', later)
|
||||
ctx.llm.registerAdapter(['a'], new NoopAdapter())
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['a'])
|
||||
expect(later).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith('llm: an llm/adapters-updated listener failed')
|
||||
})
|
||||
|
||||
it('contains an ASYNC listener rejection instead of leaving it unhandled', async () => {
|
||||
// An emit listener may be an async function; its rejection cannot reach
|
||||
// the synchronous catch, so an uncontained one escapes the process as an
|
||||
// unhandled rejection rather than a warned observer failure.
|
||||
const ctx = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
// Typed as returning unknown so the listener is not a Promise-returning
|
||||
// function type: the point is exactly that an async one may slip in.
|
||||
const rejecting = (): unknown => Promise.reject(new Error('async observer'))
|
||||
ctx.on('llm/adapters-updated', rejecting)
|
||||
ctx.llm.registerAdapter(['a'], new NoopAdapter())
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['a'])
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith('llm: an llm/adapters-updated listener failed')
|
||||
} finally {
|
||||
process.off('unhandledRejection', unhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces a route set in one event, never publishing an empty registry between the two', async () => {
|
||||
// The retry-policy swap in llm-deepseek: disposing and re-registering
|
||||
// would let an observer see the provider disappear and come back.
|
||||
const ctx = await setup()
|
||||
const observed: string[][] = []
|
||||
const registration = ctx.llm.registerAdapter(['a'], new NoopAdapter())
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
observed.push(ctx.llm.listProviders().map(provider => provider.id))
|
||||
})
|
||||
registration.replace(['a'])
|
||||
expect(observed).toEqual([['a']])
|
||||
})
|
||||
|
||||
it('rethrows the first INVARIANT-coded listener failure after notifying the rest', async () => {
|
||||
const ctx = await setup()
|
||||
const later = vi.fn()
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
throw Object.assign(new Error('registry incoherent'), { code: 'INVARIANT' })
|
||||
})
|
||||
ctx.on('llm/adapters-updated', later)
|
||||
expect(() => ctx.llm.registerAdapter(['a'], new NoopAdapter())).toThrow('registry incoherent')
|
||||
expect(later).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('configurable-provider directory', () => {
|
||||
it('registers entries, lists detached copies in order, and fires the topology event', async () => {
|
||||
const ctx = await setup()
|
||||
const events = vi.fn()
|
||||
ctx.on('llm/adapters-updated', events)
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
entry({ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }),
|
||||
entry(),
|
||||
])
|
||||
expect(events).toHaveBeenCalledTimes(1)
|
||||
const listed = ctx.llm.listConfigurableProviders()
|
||||
expect(listed).toEqual([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
{ provider: 'openai', displayName: 'OpenAI', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
])
|
||||
listed[0]!.displayName = 'mutated'
|
||||
;(listed[1]!.settingsPath as string[]).push('mutated')
|
||||
expect(ctx.llm.listConfigurableProviders()[0]!.displayName).toBe('DeepSeek')
|
||||
expect(ctx.llm.listConfigurableProviders()[1]!.settingsPath).toEqual(['providers', 'openai'])
|
||||
})
|
||||
|
||||
it('detaches stored entries from caller-owned objects', async () => {
|
||||
const ctx = await setup()
|
||||
const source = entry()
|
||||
ctx.llm.registerConfigurableProviders([source])
|
||||
source.displayName = 'mutated'
|
||||
expect(ctx.llm.listConfigurableProviders()[0]!.displayName).toBe('OpenAI')
|
||||
})
|
||||
|
||||
it('withdraws every entry when the registration disposes', async () => {
|
||||
const ctx = await setup()
|
||||
const dispose = ctx.llm.registerConfigurableProviders([entry()])
|
||||
const events = vi.fn()
|
||||
ctx.on('llm/adapters-updated', events)
|
||||
dispose()
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
expect(events).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('withdraws entries when the contributing fiber disposes', async () => {
|
||||
const ctx = await setup()
|
||||
const fiber = await ctx.plugin({
|
||||
inject: ['llm'],
|
||||
apply: (child: Context) => {
|
||||
child.llm.registerConfigurableProviders([entry()])
|
||||
},
|
||||
})
|
||||
expect(ctx.llm.listConfigurableProviders()).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an empty registration', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.llm.registerConfigurableProviders([])).toThrow(LlmError)
|
||||
expect(() => ctx.llm.registerConfigurableProviders([])).toThrow(/at least one provider/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[entry({ provider: '' }), /non-empty provider/],
|
||||
[entry({ displayName: '' }), /non-empty provider/],
|
||||
[entry({ settingsNs: '' }), /non-empty provider/],
|
||||
[entry({ settingsPath: ['providers', ''] }), /empty settingsPath segment/],
|
||||
])('rejects invalid entries all-or-nothing', async (invalid, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.llm.registerConfigurableProviders([entry({ provider: 'valid-first' }), invalid])).toThrow(message)
|
||||
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicates within one registration and across registrations', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.llm.registerConfigurableProviders([entry(), entry()])).toThrow(/already declared/)
|
||||
ctx.llm.registerConfigurableProviders([entry()])
|
||||
expect(() => ctx.llm.registerConfigurableProviders([entry({ displayName: 'Other' }), entry({ provider: 'unseen' })]))
|
||||
.toThrow(/already declared/)
|
||||
expect(ctx.llm.listConfigurableProviders()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user