diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml index c3d5b45684..e9adb2cd8b 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml @@ -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 -2026-07-24-adapter-owned-reasoning-effort-capabilities.md: ffd633a3876c96f312fb274159f3f8422f8811ab -2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: 05c9941ad9c0906ab5400b5a215b92381fba3119 +2026-07-24-adapter-owned-reasoning-effort-capabilities.md: cc66e4ec151fcc04445a91f4a3527cbddd130c33 +2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: e0d28e1aca370068478e8fb1704defeaac3ab351 diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md index ffd633a387..cc66e4ec15 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md @@ -14,7 +14,7 @@ Reasoning strength was adapter configuration only, so a conversation could not d `LlmCallConfig` and `GenerateOptions` carry the optional effort. The agent loop prepares the post-`agent/request` config under the active turn signal before writing `request/header`, so defaults and dynamic changes are model-visible only after becoming durable facts. The prepared call retains the exact adapter registration across asynchronous exact-model resolution, durable header logging, and dispatch; direct `LlmService.stream()` calls likewise capture their final registration before awaiting resolution. A route with no registered adapter retains its proposed config so an `llm/stream` middleware can own and short-circuit it; terminal dispatch still rejects an unhandled route. A resumed loop retains the logged effort only when its initial provider/model route is unchanged; a route change discards the previous model's opaque id. -The native DeepSeek adapter advertises `high` and `max`, defaults to configured effort or `high`, and exposes no effort capability while thinking is disabled. The pi-ai adapter publishes each exact model's `getSupportedThinkingLevels()` result unchanged, including `off`, preserves an absent profile default as a provider default, and leaves provider wire-value mapping inside pi-ai. Its common stream options represent `off` by omitting `reasoning`, as required by pi-ai's own API. +The native DeepSeek adapter advertises `off`, `high`, and `max` when deployment policy permits thinking, and defaults to the configured effort or `high`. Its adapter-owned `off` maps to `thinking.type: disabled` with no `reasoning_effort`; `high` and `max` enable thinking and carry their official wire effort. A `thinking: disabled` deployment publishes only `off` and rejects attempts to enable thinking before provider I/O. The pi-ai adapter publishes each exact model's `getSupportedThinkingLevels()` result unchanged, including `off`, preserves an absent profile default as a provider default, and leaves provider wire-value mapping inside pi-ai. Its common stream options represent `off` by omitting `reasoning`, as required by pi-ai's own API. ## Alternatives considered @@ -28,6 +28,6 @@ The native DeepSeek adapter advertises `high` and `max`, defaults to configured ## Consequences -Clients can query one exact route once and render its identity, context capacity, and adapter-owned reasoning choices without knowing a global enum. Adapter configuration remains the deployment-default owner, while `agent/request` can replace the effective effort on each step. Invalid exact identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`; unsupported explicit or configured values fail with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. +Clients can query one exact route once and render its identity, context capacity, and adapter-owned reasoning choices without knowing a global enum or synthesizing `off`. Adapter configuration remains the deployment-default and policy owner, while `agent/request` can replace the effective effort on each step within that policy. Invalid exact identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`; unsupported explicit or configured values fail with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. The aggregate exact-model query is asynchronous and may fail for adapters backed by authoritative catalogs. Its optional signal is the caller's cancellation boundary; an asynchronous adapter must settle promptly after abort so loop disposal can reach quiescence. Keyless service, adapter, loop, session, and request-header tests pin validation, defaulting, dynamic changes, logging, resume behavior, HMR registration ownership, and cancellation; runnable snapshots pin the resolved effort in real assembled request headers, while key-gated adapter tests exercise provider serialization. diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md index 05c9941ad9..e0d28e1aca 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md @@ -14,7 +14,7 @@ Status: implemented `LlmCallConfig` 和 `GenerateOptions` 携带可选的推理强度。agent loop(智能体循环)在活跃轮次信号的控制下准备 `agent/request` 处理完成后的配置,再写入 `request/header`,因此默认值和动态变更只有成为持久化事实后才对模型可见。准备完成的调用在异步确切模型解析、请求头持久记录和分派全程保留同一项确切的适配器注册;直接调用 `LlmService.stream()` 时,也会在等待解析前捕获最终的适配器注册。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 中间件可以接管并短路该请求;若仍未得到处理,最终分发会拒绝该路由。恢复后的主循环仅在初始提供方/模型路由未变时保留日志中记录的推理强度;如果路由发生变化,则丢弃上一模型的不透明 ID。 -原生 DeepSeek 适配器声明 `high` 和 `max`,默认使用配置指定的推理强度,若未配置则使用 `high`;禁用思考时不暴露推理强度能力。pi-ai 适配器原样发布每个确切模型的 `getSupportedThinkingLevels()` 结果,其中包括 `off`;profile 未指定默认值时保留提供方默认行为,并将提供方协议值的映射留在 pi-ai 内部。按照 pi-ai 自身 API 的要求,其通用流选项通过省略 `reasoning` 来表示 `off`。 +当部署策略允许思考时,原生 DeepSeek 适配器声明 `off`、`high` 和 `max`,默认使用配置指定的推理强度,若未配置则使用 `high`。由适配器持有的 `off` 映射为 `thinking.type: disabled`,且不带 `reasoning_effort`;`high` 和 `max` 会启用思考并携带各自的官方协议强度值。配置为 `thinking: disabled` 的部署仅声明 `off`,并会在提供方 I/O 前拒绝启用思考的尝试。pi-ai 适配器原样发布每个确切模型的 `getSupportedThinkingLevels()` 结果,其中包括 `off`;profile 未指定默认值时保留提供方默认行为,并将提供方协议值的映射留在 pi-ai 内部。按照 pi-ai 自身 API 的要求,其通用流选项通过省略 `reasoning` 来表示 `off`。 ## 备选方案 @@ -28,6 +28,6 @@ Status: implemented ## 影响 -客户端只需查询一次确切路由,即可渲染其身份、上下文容量和由适配器持有的推理选项,而无需了解全局枚举。适配器配置仍负责提供部署默认值,`agent/request` 则可以在每个步骤替换实际生效的推理强度。确切身份、上下文或推理元数据无效时,分别抛出 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING`;显式指定或配置指定的值不受支持时,会在提供方 I/O 前抛出 `UNSUPPORTED_REASONING_EFFORT`。 +客户端只需查询一次确切路由,即可渲染其身份、上下文容量和由适配器持有的推理选项,而无需了解全局枚举或自行合成 `off`。适配器配置仍是部署默认值和策略的归属方,`agent/request` 则可以在该策略范围内为每个步骤替换实际生效的推理强度。确切身份、上下文或推理元数据无效时,分别抛出 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING`;显式指定或配置指定的值不受支持时,会在提供方 I/O 前抛出 `UNSUPPORTED_REASONING_EFFORT`。 确切模型元数据的聚合查询采用异步方式,并且对于由权威目录支持的适配器可能失败。可选信号构成调用方的取消边界;异步适配器必须在信号中止后迅速完成结算,使主循环的资源释放达到完全停稳。无密钥的服务、适配器、主循环、会话和请求头测试为校验、默认值解析、动态变更、日志记录、恢复行为、HMR(热模块替换)期间的注册所有权和取消提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a96858c3e6..cc269754bf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -574,10 +574,10 @@ export interface Config { apiKey?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Thinking-mode default for every request (provider default: enabled). */ + /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Default thinking effort when thinking is enabled (default `high`). */ - reasoningEffort?: 'high' | 'max' + /** 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. */ defaultContextWindow?: number /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml index ca8c27ddae..ebd3cf07a8 100644 --- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -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 -adding-an-llm-adapter.md: 255dce40e5c10a4c94e34b358ad71f0283c1227f -adding-an-llm-adapter.zh.md: 67d85f96326474f6acb1b604b2ed31eba18eab4f +adding-an-llm-adapter.md: 659fdab30e753c9a7acf9e06f85ebfc83480ec50 +adding-an-llm-adapter.zh.md: 5e2a84c82f9a8025c951b1bb2b44ba8b8950f5e3 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index 255dce40e5..659fdab30e 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -32,7 +32,7 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl - A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it. - If the provider requires response ids, signatures, or other native metadata on follow-up calls, emit the minimal lossless-JSON projection as `finish.replayState`. Validate it when rebuilding history. `LlmService` passes it only when the historical provider route and target provider route are currently owned by the exact same adapter instance; your adapter decides whether same-model, cross-model, or cross-provider restoration is legal. Never infer native replay from provider/model names alone when state is absent. -Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list—including `off` when its upstream capability API returns it—without exposing final wire spellings or clamping unsupported values. +Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list, including an adapter-defined `off` when supported, without exposing final wire spellings or clamping unsupported values; an id need not equal its wire representation. ## Structure that worked diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md index 67d85f9632..5e2a84c82f 100644 --- a/docs/cookbook/adding-an-llm-adapter.zh.md +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config) { - 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。 - 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。 -提供方特有的 thinking 模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;响应传给解析器的可选 `AbortSignal`。推理强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 `off`,但不要暴露最终协议值的具体拼写,也不要自动调整不支持的值。 +提供方特有的 thinking 模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;响应传给解析器的可选 `AbortSignal`。推理强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。 ## 经验证有效的结构 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index a92c48463d..9cfc69ee0b 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -15,7 +15,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled - reasoningEffort: high # optional; high | max — omitted ⇒ high + reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro @@ -30,9 +30,9 @@ The plugin registers the single provider route `deepseek`. A request selects it `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')`. -The same exact-model result exposes ordered `high` and `max` efforts under `reasoning` for every pass-through model while thinking is enabled. `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` and serialized as the official top-level `reasoning_effort` field. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. +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. -`thinking: disabled` removes the reasoning capability and omits `reasoning_effort`; combining it with a configured default fails plugin loading, and a per-request effort fails as unsupported. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. +`thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. `streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. @@ -43,6 +43,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Wire-format notes (verified live + against the official docs) - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`. +- The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. @@ -53,7 +54,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. ## Model Experience diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 3f6d50dbb8..ca031b8c1e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -51,12 +51,17 @@ export interface DeepSeekAdapterOptions { /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' +const OFF_REASONING_EFFORT = ReasoningEffortId('off') const HIGH_REASONING_EFFORT = ReasoningEffortId('high') const MAX_REASONING_EFFORT = ReasoningEffortId('max') const REASONING_EFFORTS = [ + { id: OFF_REASONING_EFFORT, name: 'Off' }, { id: HIGH_REASONING_EFFORT, name: 'High' }, { id: MAX_REASONING_EFFORT, name: 'Max' }, ] as const +const OFF_ONLY_REASONING_EFFORTS = [ + { id: OFF_REASONING_EFFORT, name: 'Off' }, +] as const function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo { return { @@ -113,8 +118,10 @@ export class DeepSeekAdapter extends LlmAdapter { constructor(private readonly options: DeepSeekAdapterOptions) { super() - if (options.defaults?.thinking === 'disabled' && options.defaults.reasoningEffort !== undefined) { - throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled') + if (options.defaults?.thinking === 'disabled' + && options.defaults.reasoningEffort !== undefined + && options.defaults.reasoningEffort !== 'off') { + throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') } if (options.defaultContextWindow !== undefined && (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) { @@ -152,13 +159,20 @@ export class DeepSeekAdapter extends LlmAdapter { : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, ...this.options.defaults?.thinking === 'disabled' - ? {} + ? { + reasoning: { + efforts: OFF_ONLY_REASONING_EFFORTS, + defaultEffort: OFF_REASONING_EFFORT, + }, + } : { reasoning: { efforts: REASONING_EFFORTS, - defaultEffort: this.options.defaults?.reasoningEffort === 'max' - ? MAX_REASONING_EFFORT - : HIGH_REASONING_EFFORT, + defaultEffort: this.options.defaults?.reasoningEffort === 'off' + ? OFF_REASONING_EFFORT + : this.options.defaults?.reasoningEffort === 'max' + ? MAX_REASONING_EFFORT + : HIGH_REASONING_EFFORT, }, }, }) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 1dad768440..b5468f4bdb 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -37,10 +37,10 @@ export interface Config { apiKey?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Thinking-mode default for every request (provider default: enabled). */ + /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Default thinking effort when thinking is enabled (default `high`). */ - reasoningEffort?: 'high' | 'max' + /** 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. */ defaultContextWindow?: number /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ @@ -60,7 +60,7 @@ export const Config: z = z.object({ apiKey: z.string(), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), - reasoningEffort: z.union(['high', 'max']), + reasoningEffort: z.union(['off', 'high', 'max']), defaultContextWindow: z.number().step(1).min(1), 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), @@ -95,8 +95,10 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee } export function apply(ctx: Context, config: Config): void { - if (config.thinking === 'disabled' && config.reasoningEffort !== undefined) { - throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled') + if (config.thinking === 'disabled' + && config.reasoningEffort !== undefined + && config.reasoningEffort !== 'off') { + throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') } const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY if (apiKey === undefined || apiKey.length === 0) { diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 70d51c8549..fb6b8d9117 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -13,20 +13,44 @@ import type { WireMessage, WireRequest, WireTool } from './types.ts' /** Adapter-level request defaults (from plugin config). */ export interface RequestDefaults { thinking?: 'enabled' | 'disabled' | undefined - reasoningEffort?: 'high' | 'max' | undefined + reasoningEffort?: 'off' | 'high' | 'max' | undefined } -/** Validate the adapter-owned effort before assigning its narrower wire type. */ -function reasoningEffort(options: GenerateOptions): 'high' | 'max' | undefined { - const effort = options.reasoningEffort - if (effort === undefined) return undefined - if (effort === 'high' || effort === 'max') return effort as 'high' | 'max' +interface ResolvedThinking { + thinking?: 'enabled' | 'disabled' + reasoningEffort?: 'high' | 'max' +} + +/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */ +function reasoningEffort(effort: NonNullable): 'off' | 'high' | 'max' { + if (effort === 'off' || effort === 'high' || effort === 'max') { + return effort as 'off' | 'high' | 'max' + } throw new LlmError( `DeepSeek does not support reasoning effort "${effort}"`, 'UNSUPPORTED_REASONING_EFFORT', ) } +/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */ +function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking { + if (options.purpose === 'session-title') return { thinking: 'disabled' } + const effort = options.reasoningEffort === undefined + ? defaults.reasoningEffort + : reasoningEffort(options.reasoningEffort) + if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') { + throw new LlmError( + `DeepSeek deployment does not support reasoning effort "${effort}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) + } + if (effort === 'off') return { thinking: 'disabled' } + if (effort === 'high' || effort === 'max') { + return { thinking: 'enabled', reasoningEffort: effort } + } + return defaults.thinking === undefined ? {} : { thinking: defaults.thinking } +} + /** Join the text blocks of a message (used for user/tool-result content). */ function flattenText(blocks: ContentBlock[]): string { return blocks @@ -132,18 +156,17 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa })) // A short title budget must produce visible text; conversation and // compaction calls continue to inherit the adapter's thinking defaults. - const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking - const resolvedReasoningEffort = options.purpose === 'session-title' - ? undefined - : reasoningEffort(options) + const resolvedThinking = resolveThinking(options, defaults) return { model: options.model, messages, stream: true, stream_options: { include_usage: true }, - ...thinking !== undefined ? { thinking: { type: thinking } } : {}, - ...resolvedReasoningEffort !== undefined ? { reasoning_effort: resolvedReasoningEffort } : {}, + ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, + ...resolvedThinking.reasoningEffort !== undefined + ? { reasoning_effort: resolvedThinking.reasoningEffort } + : {}, ...tools !== undefined && tools.length > 0 ? { tools } : {}, ...options.temperature !== undefined ? { temperature: options.temperature } : {}, ...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}, diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 8897d500d5..742a69d20a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -50,31 +50,29 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { - it('flash + thinking disabled: plain text generation', async () => { - const ctx = await harness(FLASH, { thinking: 'disabled' }) - const result = await assemble(ctx,{ + it('flash dynamically switches from off to high', async () => { + const ctx = await harness(FLASH, { reasoningEffort: 'off' }) + const withoutThinking = await assemble(ctx,{ model: FLASH, messages: ask('Reply with exactly the word: pong'), maxTokens: 50, }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('pong') - expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false) - expect(result.usage?.inputTokens).toBeGreaterThan(0) - expect(result.usage?.outputTokens).toBeGreaterThan(0) - }) + expect(withoutThinking.finish.kind).toBe('stop') + expect(textOf(withoutThinking).toLowerCase()).toContain('pong') + expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false) + expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0) + expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0) - it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => { - const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' }) - const result = await assemble(ctx,{ + const withThinking = await assemble(ctx,{ model: FLASH, + reasoningEffort: ReasoningEffortId('high'), messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), maxTokens: 2000, }) - expect(result.finish.kind).toBe('stop') - expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true) - expect(textOf(result)).toContain('9.8') - expect(result.usage?.reasoningTokens).toBeGreaterThan(0) + expect(withThinking.finish.kind).toBe('stop') + expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true) + expect(textOf(withThinking)).toContain('9.8') + expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) }) it.each(['high', 'max'] as const)( diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9ac66ce5d1..341d563b1b 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -175,12 +175,13 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') }) - it('forwards the configured reasoning default and a dynamic request override', async () => { + it('switches dynamically from the configured high default through off to max', async () => { const server = await mockServer([ { kind: 'sse', events: textEvents }, { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, ]) - const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'max' }) + const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'high' }) await assemble(ctx,{ model: 'deepseek-v4-flash', @@ -188,20 +189,29 @@ describe('DeepSeekAdapter against a mock server', () => { }) await assemble(ctx,{ model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('high'), + reasoningEffort: ReasoningEffortId('off'), messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }], }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('max'), + messages: [{ role: 'user', content: [{ type: 'text', text: 'one more time' }] }], + }) expect(server.requests[0]).toMatchObject({ thinking: { type: 'enabled' }, - reasoning_effort: 'max', + reasoning_effort: 'high', }) expect(server.requests[1]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[1]).not.toHaveProperty('reasoning_effort') + expect(server.requests[2]).toMatchObject({ thinking: { type: 'enabled' }, - reasoning_effort: 'high', + reasoning_effort: 'max', }) }) - it('omits reasoning capability and effort when thinking is disabled', async () => { + 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' }) @@ -214,7 +224,12 @@ describe('DeepSeekAdapter against a mock server', () => { }) expect(server.requests[0]).not.toHaveProperty('reasoning_effort') await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) - .resolves.not.toHaveProperty('reasoning') + .resolves.toMatchObject({ + reasoning: { + efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + defaultEffort: ReasoningEffortId('off'), + }, + }) }) it('rejects a per-request effort before I/O when thinking is disabled', async () => { @@ -229,6 +244,29 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.requests).toHaveLength(0) }) + it.each(['high', 'max'])( + 'rejects direct adapter effort %s before I/O when thinking is disabled', + async (effort) => { + const server = await mockServer([]) + const adapter = new DeepSeekAdapter({ + apiKey: 'test-key', + baseURL: server.url, + defaults: { thinking: 'disabled' }, + }) + + const stream = adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId(effort), + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }) + await expect(async () => { + for await (const _chunk of stream) { /* drain */ } + }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(0) + }, + ) + it.each([ [401, 'AUTH'], [403, 'AUTH'], @@ -581,6 +619,7 @@ describe('plugin registration and config', () => { context: { contextWindow: 128_000 }, reasoning: { efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, { id: ReasoningEffortId('high'), name: 'High' }, { id: ReasoningEffortId('max'), name: 'Max' }, ], @@ -589,24 +628,83 @@ describe('plugin registration and config', () => { }) }) - it('rejects a configured reasoning effort when thinking is disabled', async () => { + it.each(['off', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmDeepSeek, { + await ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + reasoningEffort: effort, + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId(effort), + }, + }) + }) + + it('accepts off as the default when thinking is deployment-disabled', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1', thinking: 'disabled', - reasoningEffort: 'high', - })).rejects.toThrow(/reasoningEffort cannot be configured/) - expect(ctx.llm.listProviders()).toEqual([]) + reasoningEffort: 'off', + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + defaultEffort: ReasoningEffortId('off'), + }, + }) }) - it('rejects a disabled-thinking effort at the direct constructor boundary', () => { - expect(() => new DeepSeekAdapter({ + it.each(['high', 'max'] as const)( + 'rejects configured reasoning effort %s when thinking is disabled', + async (reasoningEffort) => { + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort, + })).rejects.toThrow(/only reasoningEffort "off"/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each(['high', 'max'] as const)( + 'rejects disabled-thinking effort %s at the direct constructor boundary', + (reasoningEffort) => { + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + defaults: { thinking: 'disabled', reasoningEffort }, + })).toThrow(/only reasoningEffort "off"/) + }, + ) + + it('accepts disabled thinking with off at the direct constructor boundary', async () => { + const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1', - defaults: { thinking: 'disabled', reasoningEffort: 'high' }, - })).toThrow(/reasoningEffort cannot be configured/) + defaults: { thinking: 'disabled', reasoningEffort: 'off' }, + }) + await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({ + reasoning: { + efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + defaultEffort: ReasoningEffortId('off'), + }, + }) }) it('uses the default model catalog when apply is called directly', async () => { diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 0f294a5e3a..213e910c3f 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -183,6 +183,31 @@ describe('serializeRequest', () => { expect(wire.reasoning_effort).toBe('max') }) + it('maps off to disabled thinking without a wire reasoning effort', () => { + const wire = serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('off') }), + { thinking: 'enabled', reasoningEffort: 'max' }, + ) + expect(wire.thinking).toEqual({ type: 'disabled' }) + expect(wire.reasoning_effort).toBeUndefined() + }) + + it('re-enables thinking when max overrides an off default', () => { + const wire = serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('max') }), + { reasoningEffort: 'off' }, + ) + expect(wire.thinking).toEqual({ type: 'enabled' }) + expect(wire.reasoning_effort).toBe('max') + }) + + it('rejects enabling thinking when the deployment is locked to disabled', () => { + expect(() => serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('high') }), + { thinking: 'disabled' }, + )).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) + }) + it('disables thinking for session-title requests without changing adapter defaults', () => { const wire = serializeRequest( request({ @@ -202,6 +227,12 @@ describe('serializeRequest', () => { expect(wire.reasoning_effort).toBeUndefined() }) + it('preserves an explicit enabled default without inventing a wire effort', () => { + const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' }) + expect(wire.thinking).toEqual({ type: 'enabled' }) + expect(wire.reasoning_effort).toBeUndefined() + }) + it('rejects an effort outside the DeepSeek capability', () => { expect(() => serializeRequest(request({ messages: history,