mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge commit '0c177e17a23692d018a79bae59ac9a2db6eba59c' into codex/product-subagent-runtime-refresh-codex
This commit is contained in:
+2
-2
@@ -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 .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 8bfd6654e449bb762b153a1b0b950a504cf25a4b
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: f59126f70fb0b1a88ff874840a49993d4dc2f988
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: e1e0d044089d3c7aacdbe98e8556669f28d552c5
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 1d1b5bf5b8ae4d5403eee4d8e98c2ad9b8d0c861
|
||||
|
||||
+6
-6
@@ -12,20 +12,20 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-sdk-jsonrpc-server`, the [
|
||||
|
||||
Three packages, layered exactly like the existing Python stack, plus one Service Provider registration:
|
||||
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
|
||||
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `RunResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. The launch interface resolves the same-version `@deepseek-ai/dsh` dependency and selects a named profile, with optional `dshBin`, ordered patches, an explicit Harness home, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. A clean checkout without `lib/bin.js` uses that package's source entry through an absolute `tsx/esm` loader and an internal patch that omits build-generated Typert contribution loading, which the SDK protocol does not consume. `env` replaces rather than merges and is read when `start()` spawns, so callers own credential policy and can finish preparing it before first use. `RunResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, `provider`/`model` feeds the child's `initialize`, and `env` supplies explicit child-only values such as its API key.
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` lives here, and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. `InitializeParams` carries provider, model, optional adapter-owned reasoning effort, and optional output cap. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. Error responses reject with `JsonRpcResponseError` carrying the wire `code`/`data`, matching the Python client.
|
||||
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its owned activity). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `RunResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. The launch interface resolves the same-version `@deepseek-ai/dsh` dependency and selects a named profile, with optional `dshBin`, ordered patches, an explicit Harness home, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. `initialize` carries provider, model, optional reasoning effort, and optional output cap. A clean checkout without `lib/bin.js` uses that package's source entry through an absolute `tsx/esm` loader and an internal patch that omits build-generated Typert contribution loading, which the SDK protocol does not consume. `env` replaces rather than merges and is read when `start()` spawns, so callers own credential policy and can finish preparing it before first use. Teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit because the client runs outside any harness context.
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling but advertising `agentOptions: true`: each run merges provider/model/reasoning/maxTokens over instance defaults and sends only those fields through the child `initialize`. Other start capabilities remain false, and `inheritsParentContext: false`. The provider retains the same publish-after-handshake ownership transaction, result-never-rejects flattening through an `onError` sink, and parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, while `env` supplies explicit child-only values such as its API key.
|
||||
- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.
|
||||
|
||||
`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical). TypeScript and Python clients both consume the shared protocol through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree.
|
||||
`dsh-sdk-jsonrpc-server` validates the exact provider/model/effort route during `initialize`, stores only explicitly supplied effort and token values, and creates every SDK root Agent from that fixed process-wide route. Because JSON-RPC requests can dispatch concurrently, it rejects `session/prompt` until one initialization has completed successfully, preventing pending or invalid routes from falling back to constructor defaults. TypeScript and Python clients both expose the same initialization fields through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree.
|
||||
|
||||
## Testing
|
||||
|
||||
Four tiers, per [testing policy](../../../../docs/testing.md):
|
||||
|
||||
- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages.
|
||||
- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots its package-owned test composition (`packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/`), where the child is a real second `dsh --profile sdk` runtime with its own isolated home and ordered patch; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd.
|
||||
- **Keyless snapshot** — `snapshots/sdk/sdk.snapshot.ts` drives the real `dsh --profile sdk` runtime through the real `dsh-sdk-client`, replaying recorded fixtures through an ordered `llm-replay` patch. Each scenario pins the normalized notification stream, SDK turn result, and persisted parent and child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side.
|
||||
- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots its package-owned test composition (`packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/`), where the child is a real second `dsh --profile sdk` runtime with its own isolated home and ordered patch; the tool result and the child's persisted request header both prove provider, model, reasoning effort, maxTokens, and parent-session cwd.
|
||||
- **Keyless snapshot** — `snapshots/sdk/sdk.snapshot.ts` drives the real `dsh --profile sdk` runtime through the real `dsh-sdk-client`, replaying recorded fixtures through ordered `llm-replay` patches. The DSH SDK scenario uses deterministic parent and child adapters to pin a model-selected route through the delegation tool, a second SDK runtime, and the child's persisted request header; every scenario pins the normalized notification stream, SDK turn result, and applicable parent and child logs.
|
||||
- **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
+6
-6
@@ -12,20 +12,20 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
|
||||
|
||||
三个包,分层与既有 Python 栈完全一致,外加一个 Service Provider 注册:
|
||||
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
|
||||
- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`RunResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。启动接口解析同版本 `@deepseek-ai/dsh` 依赖并选择具名 profile,可选配置包括 `dshBin`、有序 patch、显式 Harness home、进程 cwd、环境和超时;任意 command/argv 启动只作为内部 fake-runtime 适配器。干净 checkout 中若不存在 `lib/bin.js`,client 会通过绝对 `tsx/esm` loader 使用该包的源码入口,并应用一个省略构建期生成 Typert 贡献加载的内部 patch;SDK 协议不消费这些贡献。`env` 整体替换而非合并,并在 `start()` spawn 时读取,因此凭据策略归调用方,且调用方可在首次使用前完成环境准备。`RunResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(client 运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`/profile/patch/home 配置选择隔离的 SDK 应用,`provider`/`model` 写入子进程 `initialize`,`env` 则提供子进程专用的显式值,例如其 API key。
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把协议格式做成共享且具名。`JsonRpcLineTransport` 位于此处,`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。`InitializeParams` 携带提供方、模型、可选且由适配器持有的推理强度,以及可选输出上限。该包根显式导出完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。错误响应以携带协议 `code`/`data` 的 `JsonRpcResponseError` 拒绝,与 Python 客户端一致。
|
||||
- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 持有一次完整活动区间)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`RunResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。启动接口解析同版本 `@deepseek-ai/dsh` 依赖并选择具名 profile,可选配置包括 `dshBin`、有序 patch、显式 Harness home、进程 cwd、环境和超时;任意 command/argv 启动只作为内部 fake-runtime 适配器。`initialize` 携带提供方、模型、可选推理强度与可选输出上限。干净 checkout 中若不存在 `lib/bin.js`,client 会通过绝对 `tsx/esm` loader 使用该包的源码入口,并应用一个省略构建期生成 Typert 贡献加载的内部 patch;SDK 协议不消费这些贡献。`env` 整体替换而非合并,并在 `start()` spawn 时读取,因此凭据策略归调用方,且调用方可在首次使用前完成环境准备。拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出,因为 client 运行在任何 harness 上下文之外。
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构,但声明 `agentOptions: true`:每次运行都会把提供方/模型/推理强度/maxTokens 合并到实例默认值之上,并且只把这些字段送入子进程 `initialize`。其他启动能力保持 false,`inheritsParentContext: false`。提供方保留握手后发布所有权事务、通过 `onError` sink 将结果归一为绝不拒绝,以及父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`/profile/patch/home 配置选择隔离的 SDK 应用,`env` 则提供子进程专用的显式值,例如其 API key。
|
||||
- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。
|
||||
|
||||
`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致)。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 消费共享协议;Python wheel 会打包该 CLI 及其封闭依赖树。
|
||||
`dsh-sdk-jsonrpc-server` 会在 `initialize` 期间校验确切的提供方/模型/推理强度路由,只保存显式提供的推理强度与 token 值,并使用这条固定的进程级路由创建每个 SDK 根 Agent。由于 JSON-RPC 请求可能并发分派,它会在一次初始化成功完成前拒绝 `session/prompt`,避免待定或非法路由回退到构造期默认值。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 公开同一组初始化字段;Python wheel 会打包该 CLI 及其封闭依赖树。
|
||||
|
||||
## 测试
|
||||
|
||||
四层,依[测试政策](../../../../docs/testing.zh.md):
|
||||
|
||||
- **免密钥单元**——`sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实提供方驱动同一伪运行时。三个包全部 100% 逐文件覆盖。
|
||||
- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动包自有测试组合(`packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/`),其中子进程是真实的第二个 `dsh --profile sdk` 运行时,拥有独立 home 与有序 patch;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。
|
||||
- **免密钥快照**——`snapshots/sdk/sdk.snapshot.ts` 通过真实 `dsh-sdk-client` 驱动真实 `dsh --profile sdk` 运行时,并通过有序 `llm-replay` patch 回放已录制 fixture(测试前置数据)。每个场景都钉住规范化通知流、SDK 轮次结果,以及持久化的父日志与子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。
|
||||
- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动包自有测试组合(`packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/`),其中子进程是真实的第二个 `dsh --profile sdk` 运行时,拥有独立 home 与有序 patch;工具结果与子进程持久化请求 header 都会证明提供方、模型、推理强度、maxTokens 与父会话 cwd。
|
||||
- **免密钥快照**——`snapshots/sdk/sdk.snapshot.ts` 通过真实 `dsh-sdk-client` 驱动真实 `dsh --profile sdk` 运行时,并通过有序 `llm-replay` patch 回放已录制 fixture(测试前置数据)。DSH SDK 场景使用确定性的父级和子级适配器,把模型选择的路由固定在委派工具、第二个 SDK 运行时及子级持久化请求 header 中;每个场景都钉住规范化通知流、SDK 轮次结果和适用的父子日志。
|
||||
- **带密钥 e2e**——快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -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 .agents/notes/implemented/feature/2026-07-28-sdk-max-output-tokens.md
|
||||
2026-07-28-sdk-max-output-tokens.md: 72a9e87484ca87e0a750f52d7e46db7aee436d21
|
||||
2026-07-28-sdk-max-output-tokens.zh.md: 820008ec7293cfee20c0a9c26037f746c9e081c2
|
||||
2026-07-28-sdk-max-output-tokens.md: 1d2915b7f7169b0784c648aad5900a85fac4c977
|
||||
2026-07-28-sdk-max-output-tokens.zh.md: ba59f745bc921d3cc0d5c01f83808dd495220bc7
|
||||
|
||||
@@ -14,7 +14,7 @@ The high-level SDKs expose one optional process-wide output cap: Python names it
|
||||
|
||||
Each SDK-created root Agent receives the cap through `AgentOptions.maxTokens`. Agent Loop places that value in the initial `LlmCallConfig`; final call preparation preserves the explicit value or materializes an exact-model adapter default, logs the effective cap in the request header, and reconstructs every dispatched conversation request from that durable header. Omitting the SDK option therefore allows the selected adapter or provider route default to apply.
|
||||
|
||||
In-process subagents inherit the parent's provider, model, and output cap. An explicit `SubagentStartRequest.agentOptions.maxTokens`, including one configured by `dsh-tool-subagent`, overrides the inherited value for that child and its descendants. Out-of-process providers own the configuration of their separate runtime; `subagent-dsh-sdk` therefore exposes its own optional `maxTokens` and forwards it through that child runtime's SDK handshake.
|
||||
In-process subagents inherit the parent's provider, model, and output cap. An explicit `SubagentStartRequest.agentOptions.maxTokens`, including one configured by `dsh-tool-subagent`, overrides the inherited value for that child and its descendants. `subagent-dsh-sdk` owns a separate runtime per run: request `maxTokens` overrides its optional instance default, and the resolved cap crosses that child runtime's SDK handshake.
|
||||
|
||||
Compaction, session-title generation, web search, and other auxiliary calls keep their independently owned output limits. `maxTokensAsSuccess` remains outcome mapping only: it does not set or alter the cap.
|
||||
|
||||
@@ -30,4 +30,4 @@ Compaction, session-title generation, web search, and other auxiliary calls keep
|
||||
|
||||
SDK callers can bound model output without editing Cordis composition, and direct Agent creation uses the same validated `AgentOptions` contract. The cap is visible in durable request headers and reaches provider adapters as `GenerateOptions.maxTokens`; DeepSeek serialization maps it to `max_tokens`.
|
||||
|
||||
One SDK runtime has one default cap. A caller needing different caps runs separate runtime instances or explicitly overrides an in-process child through its agent options. Reaching the cap still produces the existing `max-tokens` stop reason, whose `ok` or `error` mapping remains deployment policy.
|
||||
One SDK runtime has one default cap. A caller needing different caps runs separate runtime instances or uses a subagent provider that advertises `agentOptions`; DSH SDK naturally creates one such runtime per child run. Reaching the cap still produces the existing `max-tokens` stop reason, whose `ok` or `error` mapping remains deployment policy.
|
||||
|
||||
@@ -14,7 +14,7 @@ Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话
|
||||
|
||||
每个由 SDK 创建的根 Agent 都通过 `AgentOptions.maxTokens` 获得该上限。agent loop(智能体循环)将它放入初始 `LlmCallConfig`;最终调用准备会保留显式值,或填入确切模型的适配器默认值,再将生效上限记录到请求 header,并从该持久化 header 重建每次分派的对话请求。因此,省略 SDK 选项时会应用所选适配器或提供方路由的默认值。
|
||||
|
||||
进程内 subagent 继承父级的提供方、模型和输出上限。显式的 `SubagentStartRequest.agentOptions.maxTokens`(包括通过 `dsh-tool-subagent` 配置的值)会覆盖该子级及其后代的继承值。进程外提供方自行持有其独立运行时的配置;因此 `subagent-dsh-sdk` 公开独立的可选 `maxTokens`,并通过该子运行时自己的 SDK 握手传入。
|
||||
进程内 subagent 继承父级的提供方、模型和输出上限。显式的 `SubagentStartRequest.agentOptions.maxTokens`(包括通过 `dsh-tool-subagent` 配置的值)会覆盖该子级及其后代的继承值。`subagent-dsh-sdk` 为每次运行持有独立运行时:请求 `maxTokens` 会覆盖可选的实例默认值,解析后的上限再经过该子运行时自己的 SDK 握手。
|
||||
|
||||
压缩、会话标题生成、网页搜索和其他辅助调用继续使用各自持有的独立输出上限。`maxTokensAsSuccess` 仍然只负责结果映射,不会设置或改变上限。
|
||||
|
||||
@@ -30,4 +30,4 @@ Python 与 TypeScript SDK 可以选择提供方和模型,却无法限制对话
|
||||
|
||||
SDK 调用方无需修改 Cordis 组合即可限制模型输出,直接创建 Agent 也使用同一套经过校验的 `AgentOptions` 约定。该上限在持久化请求 header 中可见,并以 `GenerateOptions.maxTokens` 到达提供方适配器;DeepSeek 序列化会将其映射为 `max_tokens`。
|
||||
|
||||
一个 SDK 运行时只有一个默认上限。需要不同上限的调用方应运行独立的运行时实例,或通过 agent options 显式覆盖某个进程内子级。达到上限时仍产生现有的 `max-tokens` 停止原因;将其映射为 `ok` 还是 `error` 仍由部署策略决定。
|
||||
一个 SDK 运行时只有一个默认上限。需要不同上限的调用方应运行独立的运行时实例,或使用声明 `agentOptions` 的 subagent 提供方;DSH SDK 会自然地为每次子级运行创建一个这样的运行时。达到上限时仍产生现有的 `max-tokens` 停止原因;将其映射为 `ok` 还是 `error` 仍由部署策略决定。
|
||||
|
||||
+2
-2
@@ -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 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md
|
||||
2026-08-18-model-selected-subagent-routes.md: 1602e3ac90870edbd0206cd87fdf97ecc34cad41
|
||||
2026-08-18-model-selected-subagent-routes.zh.md: 0dad8b9d030e6de65cb3fa1e0e93ad7c28bcc5c1
|
||||
2026-08-18-model-selected-subagent-routes.md: bf4788b141370933197d9ec1a1ad3c8e76a6740c
|
||||
2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7
|
||||
|
||||
@@ -14,9 +14,9 @@ The model also needs a bounded way to discover live providers and model-owned ef
|
||||
|
||||
`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount.
|
||||
|
||||
Provider and model form one route and must be supplied together. An effort may be supplied alone when configured or parent values provide the effective route. Model arguments override `Config.agentOptions`, and configured fields override the parent Agent's latest logged request selection; creation options supply the fallback before its first request and retain the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort, while changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection.
|
||||
Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection.
|
||||
|
||||
An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` before child creation. That lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. The tool checks cancellation again after the asynchronous lookup and before creating a child or background job. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service.
|
||||
An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service.
|
||||
|
||||
An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id.
|
||||
|
||||
@@ -24,7 +24,7 @@ Shipped `subagent_fork` instances leave `enableModelSelection` disabled even tho
|
||||
|
||||
The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix.
|
||||
|
||||
`SubagentCapabilities.agentOptions` remains the transport truth. The service rejects a request carrying those options before calling a provider that advertises `false`. Both in-process providers advertise `true`; the current ACP, Codex, Claude Code, and DSH SDK transports advertise `false`. Tool configuration that supplies `agentOptions`, statically enables model selection, or makes it settings-controlled also fails when its bound provider lacks the capability.
|
||||
`SubagentCapabilities.agentOptions` remains the transport truth. The service rejects a request carrying those options before calling a provider that advertises `false`. Both in-process providers and the DSH SDK transport advertise `true`; DSH SDK publishes its provider/model defaults as detached immutable data for Consumer preflight, while `start()` independently applies the same Config defaults plus maxTokens for direct callers and child initialization. ACP, Codex, and Claude Code advertise `false`. Tool configuration that supplies `agentOptions`, statically enables model selection, or makes it settings-controlled also fails when its bound provider lacks the capability.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -51,10 +51,10 @@ The delegation definition is static across adapter registration and catalog chan
|
||||
- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields.
|
||||
- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation.
|
||||
- Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse.
|
||||
- Omission retains configured defaults and compatible inheritance from the parent's latest logged request; a route change without an explicit effort uses the selected model's default.
|
||||
- Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default.
|
||||
- Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged.
|
||||
- Out-of-process subagent providers reject configured and model-selected Agent options until they implement and advertise the capability.
|
||||
- Unit coverage owns the default-off Host preference, new-Session sampling, child inheritance, resumed decisions, opt-in schema and execution enforcement, merge precedence, route-aware effort inheritance, preflight cancellation, live discovery, diagnostics, definition stability, capability rejection, and optional-service behavior. A shipped headless snapshot pins inheritance from a logged parent selection; the shipped examples also own the assembled keyless model-visible schemas.
|
||||
- DSH SDK children accept configured and model-selected Agent routes; ACP, Codex, and Claude Code reject them until they implement and advertise the capability.
|
||||
- Unit coverage owns the default-off Host preference, new-Session sampling, child inheritance, resumed decisions, opt-in schema and execution enforcement, merge precedence, route-aware effort inheritance, preflight cancellation, live discovery, diagnostics, definition stability, capability rejection, and optional-service behavior. A shipped headless snapshot pins inheritance from a logged parent selection; the shipped examples own the assembled keyless model-visible schemas, and the SDK Loader and snapshot evidence pin the complete route through a separate child runtime.
|
||||
|
||||
## Related decisions
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ Status: implemented
|
||||
|
||||
只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。
|
||||
|
||||
提供方与模型共同组成一条路由,必须一起提供。如果配置值或父级值能够提供生效路由,则可以只提供推理强度。模型参数覆盖 `Config.agentOptions`,配置字段覆盖父 Agent 最新记录的请求选择;首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。路由不变时会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。
|
||||
提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。
|
||||
|
||||
显式或配置的提供方、模型或强度会在创建子级前通过 `ctx.llm.resolveCallConfig()` 解析。该查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。
|
||||
显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。
|
||||
|
||||
启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。
|
||||
|
||||
@@ -24,7 +24,7 @@ Status: implemented
|
||||
|
||||
委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。
|
||||
|
||||
`SubagentCapabilities.agentOptions` 仍是传输事实。如果请求携带这些选项,而提供方声明为 `false`,服务会在调用提供方前拒绝。两个进程内提供方声明为 `true`;当前 ACP、Codex、Claude Code 与 DSH SDK 传输声明为 `false`。工具配置提供 `agentOptions`、静态启用模型选择或让它受 settings 控制时,如果绑定的提供方缺少该能力,也会失败。
|
||||
`SubagentCapabilities.agentOptions` 仍是传输事实。如果请求携带这些选项,而提供方声明为 `false`,服务会在调用提供方前拒绝。两个进程内提供方与 DSH SDK 传输声明为 `true`;DSH SDK 会把 provider/model 默认值作为分离且不可变的数据公开给 Consumer 预检,而 `start()` 会为直接调用方与子运行时初始化独立应用同一份 Config 默认值及 maxTokens。ACP、Codex 与 Claude Code 声明为 `false`。工具配置提供 `agentOptions`、静态启用模型选择或让它受 settings 控制时,如果绑定的提供方缺少该能力,也会失败。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -51,10 +51,10 @@ Status: implemented
|
||||
- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。
|
||||
- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。
|
||||
- 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。
|
||||
- 省略选择时保留配置默认值,并从父级最新记录的请求中进行兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。
|
||||
- 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。
|
||||
- adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。
|
||||
- 进程外 subagent 提供方在实现并声明该能力前,会拒绝配置和模型选择的 Agent 选项。
|
||||
- 单元测试覆盖默认关闭的 Host 偏好、新 Session 读取、子级继承、恢复决定、选择启用时的 schema 与执行强制、合并优先级、路由相关强度继承、预检取消、实时发现、诊断、定义稳定性、能力拒绝与可选服务行为。随附的 headless 快照固定从父级已记录选择继承的行为;随附示例还覆盖组装后无密钥、模型可见的 schema。
|
||||
- DSH SDK 子级接受配置和模型选择的 Agent 路由;ACP、Codex 与 Claude Code 在实现并声明该能力前仍会拒绝。
|
||||
- 单元测试覆盖默认关闭的 Host 偏好、新 Session 读取、子级继承、恢复决定、选择启用时的 schema 与执行强制、合并优先级、路由相关强度继承、预检取消、实时发现、诊断、定义稳定性、能力拒绝与可选服务行为。随附的 headless 快照固定从父级已记录选择继承的行为;随附示例覆盖组装后无密钥、模型可见的 schema,SDK Loader 与快照证据固定完整路由经过独立子运行时的链路。
|
||||
|
||||
## 相关决策
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.md
|
||||
2026-08-24-route-priced-image-request-pressure.md: 45a29211730474369607ed5fb933f380d640bf27
|
||||
2026-08-24-route-priced-image-request-pressure.zh.md: cf005a3ee343edf5774d554a4ec78cb876703774
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Route-priced image request pressure
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-24-route-priced-image-request-pressure.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The token meter priced an `ImageBlock` as the structural JSON of its durable reference — roughly forty tokens — while a DeepSeek request image costs up to 384 visual tokens, so an image-dense session could carry hundreds of thousands of unbilled estimated tokens. Provider usage anchors only completed requests: the first multimodal request, images added after the anchor, and offload-set changes all fed automatic compaction a pressure figure that was wrong by orders of magnitude, triggering it far too late (context overflow) or, after a route change, too early. The [version-one simplification](../simplification/2026-07-29-simplify-web-image-input-v1.md) had deliberately rejected a provider-neutral tile formula and deferred visual pricing until a provider-aware estimator had a concrete consumer.
|
||||
|
||||
## Decision
|
||||
|
||||
Compaction pressure is now priced by the routed model's own request projection. `LlmAdapter.imageRequestPricing(provider, model)` is an optional synchronous hook returning an `LlmImageRequestPricing` for one exact route, resolved through `ctx.llm.imageRequestPricing()`; the base adapter declares none and unknown providers degrade to `undefined`, never throw. Each ordered image occurrence resolves to an `LlmImageRequestPrice`: the provider's visual tokens for a retained image plus the model-visible text the wire actually carries (request-preview handle, offload placeholder, or text-only substitution), with the text left to the caller's own estimator so no provider fixes a text tokenization.
|
||||
|
||||
The DeepSeek adapter implements the hook from its connection snapshot (`request-pricing.ts`): uncatalogued and text-only models price every occurrence as its `textOnlyImageText` substitution; image-capable models reproduce the serializer's first-stage oldest-first offload through the shared `offloadedImagePrefixCount()`, build handle and placeholder text through the same execution-world access resolution the serializer uses, and price retained images at their `requestImageDimensions` projection with `deepSeekImageTokens()` — a verbatim port of the provider's published v4 vision calculator (14px patches, 3:1 downsampling, 384-token cap, minimum-pixel scale-up, 8:1 width clamp), priced at the worst-case pad-to-4 alignment. The pure geometry moved from `attachment-local` to `dsh-attachment` so provider and pricing share it.
|
||||
|
||||
The token meter's surface fold stores route-neutral facts per node — the fixed-heuristic price, the image-free price, and the durable image occurrences — and `measure()` prices the surface under the effective envelope's route on every call. The anchor holds its raw materials (surface snapshot, provider-output price, usage) instead of a precomputed baseline, so a matching header reprices both the anchor and the current surface under one route and the signed delta compares like with like; the usage-versus-estimated choice happens per measurement against the route-priced anchor. Public `TokenSurfaceNode` carries both `tokens` (route-priced; read by trigger, retention, range selection, and the summary-shrink comparison) and `heuristicTokens` (fixed; the shadow-price protocol's unit, so `compaction/summary` and `compaction/prune` stay consistent with the O(1) projection fold's own appends). The `contextPressure` and `contextBreakdown` projections deliberately stay on the fixed heuristic.
|
||||
|
||||
The test-support replay adapter declares a flat per-model `imageRequestTokens` so keyless assembled scenarios exercise the seam; the `image-compaction` ACP snapshot proves six inline images push the second turn's pre-step measurement over an automatic threshold that the text-only heuristic stays under, and that the triggered compaction shadows the image message at its heuristic price.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Price images inside the provider-neutral estimator.** Rejected by the [version-one note](../simplification/2026-07-29-simplify-web-image-input-v1.md) and still wrong: visual pricing varies by provider, model, detail mode, and preprocessing, and a hard-coded figure would look authoritative on routes it does not describe. The hook keeps every constant in the adapter that owns the route.
|
||||
|
||||
**Correct pressure only from provider usage.** Usage cannot price the first multimodal request, an image added after the anchor, or a changed offload set — exactly the cases that made compaction fire too late. Usage stays the anchor for completed requests; the route projection prices the increment.
|
||||
|
||||
**Reproduce the full serialization pipeline, including prepared-version bytes and the base64 fallback budgets.** The second-stage offload depends on encoded request bytes that only exist after asynchronous image preparation. The pricing reproduces the deterministic first stage from durable byte lengths; a fallback request can only offload more and cost less, so the estimate stays conservative without I/O in a synchronous hook.
|
||||
|
||||
**Route-price the shadow-price protocol too.** Logged `shadowedTokenCount` feeds the O(1) projection fold, whose appends are priced by the fixed heuristic; pricing replacements by route would make the persisted running total drift. Keeping the protocol on `heuristicTokens` preserves the fold's by-construction agreement.
|
||||
|
||||
**Fold route pricing into the meter's replay state.** A fold keyed to one route would have to replay on every route change and could not answer a `requestHeader` override for a different model. Storing route-neutral node facts and pricing at `measure()` keeps replay single-pass and measurement O(surface), which the contract already promises.
|
||||
|
||||
## Consequences
|
||||
|
||||
Automatic compaction now triggers on the pressure the routed model's next request will actually carry: image-dense DeepSeek sessions compact before overflow instead of after it, text-only routes charge substitution text instead of phantom visual tokens, and offloaded images cost their placeholder. The worst-case alignment pad overprices an image by at most three tokens, and the unreproduced base64-fallback budgets can only overprice — both errors are conservative; an execution-world access path that changes between pricing and the request shifts a descriptor's text price by its own length, and provider usage remains the authoritative anchor once a request completes. The published v4 calculator constants live in `llm-deepseek` alone; if the provider revises its vision projection, that one module and its pinned vectors are the change site. Measurement cost gains one pricing resolution and one image-occurrence walk per call, still O(surface).
|
||||
|
||||
## Testing
|
||||
|
||||
Formula vectors in `image-tokens.spec.ts` pin the published calculator's outputs, including the aspect-clamp, scale-up floor, one-column solver, odd-grid trim, and second-pass convergence cases, cross-checked against the reference implementation over a dimension grid and 50,000-point fuzz during development. `request-pricing.spec.ts` covers text-only substitution, the low-detail preset, and count- and byte-driven offload boundaries. Token-meter specs cover the first multimodal estimate, post-anchor image deltas over usage, text-only repricing under a header override, pricer-less neutrality, occurrence-count mismatch, and nested tool-result images. Compaction specs prove trigger, retention, range selection, and the summary-shrink comparison read the route price while the logged shadow price stays heuristic, including a summary that only route-priced shrink accepts. Access-resolution threading is covered at the pricing function and the adapter override. The keyless `image-compaction` ACP snapshot exercises the assembled application end to end.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 按路由定价的图片请求压力
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-24-route-priced-image-request-pressure.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
token 计量服务把 `ImageBlock` 按其持久引用的 JSON 结构计价,约四十个 token,而一张 DeepSeek 请求图片最多消耗 384 个视觉 token,因此图片密集的会话可能携带数十万个未计入估算的 token。provider usage 只锚定已完成的请求:首次多模态请求、锚点之后新增的图片、offload 集合的变化,都会让自动 compaction 拿到数量级错误的压力值,触发得过晚(上下文溢出)或在路由切换后过早。[版本一简化](../simplification/2026-07-29-simplify-web-image-input-v1.zh.md)曾有意否决 provider-neutral 的 tile 公式,把视觉定价推迟到 provider-aware 估算器出现具体消费方之时。
|
||||
|
||||
## Decision
|
||||
|
||||
compaction 压力现在按路由模型自身的请求投影定价。`LlmAdapter.imageRequestPricing(provider, model)` 是可选的同步钩子,为一条确切路由返回 `LlmImageRequestPricing`,经 `ctx.llm.imageRequestPricing()` 解析;基类不声明定价,未注册的 provider 降级为 `undefined` 而绝不抛出。每个按序的图片出现处解析为一个 `LlmImageRequestPrice`:保留图片的提供方视觉 token,加上线上实际携带的模型可见文本(请求预览句柄、offload 占位文本或纯文本替换),文本交由调用方自己的估算器计价,避免任何提供方固定一种文本 token 化。
|
||||
|
||||
DeepSeek 适配器基于连接快照实现该钩子(`request-pricing.ts`):未编目和纯文本模型把每个出现处按其 `textOnlyImageText` 替换计价;支持图片的模型通过共享的 `offloadedImagePrefixCount()` 复现序列化器第一阶段的最旧优先 offload,经序列化器同一套执行环境访问解析构建句柄与占位文本,并按 `requestImageDimensions` 投影尺寸用 `deepSeekImageTokens()` 为保留图片计价,后者是提供方公布的 v4 视觉计算器的逐句移植(14px patch、3:1 降采样、384 token 上限、最小像素放大、8:1 宽度钳制),按最坏的 pad-to-4 对齐计价。纯几何函数从 `attachment-local` 上移到 `dsh-attachment`,供提供方与定价共享。
|
||||
|
||||
token 计量服务的表层 fold 为每个节点存储与路由无关的事实:固定启发式价格、去图价格与持久图片出现处;`measure()` 在每次调用时按生效 envelope 的路由为表层定价。锚点保存原始材料(表层快照、提供方输出价格、usage)而非预先计算的基线,因此匹配的标头会把锚点与当前表层放在同一路由下重新定价,带符号 delta 的比较口径一致;usage 与估算的选择在每次计量时针对路由定价锚点做出。公开的 `TokenSurfaceNode` 同时携带 `tokens`(路由定价;触发、保留、选段与摘要收缩比较读取它)和 `heuristicTokens`(固定值;影子价协议的计量单位,使 `compaction/summary` 与 `compaction/prune` 与 O(1) 投影 fold 自身的追加保持一致)。`contextPressure` 与 `contextBreakdown` 投影有意保持固定启发式规则。
|
||||
|
||||
test-support 的回放适配器按模型声明固定的 `imageRequestTokens`,让 keyless 装配场景走通这条 seam;`image-compaction` ACP 快照证明六张内联图片把第二轮 pre-step 计量推过自动阈值,而纯文本启发式保持在阈值之下,且被触发的 compaction 按启发式价格遮蔽了图片消息。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 provider-neutral 估算器里为图片定价。** 已被[版本一 note](../simplification/2026-07-29-simplify-web-image-input-v1.zh.md)否决且依然错误:视觉定价随提供方、模型、细节档位与预处理而不同,写死的数字在它不描述的路由上会显得权威却错误。钩子把每个常量留在拥有该路由的适配器里。
|
||||
|
||||
**只用 provider usage 校正压力。** usage 无法为首次多模态请求、锚点后新增图片或变化的 offload 集合定价,而这些正是让 compaction 触发过晚的情形。usage 仍是已完成请求的锚点;增量由路由投影定价。
|
||||
|
||||
**复现完整序列化管线,包括请求版本字节与 base64 回退预算。** 第二阶段 offload 依赖异步图片准备之后才存在的编码字节。定价复现由持久字节长度决定的确定性第一阶段;回退请求只会 offload 更多、花费更少,因此估算在同步无 I/O 的钩子里保持保守。
|
||||
|
||||
**让影子价协议也按路由定价。** 记录的 `shadowedTokenCount` 供 O(1) 投影 fold 消费,而该 fold 的追加按固定启发式计价;替换若按路由定价会让持久化的累计值漂移。协议保持在 `heuristicTokens` 上,维持 fold 的构造性一致。
|
||||
|
||||
**把路由定价并入计量服务的回放状态。** 绑定单一路由的 fold 在路由每次变化时都得重放,也无法回答指向另一模型的 `requestHeader` 覆盖。存储与路由无关的节点事实并在 `measure()` 时定价,保持单遍回放与契约已承诺的 O(surface) 计量。
|
||||
|
||||
## Consequences
|
||||
|
||||
自动 compaction 现在按路由模型下一次请求实际携带的压力触发:图片密集的 DeepSeek 会话在溢出之前而非之后压缩,纯文本路由收取替换文本而非幻影视觉 token,被 offload 的图片按占位文本计费。最坏对齐 pad 对单图最多多计三个 token,未复现的 base64 回退预算只会多计——两种误差都偏保守;执行环境访问路径若在定价与请求之间变化,只会按其自身长度改变描述文本的价格,请求完成后 provider usage 仍是权威锚点。公布的 v4 计算器常量只存在于 `llm-deepseek`;提供方若修订其视觉投影,改动点就是这一个模块与其钉死的向量。每次计量多一次定价解析与一次图片出现处遍历,仍为 O(surface)。
|
||||
|
||||
## Testing
|
||||
|
||||
`image-tokens.spec.ts` 的公式向量钉死公布计算器的输出,覆盖宽高比钳制、放大下限、单列求解、奇数网格裁剪与第二遍收敛的用例,开发期间与参考实现在尺寸网格及五万点模糊测试上对拍。`request-pricing.spec.ts` 覆盖纯文本替换、低细节预设以及数量与字节驱动的 offload 边界。token-meter 测试覆盖首次多模态估算、usage 之上的锚后图片 delta、标头覆盖下的纯文本重定价、无定价器时的中性行为、出现处数量不匹配与嵌套工具结果图片。compaction 测试证明触发、保留、选段与摘要收缩比较读取路由价格而记录的影子价保持启发式,包括一个只有路由定价收缩才接受的摘要。访问解析的传递在定价函数与适配器覆写两处都有覆盖。keyless 的 `image-compaction` ACP 快照端到端验证装配后的应用。
|
||||
+2
-2
@@ -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 .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md
|
||||
2026-07-29-simplify-web-image-input-v1.md: e7847dc2ae18f48146cb2b686dcda64beebf2350
|
||||
2026-07-29-simplify-web-image-input-v1.zh.md: 47f6bb5face5b896b5184f7198eef7d99ee25afe
|
||||
2026-07-29-simplify-web-image-input-v1.md: f13abfda3be890f80a8ff852acbc68118b91941c
|
||||
2026-07-29-simplify-web-image-input-v1.zh.md: c92974efe5f84da5b91c09ea4acf820e7853ca7d
|
||||
|
||||
@@ -32,6 +32,6 @@ The attachment seam exposes its limits plus storage-free `validateImage`, `saveI
|
||||
|
||||
## Consequences
|
||||
|
||||
The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, policy snapshots, and route-assembly branches. Provider/model selection remains composition or profile configuration. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact.
|
||||
The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, policy snapshots, and route-assembly branches. Provider/model selection remains composition or profile configuration. Pre-request token pressure keeps the structural heuristic only on routes without declared image pricing; the [route-priced estimator](../feature/2026-08-24-route-priced-image-request-pressure.md) supplies the provider-aware figure, and reported usage remains exact.
|
||||
|
||||
Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape.
|
||||
|
||||
+1
-1
@@ -32,6 +32,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作、策略快照和路由组装分支。提供方/模型选择仍属于组合或 profile 配置。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。
|
||||
该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作、策略快照和路由组装分支。提供方/模型选择仍属于组合或 profile 配置。请求前的 token 压力只在未声明图片定价的路由上保留结构启发式;[按路由定价的估算器](../feature/2026-08-24-route-priced-image-request-pressure.zh.md)提供提供方感知的数值,上报的用量仍保持精确。
|
||||
|
||||
重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。
|
||||
|
||||
@@ -106,7 +106,13 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro', maxTokens: 1234 },
|
||||
params: {
|
||||
cwd: root,
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-pro',
|
||||
reasoningEffort: 'max',
|
||||
maxTokens: 1234,
|
||||
},
|
||||
})}\n`)
|
||||
const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
|
||||
expect(initialized).toMatchObject({
|
||||
@@ -145,6 +151,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
},
|
||||
})
|
||||
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
|
||||
expect(modelRequests[0]?.reasoning_effort).toBe('max')
|
||||
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
||||
expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models')
|
||||
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: b4798eb9f82e64fb246cb100bf061602e0f5c665
|
||||
config-catalog.zh.md: 156aa237abe44b80e316840ba404790096a23229
|
||||
config-catalog.md: 581c8c89dafea93e34968535eacfb0bb8f8ed34a
|
||||
config-catalog.zh.md: 8a64b5789ee02f724cd15b303910c5baa5c381d6
|
||||
|
||||
+12
-3
@@ -964,7 +964,7 @@ export interface DeepSeekCatalogModel {
|
||||
|
||||
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:117`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:124`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-pi-ai"></a>
|
||||
|
||||
@@ -1286,6 +1286,15 @@ export interface ReplayModelConfig {
|
||||
* omit one, so replay reconstructs the request header a live catalog produced.
|
||||
*/
|
||||
defaultMaxTokens?: number
|
||||
/**
|
||||
* Optional flat visual-token price the replay route declares for every
|
||||
* retained request image, so keyless scenarios exercise route-priced
|
||||
* request pressure; each occurrence is priced at this value plus its
|
||||
* request-preview handle text. Requires {@link inputModalities} to include
|
||||
* `image` — a text-only route never sends visual tokens. Absent declares
|
||||
* no image pricing.
|
||||
*/
|
||||
imageRequestTokens?: number
|
||||
/** Optional reasoning-effort ids the replay route accepts, in display order. */
|
||||
reasoningEfforts?: string[]
|
||||
/**
|
||||
@@ -1298,7 +1307,7 @@ export interface ReplayModelConfig {
|
||||
|
||||
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
Source: [`packages/test-support/llm-replay/src/index.ts:892`](../packages/test-support/llm-replay/src/index.ts)
|
||||
Source: [`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
@@ -2388,7 +2397,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-dsh-sdk/src/index.ts:31`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
|
||||
Source: [`packages/subagent/subagent-dsh-sdk/src/index.ts:33`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-fork-in-process"></a>
|
||||
|
||||
|
||||
@@ -1288,6 +1288,15 @@ export interface ReplayModelConfig {
|
||||
* omit one, so replay reconstructs the request header a live catalog produced.
|
||||
*/
|
||||
defaultMaxTokens?: number
|
||||
/**
|
||||
* Optional flat visual-token price the replay route declares for every
|
||||
* retained request image, so keyless scenarios exercise route-priced
|
||||
* request pressure; each occurrence is priced at this value plus its
|
||||
* request-preview handle text. Requires {@link inputModalities} to include
|
||||
* `image` — a text-only route never sends visual tokens. Absent declares
|
||||
* no image pricing.
|
||||
*/
|
||||
imageRequestTokens?: number
|
||||
/** Optional reasoning-effort ids the replay route accepts, in display order. */
|
||||
reasoningEfforts?: string[]
|
||||
/**
|
||||
@@ -1300,7 +1309,7 @@ export interface ReplayModelConfig {
|
||||
|
||||
依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
来源:[`packages/test-support/llm-replay/src/index.ts:892`](../packages/test-support/llm-replay/src/index.ts)
|
||||
来源:[`packages/test-support/llm-replay/src/index.ts:914`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
@@ -2390,7 +2399,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/subagent/subagent-dsh-sdk/src/index.ts:31`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
|
||||
来源:[`packages/subagent/subagent-dsh-sdk/src/index.ts:33`](../packages/subagent/subagent-dsh-sdk/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-fork-in-process"></a>
|
||||
|
||||
|
||||
@@ -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 docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: baeddb7b0171b4347fa1748e0adf87df5c15e4d0
|
||||
event-producer-consumer.zh.md: baee416d8c0bf1b4102f839cdcd98654d0acd63e
|
||||
event-producer-consumer.md: 6f971f5470c34093dd3774650ad191f9a3516df7
|
||||
event-producer-consumer.zh.md: ca375c98b6c0552f255e5d2604c9ad0c1d968aad
|
||||
|
||||
@@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
|
||||
@@ -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 docs/subsystems/llm-streaming.md
|
||||
llm-streaming.md: 29efabd2b01659bdf2cc798ceadb4bb495e1731e
|
||||
llm-streaming.zh.md: 21ad56e526b9a507644b436b41ad063c5310b2ce
|
||||
llm-streaming.md: 73e8dc4a6a5b5408a5c85dcbeac4cfa2108a9ddd
|
||||
llm-streaming.zh.md: 7f98029f35100ec9c72f55c509f20b6709315f47
|
||||
|
||||
@@ -236,6 +236,44 @@ interface LlmFailure {
|
||||
}
|
||||
```
|
||||
|
||||
## Request-image pricing
|
||||
|
||||
An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published v4 vision accounting, while provider usage remains the authoritative anchor for completed requests.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Request price of one ordered image occurrence under one exact model route's
|
||||
* request projection. Every occurrence resolves to the pair the wire actually
|
||||
* carries: provider visual tokens for a retained image, plus the model-visible
|
||||
* text sent with or instead of it (request-preview handle, offload placeholder,
|
||||
* or text-only substitution). The caller prices `text` with its own text
|
||||
* estimator so provider pricing never fixes a text tokenization.
|
||||
*/
|
||||
interface LlmImageRequestPrice {
|
||||
/** Provider visual tokens for the retained request image; 0 when only text represents this occurrence. */
|
||||
visualTokens: number
|
||||
/** Model-visible text sent for this occurrence, to be priced by the caller's text estimator. */
|
||||
text: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Provider-side request-image pricing for one exact model route. Implemented
|
||||
* by adapters whose provider charges visual tokens; consumers (the token
|
||||
* meter) resolve it synchronously per measurement, so implementations must not
|
||||
* perform I/O.
|
||||
*/
|
||||
interface LlmImageRequestPricing {
|
||||
/**
|
||||
* Price every image occurrence of one request projection.
|
||||
* @param images - durable image references in request order, one entry per occurrence.
|
||||
* @returns one price per occurrence, aligned by index with `images`.
|
||||
*/
|
||||
priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[]
|
||||
}
|
||||
```
|
||||
|
||||
## The adapter contract
|
||||
|
||||
Every adapter MUST obey these, and every consumer may rely on them:
|
||||
@@ -736,6 +774,16 @@ declare abstract class LlmAdapter {
|
||||
* @returns a resolved policy, or `undefined` to use the normal defaults.
|
||||
*/
|
||||
providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;
|
||||
/**
|
||||
* Resolve provider-side request-image pricing for one exact model route.
|
||||
* The default declares none, so consumers fall back to their own neutral
|
||||
* estimate. Implementations must answer synchronously without I/O; the
|
||||
* token meter resolves this per measurement.
|
||||
* @param _provider - a route passed to `registerAdapter()` for this instance.
|
||||
* @param _model - exact model id passed to {@link GenerateOptions.model}.
|
||||
* @returns route-owned image pricing, or `undefined` when the route declares none.
|
||||
*/
|
||||
imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;
|
||||
/**
|
||||
* List models this adapter can currently advertise for one owned provider.
|
||||
* The result is advisory: an adapter may accept unlisted model ids, and
|
||||
@@ -883,6 +931,17 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ):
|
||||
*/
|
||||
providerRetryPolicy(provider: string): ResolvedRetryPolicy
|
||||
|
||||
/**
|
||||
* Resolve provider-side request-image pricing for one exact route, or
|
||||
* `undefined` when the provider is unregistered or declares none. Unknown
|
||||
* providers degrade to `undefined` rather than throwing because callers
|
||||
* price durable history whose route may no longer be mounted.
|
||||
* @param provider - provider route named by a request header.
|
||||
* @param model - exact model id named by the same header.
|
||||
* @returns the owning adapter's image pricing for the route, when declared.
|
||||
*/
|
||||
imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined
|
||||
|
||||
/**
|
||||
* Discover models advertised by one registered provider. Catalog membership
|
||||
* is advisory and never changes routing or request validation.
|
||||
|
||||
@@ -238,6 +238,44 @@ interface LlmFailure {
|
||||
}
|
||||
```
|
||||
|
||||
## 请求图片定价
|
||||
|
||||
提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的 v4 视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Request price of one ordered image occurrence under one exact model route's
|
||||
* request projection. Every occurrence resolves to the pair the wire actually
|
||||
* carries: provider visual tokens for a retained image, plus the model-visible
|
||||
* text sent with or instead of it (request-preview handle, offload placeholder,
|
||||
* or text-only substitution). The caller prices `text` with its own text
|
||||
* estimator so provider pricing never fixes a text tokenization.
|
||||
*/
|
||||
interface LlmImageRequestPrice {
|
||||
/** Provider visual tokens for the retained request image; 0 when only text represents this occurrence. */
|
||||
visualTokens: number
|
||||
/** Model-visible text sent for this occurrence, to be priced by the caller's text estimator. */
|
||||
text: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Provider-side request-image pricing for one exact model route. Implemented
|
||||
* by adapters whose provider charges visual tokens; consumers (the token
|
||||
* meter) resolve it synchronously per measurement, so implementations must not
|
||||
* perform I/O.
|
||||
*/
|
||||
interface LlmImageRequestPricing {
|
||||
/**
|
||||
* Price every image occurrence of one request projection.
|
||||
* @param images - durable image references in request order, one entry per occurrence.
|
||||
* @returns one price per occurrence, aligned by index with `images`.
|
||||
*/
|
||||
priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[]
|
||||
}
|
||||
```
|
||||
|
||||
## 适配器约定
|
||||
|
||||
每个适配器必须遵守以下规则,每个消费方可以依赖它们:
|
||||
@@ -742,6 +780,16 @@ declare abstract class LlmAdapter {
|
||||
* @returns a resolved policy, or `undefined` to use the normal defaults.
|
||||
*/
|
||||
providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;
|
||||
/**
|
||||
* Resolve provider-side request-image pricing for one exact model route.
|
||||
* The default declares none, so consumers fall back to their own neutral
|
||||
* estimate. Implementations must answer synchronously without I/O; the
|
||||
* token meter resolves this per measurement.
|
||||
* @param _provider - a route passed to `registerAdapter()` for this instance.
|
||||
* @param _model - exact model id passed to {@link GenerateOptions.model}.
|
||||
* @returns route-owned image pricing, or `undefined` when the route declares none.
|
||||
*/
|
||||
imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;
|
||||
/**
|
||||
* List models this adapter can currently advertise for one owned provider.
|
||||
* The result is advisory: an adapter may accept unlisted model ids, and
|
||||
@@ -889,6 +937,17 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ):
|
||||
*/
|
||||
providerRetryPolicy(provider: string): ResolvedRetryPolicy
|
||||
|
||||
/**
|
||||
* Resolve provider-side request-image pricing for one exact route, or
|
||||
* `undefined` when the provider is unregistered or declares none. Unknown
|
||||
* providers degrade to `undefined` rather than throwing because callers
|
||||
* price durable history whose route may no longer be mounted.
|
||||
* @param provider - provider route named by a request header.
|
||||
* @param model - exact model id named by the same header.
|
||||
* @returns the owning adapter's image pricing for the route, when declared.
|
||||
*/
|
||||
imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined
|
||||
|
||||
/**
|
||||
* Discover models advertised by one registered provider. Catalog membership
|
||||
* is advisory and never changes routing or request validation.
|
||||
|
||||
@@ -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 docs/subsystems/subagent.md
|
||||
subagent.md: 8c26177bb2c534cfe724a3efb3861fb8a303b731
|
||||
subagent.zh.md: 9cdf55d5d8c9e8bec8ab93a541f2b80da4655c12
|
||||
subagent.md: 66ebb7eb45f45092c1165d3f689ea1635d9cb3fe
|
||||
subagent.zh.md: 8cdc897ddd12f4e1ff03aa8ba4cd82d4150784d3
|
||||
|
||||
@@ -35,7 +35,7 @@ interface SubagentCapabilities {
|
||||
|
||||
## The one-shot start request
|
||||
|
||||
The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. Current out-of-process providers reject `agentOptions` before starting their transport.
|
||||
The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. The DSH SDK backend merges the four Agent route fields over its instance defaults and validates them in the child runtime's initialization; ACP, Codex, and Claude Code reject `agentOptions` before starting their transports.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -68,7 +68,8 @@ interface SubagentStartRequest {
|
||||
* Optional host-Agent provider, model, reasoning-effort, and output-token
|
||||
* overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process
|
||||
* providers merge them over the parent Agent's options when they create the
|
||||
* child.
|
||||
* child, while the DSH SDK provider merges them over its instance defaults
|
||||
* before initializing the separate child runtime.
|
||||
*/
|
||||
readonly agentOptions?: AgentOptions
|
||||
/**
|
||||
@@ -419,7 +420,7 @@ A local one-shot run MUST publish an ordinary child agent/session before `start(
|
||||
|
||||
## The provider contract: `SubagentProvider`
|
||||
|
||||
Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`, and rejects a continuable start on a provider without `prepareContinuable`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority.
|
||||
Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`, and rejects a continuable start on a provider without `prepareContinuable`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. A provider whose one-shot route has static provider-owned defaults publishes optional immutable `agentRouteDefaults`, allowing a Consumer to merge model/tool overrides against the correct baseline before preflight.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -441,6 +442,13 @@ interface SubagentProvider {
|
||||
* It says nothing about tool registration, injected services, or authority inheritance.
|
||||
*/
|
||||
readonly inheritsParentContext: boolean
|
||||
/**
|
||||
* Optional static provider-owned provider/model route for one-shot Agent
|
||||
* options. Consumers merge tool/model overrides over these values before
|
||||
* preflight; providers whose route derives from the parent omit it. The value
|
||||
* is detached immutable data and requires `agentOptions` support.
|
||||
*/
|
||||
readonly agentRouteDefaults?: Readonly<{ provider: string; model: string }>
|
||||
/**
|
||||
* Establish a ONE-SHOT child and return its handle after publication.
|
||||
* The service has already validated that every requested start-time
|
||||
|
||||
@@ -35,7 +35,7 @@ interface SubagentCapabilities {
|
||||
|
||||
## 单次启动请求
|
||||
|
||||
工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。当前进程外提供方会在启动其传输前拒绝 `agentOptions`。
|
||||
工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。DSH SDK 后端会把四个 Agent 路由字段合并到实例默认值之上,并在子运行时初始化期间校验;ACP、Codex 与 Claude Code 会在启动传输前拒绝 `agentOptions`。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -68,7 +68,8 @@ interface SubagentStartRequest {
|
||||
* Optional host-Agent provider, model, reasoning-effort, and output-token
|
||||
* overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process
|
||||
* providers merge them over the parent Agent's options when they create the
|
||||
* child.
|
||||
* child, while the DSH SDK provider merges them over its instance defaults
|
||||
* before initializing the separate child runtime.
|
||||
*/
|
||||
readonly agentOptions?: AgentOptions
|
||||
/**
|
||||
@@ -423,7 +424,7 @@ interface SubagentRun {
|
||||
|
||||
## 提供方约定:`SubagentProvider`
|
||||
|
||||
每个提供方都是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力,并拒绝在没有 `prepareContinuable` 的提供方上发起可继续 start。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。
|
||||
每个提供方都是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力,并拒绝在没有 `prepareContinuable` 的提供方上发起可继续 start。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。如果某个提供方的一次性路由拥有静态的提供方自有默认值,它会公开可选且不可变的 `agentRouteDefaults`,使 Consumer 能够在预检前以正确基线合并模型与工具覆盖。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -445,6 +446,13 @@ interface SubagentProvider {
|
||||
* It says nothing about tool registration, injected services, or authority inheritance.
|
||||
*/
|
||||
readonly inheritsParentContext: boolean
|
||||
/**
|
||||
* Optional static provider-owned provider/model route for one-shot Agent
|
||||
* options. Consumers merge tool/model overrides over these values before
|
||||
* preflight; providers whose route derives from the parent omit it. The value
|
||||
* is detached immutable data and requires `agentOptions` support.
|
||||
*/
|
||||
readonly agentRouteDefaults?: Readonly<{ provider: string; model: string }>
|
||||
/**
|
||||
* Establish a ONE-SHOT child and return its handle after publication.
|
||||
* The service has already validated that every requested start-time
|
||||
|
||||
@@ -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 docs/subsystems/token-meter.md
|
||||
token-meter.md: b8b2add194cbafafc250c6fc15b23e246d87d9c1
|
||||
token-meter.zh.md: a1366d0d1d113c0a7df77b5b3bc53c9121fe9ae3
|
||||
token-meter.md: 9c4a1e4b95ffd84f65f7a73e208be245378a3301
|
||||
token-meter.zh.md: d9e2e7f773041ccb6d1e4c3cc4d81a342db0cc01
|
||||
|
||||
@@ -19,14 +19,14 @@ interface TokenMeasurement {
|
||||
readonly surfaceDeltaTokens: number
|
||||
/** Non-negative current request-and-response pressure. */
|
||||
readonly totalTokens: number
|
||||
/** Total heuristic tokens across the current surface. */
|
||||
/** Total route-priced request tokens across the current surface; equals the sum of the node prices. */
|
||||
readonly surfaceTokens: number
|
||||
/** Current surface nodes in positional head-to-tail order. */
|
||||
readonly nodes: readonly TokenSurfaceNode[]
|
||||
}
|
||||
```
|
||||
|
||||
`baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope and its total is no lower than that call's full heuristic anchor. `estimated` means no reusable conservative usage anchor exists, so the service priced the complete envelope and surface with its fixed heuristic. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of the node prices.
|
||||
Every measurement resolves the effective envelope's routed provider/model to that route's declared request-image pricing through `ctx.llm`, so image occurrences are priced as the visual tokens plus model-visible text the request actually sends; routes and compositions without declared pricing keep the fixed heuristic. `baseline.kind === 'usage'` means the latest successful provider call has the same canonical request envelope and its total is no lower than that call's full route-priced anchor. `estimated` means no reusable conservative usage anchor exists, so the service priced the complete envelope and surface itself. A later successful request replaces the earlier anchor; signed `surfaceDeltaTokens` preserves growth and shrinkage relative to a matching anchor, repricing both sides under the same route. `totalTokens` remains request-and-response pressure, while `surfaceTokens` is the surface-only route-priced total and equals the sum of the node prices.
|
||||
|
||||
## `TokenSurfaceNode`
|
||||
|
||||
@@ -35,8 +35,19 @@ interface TokenMeasurement {
|
||||
interface TokenSurfaceNode {
|
||||
/** Durable sequence number of the surface event. */
|
||||
readonly seq: number
|
||||
/** Heuristic tokens for the exact message projected by this node. */
|
||||
/**
|
||||
* Request-pressure tokens for the exact message projected by this node under
|
||||
* the measured route: image occurrences carry the route's declared visual
|
||||
* price when the routed adapter declares one, and the fixed heuristic
|
||||
* otherwise. Trigger, retention, and range selection all read this price.
|
||||
*/
|
||||
readonly tokens: number
|
||||
/**
|
||||
* Fixed-heuristic tokens for the same message, independent of any route.
|
||||
* The shadow-price protocol prices replacements with this value so the O(1)
|
||||
* projection fold stays in agreement with its own appends.
|
||||
*/
|
||||
readonly heuristicTokens: number
|
||||
}
|
||||
```
|
||||
|
||||
@@ -60,14 +71,18 @@ Replay owner for one service-wide estimator and isolated per-session folds.
|
||||
/**
|
||||
* Measure current request pressure and surface through the durable tail.
|
||||
*
|
||||
* Provider usage is reused only when the latest successful call's canonical
|
||||
* request envelope matches `requestHeader` and its total is no lower than
|
||||
* that call's full heuristic anchor; otherwise the complete envelope and
|
||||
* surface are heuristically repriced.
|
||||
* The effective envelope's routed provider/model selects the request-image
|
||||
* pricing every node is priced under: a route whose adapter declares image
|
||||
* pricing charges each retained image its visual tokens plus its
|
||||
* model-visible text, while other routes keep the fixed heuristic. Provider
|
||||
* usage is reused only when the latest successful call's canonical request
|
||||
* envelope matches `requestHeader` and its total is no lower than that
|
||||
* call's full route-priced anchor; otherwise the complete envelope and
|
||||
* surface are repriced.
|
||||
*
|
||||
* `requestHeader` affects request pressure only; surface fields always
|
||||
* describe the current session surface. Every call clones those positional
|
||||
* nodes, so measurement is O(surface).
|
||||
* `requestHeader` replaces the latest logged envelope for pressure and node
|
||||
* pricing; the node set always describes the current session surface. Every
|
||||
* call clones those positional nodes, so measurement is O(surface).
|
||||
*
|
||||
* @param session - session to replay through its current durable tail.
|
||||
* @param requestHeader - optional effective request envelope replacing the latest logged header.
|
||||
|
||||
@@ -19,14 +19,14 @@ interface TokenMeasurement {
|
||||
readonly surfaceDeltaTokens: number
|
||||
/** Non-negative current request-and-response pressure. */
|
||||
readonly totalTokens: number
|
||||
/** Total heuristic tokens across the current surface. */
|
||||
/** Total route-priced request tokens across the current surface; equals the sum of the node prices. */
|
||||
readonly surfaceTokens: number
|
||||
/** Current surface nodes in positional head-to-tail order. */
|
||||
readonly nodes: readonly TokenSurfaceNode[]
|
||||
}
|
||||
```
|
||||
|
||||
`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求 envelope,且该调用的总量不低于其完整启发式锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务使用固定启发式规则对完整信封和表层定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减。`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是仅针对表层的启发式总量,等于所有节点价格之和。
|
||||
每次计量都会通过 `ctx.llm` 把生效信封的路由 provider/model 解析为该路由声明的请求图片定价,因此图片出现处按请求实际发送的视觉 token 加模型可见文本计价;未声明定价的路由与组合保持固定启发式规则。`baseline.kind === 'usage'` 表示最近一次成功的提供方调用具有相同的规范请求 envelope,且该调用的总量不低于其完整路由定价锚点。`estimated` 表示不存在可复用的保守 usage 锚点,因此服务自行对完整信封和表层定价。后续成功请求会替换早先的锚点;有符号的 `surfaceDeltaTokens` 会保留相对于匹配锚点的增长与缩减,且两侧按同一路由重新定价。`totalTokens` 仍表示请求与响应压力,`surfaceTokens` 则是表层的路由定价总量,等于所有节点价格之和。
|
||||
|
||||
## `TokenSurfaceNode`
|
||||
|
||||
@@ -35,8 +35,19 @@ interface TokenMeasurement {
|
||||
interface TokenSurfaceNode {
|
||||
/** Durable sequence number of the surface event. */
|
||||
readonly seq: number
|
||||
/** Heuristic tokens for the exact message projected by this node. */
|
||||
/**
|
||||
* Request-pressure tokens for the exact message projected by this node under
|
||||
* the measured route: image occurrences carry the route's declared visual
|
||||
* price when the routed adapter declares one, and the fixed heuristic
|
||||
* otherwise. Trigger, retention, and range selection all read this price.
|
||||
*/
|
||||
readonly tokens: number
|
||||
/**
|
||||
* Fixed-heuristic tokens for the same message, independent of any route.
|
||||
* The shadow-price protocol prices replacements with this value so the O(1)
|
||||
* projection fold stays in agreement with its own appends.
|
||||
*/
|
||||
readonly heuristicTokens: number
|
||||
}
|
||||
```
|
||||
|
||||
@@ -60,14 +71,18 @@ Replay owner for one service-wide estimator and isolated per-session folds.
|
||||
/**
|
||||
* Measure current request pressure and surface through the durable tail.
|
||||
*
|
||||
* Provider usage is reused only when the latest successful call's canonical
|
||||
* request envelope matches `requestHeader` and its total is no lower than
|
||||
* that call's full heuristic anchor; otherwise the complete envelope and
|
||||
* surface are heuristically repriced.
|
||||
* The effective envelope's routed provider/model selects the request-image
|
||||
* pricing every node is priced under: a route whose adapter declares image
|
||||
* pricing charges each retained image its visual tokens plus its
|
||||
* model-visible text, while other routes keep the fixed heuristic. Provider
|
||||
* usage is reused only when the latest successful call's canonical request
|
||||
* envelope matches `requestHeader` and its total is no lower than that
|
||||
* call's full route-priced anchor; otherwise the complete envelope and
|
||||
* surface are repriced.
|
||||
*
|
||||
* `requestHeader` affects request pressure only; surface fields always
|
||||
* describe the current session surface. Every call clones those positional
|
||||
* nodes, so measurement is O(surface).
|
||||
* `requestHeader` replaces the latest logged envelope for pressure and node
|
||||
* pricing; the node set always describes the current session surface. Every
|
||||
* call clones those positional nodes, so measurement is O(surface).
|
||||
*
|
||||
* @param session - session to replay through its current durable tail.
|
||||
* @param requestHeader - optional effective request envelope replacing the latest logged header.
|
||||
|
||||
@@ -692,6 +692,7 @@
|
||||
"@deepseek-ai/dsh-llm-deepseek",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl",
|
||||
"@deepseek-ai/dsh-skill-filesystem",
|
||||
"@deepseek-ai/dsh-tool-subagent"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export { canPassThroughNormalization, normalizeImage } from './normalization.ts'
|
||||
export type { NormalizedImage, NormalizationPolicy } from './normalization.ts'
|
||||
export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts'
|
||||
export type { PreparedImageFile } from './store.ts'
|
||||
export { readRequestImageFile, requestImageDimensions, requestImageVariantId } from './request-image.ts'
|
||||
export { readRequestImageFile, requestImageVariantId } from './request-image.ts'
|
||||
|
||||
/** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */
|
||||
export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/** Deterministic provider-independent image normalization. */
|
||||
|
||||
import sharp, { type Sharp } from 'sharp'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import { AttachmentError, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import { encodeFirstWithinLimit, encodingLadder, isExhaustedEncoding } from './encoding.ts'
|
||||
import { requestImageDimensions } from './request-image.ts'
|
||||
import { detectImage, encodedAlphaIsCompatible } from './image.ts'
|
||||
import type { DetectedImage } from './image.ts'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import sharp, { type Sharp } from 'sharp'
|
||||
import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment'
|
||||
import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageMediaType,
|
||||
ImageAttachmentRef,
|
||||
@@ -39,38 +39,6 @@ function digest(value: string | Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute aspect-preserving integer dimensions within a hard total-pixel budget.
|
||||
* @param width - positive source width.
|
||||
* @param height - positive source height.
|
||||
* @param maxPixels - positive width-times-height cap.
|
||||
* @returns inward-rounded dimensions; small images are not enlarged.
|
||||
*/
|
||||
export function requestImageDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
maxPixels: number,
|
||||
): { width: number; height: number } {
|
||||
const scale = Math.min(1, Math.sqrt(maxPixels / (width * height)))
|
||||
if (scale === 1) return { width, height }
|
||||
if (width >= height) {
|
||||
let projectedWidth = Math.max(1, Math.floor(width * scale))
|
||||
let projectedHeight = Math.max(1, Math.round(projectedWidth * height / width))
|
||||
while (projectedWidth * projectedHeight > maxPixels && projectedWidth > 1) {
|
||||
projectedWidth -= 1
|
||||
projectedHeight = Math.max(1, Math.round(projectedWidth * height / width))
|
||||
}
|
||||
return { width: projectedWidth, height: projectedHeight }
|
||||
}
|
||||
let projectedHeight = Math.max(1, Math.floor(height * scale))
|
||||
let projectedWidth = Math.max(1, Math.round(projectedHeight * width / height))
|
||||
while (projectedWidth * projectedHeight > maxPixels && projectedHeight > 1) {
|
||||
projectedHeight -= 1
|
||||
projectedWidth = Math.max(1, Math.round(projectedHeight * width / height))
|
||||
}
|
||||
return { width: projectedWidth, height: projectedHeight }
|
||||
}
|
||||
|
||||
function checkedInteger(value: number, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new AttachmentError(`${name} must be a positive integer.`, 'INVALID_ATTACHMENT_REF')
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import sharp from 'sharp'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CompressionLimiter } from '../src/compression-limiter.ts'
|
||||
import LocalAttachmentStore, { requestImageDimensions } from '../src/index.ts'
|
||||
import LocalAttachmentStore from '../src/index.ts'
|
||||
|
||||
const homes: string[] = []
|
||||
|
||||
@@ -42,34 +42,6 @@ afterEach(async () => {
|
||||
await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('request image dimensions', () => {
|
||||
it.each([
|
||||
[4096, 4096, 800, 800],
|
||||
[4096, 2048, 1130, 565],
|
||||
[3840, 2160, 1066, 600],
|
||||
[320, 240, 320, 240],
|
||||
])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => {
|
||||
const projected = requestImageDimensions(width, height, 640_000)
|
||||
expect(projected).toEqual({
|
||||
width: expectedWidth,
|
||||
height: expectedHeight,
|
||||
})
|
||||
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
|
||||
})
|
||||
|
||||
it('projects a portrait within the same total-pixel budget', () => {
|
||||
const projected = requestImageDimensions(2160, 3840, 640_000)
|
||||
|
||||
expect(projected).toEqual({ width: 600, height: 1066 })
|
||||
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
|
||||
})
|
||||
|
||||
it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => {
|
||||
expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('local request-image cache', () => {
|
||||
it('passes through an in-budget attachment and composes ordered request reads', async () => {
|
||||
const attachments = await store()
|
||||
|
||||
@@ -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/attachment/attachment/README.md
|
||||
README.md: 21030a492464aae528d4c06b4b72d4a94c0a2603
|
||||
README.zh.md: 0540996f99b3250331e567e174264cf7da8aa474
|
||||
README.md: 976bfc82a4cf8a626259ffcddabcbead8ba03154
|
||||
README.zh.md: bc33293b34295250c332d6111fb0d52e506c47db
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events.
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageHostPath` optionally exposes the provider-owned object's absolute host path; it makes no claim that the current model tools can read that path. An LLM consumer combines this location with the mounted filesystem's execution-world mapping when it serializes a request. That current access path remains separate from the request version and its `variantId`. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure.
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes that projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` optionally exposes the provider-owned object's absolute host path; it makes no claim that the current model tools can read that path. An LLM consumer combines this location with the mounted filesystem's execution-world mapping when it serializes a request. That current access path remains separate from the request version and its `variantId`. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure.
|
||||
|
||||
`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageHostPath` 可以给出提供方所持对象的绝对宿主路径,但不保证当前模型工具能够读取它。LLM 消费方在序列化请求时将这个位置与当前文件系统提供的执行环境映射组合起来。解析出的访问路径独立于请求版本及其 `variantId`。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。纯函数导出 `requestImageDimensions` 按总像素预算计算该投影的保持宽高比尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 可以给出提供方所持对象的绝对宿主路径,但不保证当前模型工具能够读取它。LLM 消费方在序列化请求时将这个位置与当前文件系统提供的执行环境映射组合起来。解析出的访问路径独立于请求版本及其 `variantId`。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。
|
||||
|
||||
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export { AttachmentId, ImageVariantId } from './brand.ts'
|
||||
export { AttachmentError, isImageAdmissionError } from './error.ts'
|
||||
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
|
||||
export { admitEncodedImages } from './admission.ts'
|
||||
export { requestImageDimensions } from './request-projection.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
EncodedImageAttachment,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Pure request-projection geometry shared by attachment providers and
|
||||
* provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute aspect-preserving integer dimensions within a hard total-pixel budget.
|
||||
* @param width - positive source width.
|
||||
* @param height - positive source height.
|
||||
* @param maxPixels - positive width-times-height cap.
|
||||
* @returns inward-rounded dimensions; small images are not enlarged.
|
||||
*/
|
||||
export function requestImageDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
maxPixels: number,
|
||||
): { width: number; height: number } {
|
||||
const scale = Math.min(1, Math.sqrt(maxPixels / (width * height)))
|
||||
if (scale === 1) return { width, height }
|
||||
if (width >= height) {
|
||||
let projectedWidth = Math.max(1, Math.floor(width * scale))
|
||||
let projectedHeight = Math.max(1, Math.round(projectedWidth * height / width))
|
||||
while (projectedWidth * projectedHeight > maxPixels && projectedWidth > 1) {
|
||||
projectedWidth -= 1
|
||||
projectedHeight = Math.max(1, Math.round(projectedWidth * height / width))
|
||||
}
|
||||
return { width: projectedWidth, height: projectedHeight }
|
||||
}
|
||||
let projectedHeight = Math.max(1, Math.floor(height * scale))
|
||||
let projectedWidth = Math.max(1, Math.round(projectedHeight * width / height))
|
||||
while (projectedWidth * projectedHeight > maxPixels && projectedHeight > 1) {
|
||||
projectedHeight -= 1
|
||||
projectedWidth = Math.max(1, Math.round(projectedHeight * width / height))
|
||||
}
|
||||
return { width: projectedWidth, height: projectedHeight }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { requestImageDimensions } from '../src/index.ts'
|
||||
|
||||
describe('request image dimensions', () => {
|
||||
it.each([
|
||||
[4096, 4096, 800, 800],
|
||||
[4096, 2048, 1130, 565],
|
||||
[3840, 2160, 1066, 600],
|
||||
[320, 240, 320, 240],
|
||||
])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => {
|
||||
const projected = requestImageDimensions(width, height, 640_000)
|
||||
expect(projected).toEqual({
|
||||
width: expectedWidth,
|
||||
height: expectedHeight,
|
||||
})
|
||||
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
|
||||
})
|
||||
|
||||
it('projects a portrait within the same total-pixel budget', () => {
|
||||
const projected = requestImageDimensions(2160, 3840, 640_000)
|
||||
|
||||
expect(projected).toEqual({ width: 600, height: 1066 })
|
||||
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
|
||||
})
|
||||
|
||||
it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => {
|
||||
expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
|
||||
})
|
||||
})
|
||||
@@ -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/compaction/compaction-basic/README.md
|
||||
README.md: 82df7b7e399cef80d92611819e9e58f13babf175
|
||||
README.zh.md: d79ea39ad8db7e2ab7f0a5b2ef52b61ab054d01b
|
||||
README.md: e45228080db414c503420d22f5faca3daf1d3966
|
||||
README.zh.md: 1ff7bede73f36ec81d1dba0f2b414736ade09457
|
||||
|
||||
@@ -10,7 +10,7 @@ This package owns the Service Provider role of the compaction capability — see
|
||||
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, and steering.
|
||||
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision, under the routed model's declared request-image pricing when its adapter declares one. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, steering, and route-priced image history; trigger, recent-tail retention, range selection, and the summary-shrink comparison all read the same route-priced per-node figures, while the logged shadow price of a replaced range stays on the route-independent fixed heuristic so pure projection folds remain consistent.
|
||||
- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted.
|
||||
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPruner`](../compaction-tool-result-pruner/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compaction` boundary helpers](../compaction/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
|
||||
@@ -157,7 +157,7 @@ The replayed system prompt, tools, and shadowed-region messages match the conver
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
|
||||
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization; image occurrences carry provider-exact visual tokens only on routes whose adapter declares request-image pricing.
|
||||
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
|
||||
- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair.
|
||||
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
该后端拥有压缩策略:
|
||||
|
||||
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上,计量最新一份规范化已记录 envelope 与当前表层的 token 用量。因此,步骤边界的压力计量会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文与 steering(中途引导)。
|
||||
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上,计量最新一份规范化已记录 envelope 与当前表层的 token 用量;当路由模型的适配器声明了请求图片定价时,按该定价计量。因此,步骤边界的压力计量会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文、steering(中途引导)与按路由定价的图片历史;触发、近期尾部保留、范围选择与摘要收缩比较读取同一套路由定价的逐节点数字,而被替换范围记录的影子价保持在与路由无关的固定启发式规则上,使纯投影 fold 保持一致。
|
||||
- **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍仅供参考,不参与此处的策略解析。
|
||||
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPruner`](../compaction-tool-result-pruner/README.zh.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则对已剪枝的表层进行摘要。低于压力的步骤检查绝不剪枝。
|
||||
- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compaction` 边界 helper](../compaction/README.zh.md#tool-pairing-boundaries) 将切分点调整到工具调用/结果配对平衡的位置。轮次边界不会保护失控轮次内的旧步骤。尚未闭合且不可分的尾部会在闭合前拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
|
||||
@@ -157,7 +157,7 @@ Rules:
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **计量准确度取决于固定启发式规则**:可复用提供方用量缺失时,会回退到字符数加结构开销,而非精确的 token 化。
|
||||
- **计量准确度取决于固定启发式规则**:可复用提供方用量缺失时,会回退到字符数加结构开销,而非精确的 token 化;只有在适配器声明了请求图片定价的路由上,图片出现处才携带提供方精确的视觉 token。
|
||||
- **溢出分类由适配器维护**:提供方措辞可能改变;两个 DeepSeek 适配器将当前可识别的上下文限制失败规范化为 `CONTEXT_WINDOW_EXCEEDED`。
|
||||
- **部分不可分单元与仅 envelope 溢出仍不在表层压缩范围内**:恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。可选 pruner 可以缩减原本不可分工具对内的文本型工具结果主体。
|
||||
- **`compactRegion` 要求存在未结束的轮次**:在完全关闭的会话上手动调用会抛出异常(「no open turn」),而不是执行压缩。
|
||||
|
||||
@@ -43,6 +43,8 @@ interface PreparedCompaction extends SurfaceSelection {
|
||||
readonly measurement: TokenMeasurement
|
||||
readonly selectedNodes: TokenMeasurement['nodes']
|
||||
readonly shadowedTokenCount: number
|
||||
/** Route-priced total of the selected span; the shrink comparison's unit. */
|
||||
readonly shadowedRouteTokenCount: number
|
||||
readonly input: SummarizationInput
|
||||
}
|
||||
|
||||
@@ -351,7 +353,12 @@ function prepareCompaction(
|
||||
...selection,
|
||||
measurement,
|
||||
selectedNodes,
|
||||
shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
|
||||
// The shadow-price protocol prices replacements with the fixed heuristic
|
||||
// so the O(1) projection fold stays in agreement with its own appends;
|
||||
// retention, range selection, and the shrink comparison read the
|
||||
// route-priced `tokens` instead.
|
||||
shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.heuristicTokens, 0),
|
||||
shadowedRouteTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
|
||||
input: buildSummarizationInput(session, selection.shadowedSeqs),
|
||||
}
|
||||
}
|
||||
@@ -370,10 +377,13 @@ async function summarizeCompaction(
|
||||
content: frameSummary(summaryResult.summary),
|
||||
source: compactCheckpointSource(compactionId, sourceCommandId),
|
||||
})
|
||||
// The checkpoint is text-only, so its fixed-heuristic price IS its route
|
||||
// price; comparing it against the span's route price asks the real
|
||||
// question — does the replacement lower the next request's pressure.
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
|
||||
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
|
||||
if (framedSummaryTokenCount >= prepared.shadowedRouteTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedRouteTokenCount})`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic'
|
||||
import type { BasicCompactionConfig } from '@deepseek-ai/dsh-compaction-basic'
|
||||
import { selectCompactableRange } from '@deepseek-ai/dsh-compaction-basic/src/region.ts'
|
||||
import { frameSummary } from '@deepseek-ai/dsh-compaction-basic/src/summarizer.ts'
|
||||
import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compaction-basic/src/summarizer.ts'
|
||||
import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
|
||||
import {
|
||||
@@ -1878,3 +1879,146 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('route-priced image pressure', () => {
|
||||
const IMAGE_VISUAL_TOKENS = 300
|
||||
const IMAGE_HANDLE_TEXT = 'request preview'
|
||||
|
||||
class PricedContextAdapter extends ContextAdapter {
|
||||
override imageRequestPricing(): { priceImages: (images: readonly unknown[]) => Array<{ visualTokens: number; text: string }> } {
|
||||
return {
|
||||
priceImages: images => images.map(() => ({
|
||||
visualTokens: IMAGE_VISUAL_TOKENS,
|
||||
text: IMAGE_HANDLE_TEXT,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pricedContext(contextWindow = 1_000): Context {
|
||||
const ctx = new Context()
|
||||
void new LlmRuntime(ctx)
|
||||
void new TokenMeter(ctx)
|
||||
ctx.llm.registerAdapter([MODEL], new PricedContextAdapter(contextWindow))
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Closed short-text turns whose user messages each carry one image. */
|
||||
function imageConversation(turns = 4): Session {
|
||||
const session = Session.create(SessionId(`image-dense-${turns}`))
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [
|
||||
{ type: 'text', text: `image turn ${turn}` },
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: AttachmentId(`sha256:${String(turn).repeat(8)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 2048,
|
||||
width: 800,
|
||||
height: 800,
|
||||
name: `shot-${turn}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
if (turn === 1) {
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
}
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `ok ${turn}` }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: MODEL, model: MODEL },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn: turns + 1 })
|
||||
return session
|
||||
}
|
||||
|
||||
it('selects an image-dense range only when the routed price counts visual tokens', () => {
|
||||
const session = imageConversation()
|
||||
const routed = pricedContext().tokenMeter.measure(session)
|
||||
const neutral = createContext().tokenMeter.measure(session)
|
||||
|
||||
expect(routed.surfaceTokens).toBeGreaterThan(neutral.surfaceTokens + 4 * IMAGE_VISUAL_TOKENS - 200)
|
||||
expect(routed.nodes.map(node => node.seq)).toEqual(neutral.nodes.map(node => node.seq))
|
||||
expect(routed.nodes.map(node => node.heuristicTokens)).toEqual(neutral.nodes.map(node => node.tokens))
|
||||
|
||||
// The same verbatim tail budget retains almost everything under the
|
||||
// neutral heuristic but forces a cut once visual tokens are counted.
|
||||
expect(selectCompactableRange(session, neutral, 350)).toBeNull()
|
||||
const range = selectCompactableRange(session, routed, 350)
|
||||
expect(range).not.toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a summary larger than the span heuristic when the route price shrinks', async () => {
|
||||
// A single short image message prices below a framed summary under the
|
||||
// fixed heuristic but far above it under the route: the shrink comparison
|
||||
// must ask whether the replacement lowers route pressure.
|
||||
const ctx = pricedContext(1_000)
|
||||
const session = imageConversation(1)
|
||||
const before = ctx.tokenMeter.measure(session)
|
||||
const imageNode = before.nodes[0]!
|
||||
const compact = new TestCompactionEngine(ctx, { auto: false })
|
||||
compact.summary = [{
|
||||
type: 'text',
|
||||
text: 'summary text sized between the heuristic and route prices of the shadowed image message, '
|
||||
+ 'long enough that the fixed heuristic alone would reject it as not smaller '
|
||||
+ 'while the route-priced comparison accepts the pressure reduction.',
|
||||
}]
|
||||
const framed = ctx.tokenMeter.estimateMessage(createUserMessage({
|
||||
content: frameSummary(compact.summary),
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
expect(framed).toBeGreaterThan(imageNode.heuristicTokens)
|
||||
expect(framed).toBeLessThan(imageNode.tokens)
|
||||
|
||||
const result = await compact.compactRegion(imageNode.seq, imageNode.seq, agent(session), SIGNAL)
|
||||
expect(result.shadowedSeqs).toEqual([imageNode.seq])
|
||||
expect(result.shadowedTokenCount).toBe(imageNode.heuristicTokens)
|
||||
})
|
||||
|
||||
it('triggers pressure compaction from routed visual tokens and logs heuristic shadow prices', async () => {
|
||||
const ctx = pricedContext(1_000)
|
||||
const session = imageConversation()
|
||||
const before = ctx.tokenMeter.measure(session)
|
||||
const compact = new TestCompactionEngine(ctx, {
|
||||
auto: false,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 350,
|
||||
})
|
||||
|
||||
// The same history stays below the 800-token threshold without pricing.
|
||||
const neutralResult = await compactIfNeeded(service({
|
||||
auto: false,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 350,
|
||||
}), session)
|
||||
expect(neutralResult).toBeNull()
|
||||
|
||||
const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL)
|
||||
expect(result).not.toBeNull()
|
||||
const summaryEvent = session.events.find(event => event.type === 'compaction/summary')
|
||||
expect(summaryEvent).toBeDefined()
|
||||
const shadowedHeuristic = before.nodes
|
||||
.filter(node => result?.shadowedSeqs.includes(node.seq))
|
||||
.reduce((total, node) => total + node.heuristicTokens, 0)
|
||||
expect(summaryEvent?.data.shadowedTokenCount).toBe(shadowedHeuristic)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1033,6 +1033,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }],
|
||||
returns: 'the provider-owned policy, with normal defaults already resolved.',
|
||||
},
|
||||
{
|
||||
signature: 'imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined',
|
||||
description: 'Resolve provider-side request-image pricing for one exact route, or `undefined` when the provider is unregistered or declares none. Unknown providers degrade to `undefined` rather than throwing because callers price durable history whose route may no longer be mounted.',
|
||||
parameters: [{ name: 'provider', description: 'provider route named by a request header.' }, { name: 'model', description: 'exact model id named by the same header.' }],
|
||||
returns: 'the owning adapter\'s image pricing for the route, when declared.',
|
||||
},
|
||||
{
|
||||
signature: 'async listModels(provider: string): Promise<LlmModelInfo[]>',
|
||||
description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.',
|
||||
@@ -2214,7 +2220,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement',
|
||||
description: 'Measure current request pressure and surface through the durable tail.\n\nProvider usage is reused only when the latest successful call\'s canonical request envelope matches `requestHeader` and its total is no lower than that call\'s full heuristic anchor; otherwise the complete envelope and surface are heuristically repriced.\n\n`requestHeader` affects request pressure only; surface fields always describe the current session surface. Every call clones those positional nodes, so measurement is O(surface).',
|
||||
description: 'Measure current request pressure and surface through the durable tail.\n\nThe effective envelope\'s routed provider/model selects the request-image pricing every node is priced under: a route whose adapter declares image pricing charges each retained image its visual tokens plus its model-visible text, while other routes keep the fixed heuristic. Provider usage is reused only when the latest successful call\'s canonical request envelope matches `requestHeader` and its total is no lower than that call\'s full route-priced anchor; otherwise the complete envelope and surface are repriced.\n\n`requestHeader` replaces the latest logged envelope for pressure and node pricing; the node set always describes the current session surface. Every call clones those positional nodes, so measurement is O(surface).',
|
||||
parameters: [{ name: 'session', description: 'session to replay through its current durable tail.' }, { name: 'requestHeader', description: 'optional effective request envelope replacing the latest logged header.' }],
|
||||
returns: 'a detached deeply immutable pressure and surface measurement.',
|
||||
},
|
||||
@@ -3915,7 +3921,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LlmAdapter',
|
||||
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmCallConfig',
|
||||
@@ -3937,6 +3943,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'LlmFailure',
|
||||
declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmImageRequestPrice',
|
||||
declaration: 'export interface LlmImageRequestPrice {\n visualTokens: number;\n text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmImageRequestPricing',
|
||||
declaration: 'export interface LlmImageRequestPricing {\n priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'LlmModelContext',
|
||||
declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}',
|
||||
@@ -3967,7 +3981,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'LlmRuntime',
|
||||
declaration: 'export class LlmRuntime extends Service {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n async listModels(provider: string): Promise<LlmModelInfo[]>;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
declaration: 'export class LlmRuntime extends Service {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n async listModels(provider: string): Promise<LlmModelInfo[]>;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LspHover',
|
||||
@@ -4983,7 +4997,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SubagentProvider',
|
||||
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>;\n prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>;\n}',
|
||||
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n readonly agentRouteDefaults?: Readonly<{\n provider: string;\n model: string;\n }>;\n start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>;\n prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentReportDelivery',
|
||||
@@ -5239,7 +5253,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'TokenSurfaceNode',
|
||||
declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n}',
|
||||
declaration: 'export interface TokenSurfaceNode {\n readonly seq: number;\n readonly tokens: number;\n readonly heuristicTokens: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TokenUsage',
|
||||
|
||||
@@ -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: 11ee4c775c6565e0842707928683587a1e2f1eb8
|
||||
README.zh.md: 86da6c75891d7e458b870b630db877c799c33127
|
||||
README.md: 5fe9302ce851f35d0a90a9af4cf6e121dd680ab9
|
||||
README.zh.md: 1761afdd62b85b0c20e24199a6573a8a0b2afe5e
|
||||
|
||||
@@ -119,7 +119,7 @@ The selected DeepSeek model receives the harness system prompt, message history,
|
||||
|
||||
#### Token effect
|
||||
|
||||
Provider tokenization governs exact text and image-token input. Reasoning passback carries every reasoned turn's chain of thought into later requests, while dropping over-budget images avoids paying those tokens again; cache-read usage is reported when available.
|
||||
Provider tokenization governs exact text and image-token input. The adapter additionally declares per-route request-image pricing (`imageRequestPricing`): it reproduces the request projection's oldest-first offload from durable byte lengths and prices each retained image with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad) at its projected request dimensions, so the token meter can price image pressure before a request is sent; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while dropping over-budget images avoids paying those tokens again; cache-read usage is reported when available.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提
|
||||
|
||||
#### Token 影响
|
||||
|
||||
精确文本与图片 token 输入取决于提供方 tokenization。推理回传会把每个含推理轮次的思维链带入后续请求,丢弃超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。
|
||||
精确文本与图片 token 输入取决于提供方 tokenization。适配器另外声明按路由的请求图片定价(`imageRequestPricing`):它根据持久字节长度复现请求投影的最旧优先 offload,并按投影后的请求尺寸用官方公布的 v4 视觉计量(14px patch 网格、3:1 降采样、单图 384 token 上限、最坏对齐 pad)为每张保留图片计价,使 token 计量服务能在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个含推理轮次的思维链带入后续请求,丢弃超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
AttachmentId,
|
||||
AttachmentStore,
|
||||
ImageAttachmentRef,
|
||||
ImageRequestPolicy,
|
||||
RequestImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
@@ -38,6 +37,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
|
||||
import { serializeRequest, serializeRequestWithImages } from './serialize.ts'
|
||||
import type { ImageWireLocation, RequestDefaults } from './serialize.ts'
|
||||
import { deepSeekImageRequestPricing, resolveRequestImagePolicy } from './request-pricing.ts'
|
||||
import { DeepSeekFileStore } from './file-store.ts'
|
||||
import type { DeepSeekFilePolicy } from './file-store.ts'
|
||||
import type { DeepSeekFileId } from './file-id.ts'
|
||||
@@ -140,18 +140,8 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_CONTEXT_WINDOW = 1_000_000
|
||||
/** Default per-request output-token cap. */
|
||||
export const DEFAULT_MAX_TOKENS = 256_000
|
||||
/** Default bound on accumulated file-referenced image bytes per request. */
|
||||
export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024
|
||||
/** Default bound on accumulated base64 image payload after Files API fallback. */
|
||||
export const DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
/** Provider request image-count limit. */
|
||||
export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600
|
||||
/** Total-pixel budget matching DeepSeek's normal vision projection. */
|
||||
export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000
|
||||
/** Total-pixel budget matching provider low-detail image input. */
|
||||
export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512
|
||||
/** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */
|
||||
export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
|
||||
/** Deterministic raw-byte removal step. */
|
||||
export const DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM = 64 * 1024 * 1024
|
||||
/** Deterministic base64-byte removal step after Files API fallback. */
|
||||
@@ -220,24 +210,6 @@ function collectImageRefs(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the request-image budgets owned by one DeepSeek model route.
|
||||
* @param model - Advertised model route and its optional image overrides.
|
||||
* @returns Complete pixel and encoded-byte budgets.
|
||||
* @internal
|
||||
*/
|
||||
export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
|
||||
const maxPixels = model.imagePixelBudget === 'low'
|
||||
? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
|
||||
: model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
|
||||
return {
|
||||
maxPixels,
|
||||
maxBytes: model.imageMaxBytes === undefined
|
||||
? DEFAULT_REQUEST_IMAGE_MAX_BYTES
|
||||
: model.imageMaxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRequestImages(
|
||||
options: GenerateOptions,
|
||||
attachments: AttachmentStore,
|
||||
@@ -394,6 +366,18 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
return this.config.options().retryPolicy
|
||||
}
|
||||
|
||||
override imageRequestPricing(_provider: string, model: string): ReturnType<LlmAdapter['imageRequestPricing']> {
|
||||
// The same access resolution the serializer uses, so priced handle and
|
||||
// placeholder text matches what the request actually sends.
|
||||
const attachments = this.config.resolveAttachments?.()
|
||||
const resolveAccess = attachments === undefined
|
||||
? undefined
|
||||
: (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => (
|
||||
this.config.resolveImageAccess?.(attachments, ref)
|
||||
)
|
||||
return deepSeekImageRequestPricing(this.config.options(), model, resolveAccess)
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* DeepSeek v4 vision-token accounting: the provider's published image-token
|
||||
* calculator (api-docs.deepseek.com, Token & Token Usage) ported verbatim.
|
||||
* The provider resizes every request image onto a 14px-patch grid, downsamples
|
||||
* 3:1 per axis, and caps one image at 384 tokens; the port prices the
|
||||
* pad-to-4 alignment at its 3-token upper bound because request pricing has
|
||||
* no preceding-token position. Actual usage remains authoritative.
|
||||
*
|
||||
* @module dsh-llm-deepseek/image-tokens
|
||||
*/
|
||||
|
||||
/** Vision patch edge in pixels. */
|
||||
const PATCH_SIZE = 14
|
||||
/** Per-axis patch-to-token downsampling ratio. */
|
||||
const DOWNSAMPLE_RATIO = 3
|
||||
/** Provider cap on tokens for one request image. */
|
||||
const MAX_IMAGE_TOKENS = 384
|
||||
/** Token-alignment quantum; pricing charges its worst-case `QUANTUM - 1` pad. */
|
||||
const COMPRESS_PAD_TO = 4
|
||||
/** Width is clamped to this multiple of height before grid projection. */
|
||||
const MAX_WIDTH_HEIGHT_RATIO = 8
|
||||
/** Total-pixel floor; smaller images are scaled up before grid projection. */
|
||||
const MIN_PIXELS = 384 * 384
|
||||
|
||||
const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor)
|
||||
const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor)
|
||||
|
||||
interface GridResize {
|
||||
readonly gridHeight: number
|
||||
readonly gridWidth: number
|
||||
readonly bestHeight: number
|
||||
readonly bestWidth: number
|
||||
readonly numTokens: number
|
||||
}
|
||||
|
||||
/** Token count of one grid, including row separators and framing. */
|
||||
function gridTokens(gridHeight: number, gridWidth: number): number {
|
||||
let tokens = gridHeight * (gridWidth + 1) + 2
|
||||
if (gridHeight % 2 === 1) tokens += gridWidth + 1
|
||||
tokens += (ceilDiv(gridHeight, 2) * (gridWidth + 1) % 2) * 2
|
||||
return tokens
|
||||
}
|
||||
|
||||
/** Solve the largest grid within `budget` tokens preserving the aspect ratio. */
|
||||
function solveResizeRatio(height: number, width: number, budget: number): GridResize {
|
||||
const aspect = height / width
|
||||
const idealGridWidth = Math.sqrt((budget - 2) / aspect + 0.25) - 0.5
|
||||
const idealGridHeight = idealGridWidth * aspect
|
||||
let bestHeight: number
|
||||
let bestWidth: number
|
||||
if (idealGridWidth < 1) {
|
||||
const solvedGridWidth = 1
|
||||
let solvedGridHeight = intDiv(budget - 2, solvedGridWidth + 1)
|
||||
// v8 ignore: at the provider budget the one-column solve always lands on
|
||||
// the odd 189-row grid, so the even path is unreachable; kept for parity
|
||||
// with the published solver.
|
||||
/* v8 ignore next */
|
||||
if (solvedGridHeight % 2 === 1) solvedGridHeight -= 1
|
||||
bestWidth = solvedGridWidth * PATCH_SIZE * DOWNSAMPLE_RATIO
|
||||
bestHeight = solvedGridHeight * PATCH_SIZE * DOWNSAMPLE_RATIO
|
||||
/* v8 ignore start -- unreachable at the provider budget: idealGridWidth >= 1
|
||||
bounds the aspect at (budget - 2) / 2, making idealGridHeight >= 2 for
|
||||
every budget this module solves; kept for parity with the published
|
||||
solver. */
|
||||
} else if (idealGridHeight < 2) {
|
||||
const solvedGridHeight = 2
|
||||
const solvedGridWidth = intDiv(budget - 2, solvedGridHeight) - 1
|
||||
if (!(solvedGridWidth > 1)) throw new Error('deepseek image tokens: no grid fits the token budget')
|
||||
bestWidth = solvedGridWidth * PATCH_SIZE * DOWNSAMPLE_RATIO
|
||||
bestHeight = solvedGridHeight * PATCH_SIZE * DOWNSAMPLE_RATIO
|
||||
/* v8 ignore stop */
|
||||
} else {
|
||||
const solvedGridWidth = Math.trunc(idealGridWidth)
|
||||
let solvedGridHeight = Math.trunc(idealGridHeight)
|
||||
if (solvedGridHeight % 2 === 1) solvedGridHeight -= 1
|
||||
const widthScale = solvedGridWidth * PATCH_SIZE * DOWNSAMPLE_RATIO / width
|
||||
const heightScale = solvedGridHeight * PATCH_SIZE * DOWNSAMPLE_RATIO / height
|
||||
const scale = Math.min(widthScale, heightScale)
|
||||
bestWidth = Math.trunc(width * scale / PATCH_SIZE) * PATCH_SIZE
|
||||
bestHeight = Math.trunc(height * scale / PATCH_SIZE) * PATCH_SIZE
|
||||
}
|
||||
const gridHeight = ceilDiv(intDiv(bestHeight, PATCH_SIZE), DOWNSAMPLE_RATIO)
|
||||
const gridWidth = ceilDiv(intDiv(bestWidth, PATCH_SIZE), DOWNSAMPLE_RATIO)
|
||||
return { gridHeight, gridWidth, bestHeight, bestWidth, numTokens: gridTokens(gridHeight, gridWidth) }
|
||||
}
|
||||
|
||||
/** Project padded pixel dimensions onto the largest in-budget token grid. */
|
||||
function safeResize(height: number, width: number, paddedHeight: number, paddedWidth: number): GridResize {
|
||||
const gridHeight = ceilDiv(intDiv(paddedHeight, PATCH_SIZE), DOWNSAMPLE_RATIO)
|
||||
const gridWidth = ceilDiv(intDiv(paddedWidth, PATCH_SIZE), DOWNSAMPLE_RATIO)
|
||||
const pad = COMPRESS_PAD_TO - 1
|
||||
const budget = MAX_IMAGE_TOKENS - pad
|
||||
let result: GridResize = {
|
||||
gridHeight,
|
||||
gridWidth,
|
||||
bestHeight: paddedHeight,
|
||||
bestWidth: paddedWidth,
|
||||
numTokens: gridTokens(gridHeight, gridWidth),
|
||||
}
|
||||
if (result.numTokens > budget) {
|
||||
result = solveResizeRatio(height, width, budget)
|
||||
/* v8 ignore next 4 -- the published solver's safety net; the closed-form
|
||||
solve stays within budget for every geometry the clamps admit. */
|
||||
for (let reduced = budget; result.numTokens > budget; reduced -= 1) {
|
||||
result = solveResizeRatio(height, width, reduced)
|
||||
}
|
||||
}
|
||||
return { ...result, numTokens: result.numTokens + pad }
|
||||
}
|
||||
|
||||
/** One clamp-scale-pad-project pass; the caller iterates it to a fixpoint. */
|
||||
function resizeOnce(width: number, height: number): GridResize {
|
||||
let clampedWidth = width
|
||||
let clampedHeight = height
|
||||
if (clampedWidth > clampedHeight * MAX_WIDTH_HEIGHT_RATIO) {
|
||||
clampedWidth = clampedHeight * MAX_WIDTH_HEIGHT_RATIO
|
||||
}
|
||||
const pixels = clampedWidth * clampedHeight
|
||||
if (pixels < MIN_PIXELS && pixels > 0) {
|
||||
const scale = Math.sqrt(MIN_PIXELS / pixels)
|
||||
clampedWidth = Math.trunc(clampedWidth * scale)
|
||||
clampedHeight = Math.trunc(clampedHeight * scale)
|
||||
}
|
||||
const paddedWidth = ceilDiv(clampedWidth, PATCH_SIZE) * PATCH_SIZE
|
||||
const paddedHeight = ceilDiv(clampedHeight, PATCH_SIZE) * PATCH_SIZE
|
||||
return safeResize(clampedHeight, clampedWidth, paddedHeight, paddedWidth)
|
||||
}
|
||||
|
||||
function sameResize(a: GridResize, b: GridResize): boolean {
|
||||
return a.gridHeight === b.gridHeight
|
||||
&& a.gridWidth === b.gridWidth
|
||||
&& a.bestHeight === b.bestHeight
|
||||
&& a.bestWidth === b.bestWidth
|
||||
&& a.numTokens === b.numTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Vision tokens DeepSeek v4 charges for one request image of the given
|
||||
* dimensions, at the worst-case alignment pad.
|
||||
* @param width - positive integer request-image width in pixels.
|
||||
* @param height - positive integer request-image height in pixels.
|
||||
* @returns the provider vision-token price, at most 384.
|
||||
*/
|
||||
export function deepSeekImageTokens(width: number, height: number): number {
|
||||
let result = resizeOnce(width, height)
|
||||
for (let iteration = 1; iteration < 10; iteration += 1) {
|
||||
const next = resizeOnce(result.bestWidth, result.bestHeight)
|
||||
if (sameResize(next, result)) return result.numTokens
|
||||
result = next
|
||||
}
|
||||
/* v8 ignore next 2 -- the published solver's non-convergence guard; every
|
||||
pass is a projection, so a second identical pass is a fixpoint. */
|
||||
throw new Error(`deepseek image tokens: resize did not converge for ${width}x${height}`)
|
||||
}
|
||||
@@ -30,17 +30,19 @@ import {
|
||||
DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM,
|
||||
DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM,
|
||||
DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM,
|
||||
DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
|
||||
DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES,
|
||||
DEFAULT_MAX_IMAGES_PER_REQUEST,
|
||||
DEFAULT_MAX_REQUEST_FILES_BYTES,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_REQUEST_IMAGE_MAX_BYTES,
|
||||
DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
|
||||
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
||||
DeepSeekAdapter,
|
||||
} from './adapter.ts'
|
||||
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
|
||||
import {
|
||||
DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
|
||||
DEFAULT_MAX_IMAGES_PER_REQUEST,
|
||||
DEFAULT_MAX_REQUEST_FILES_BYTES,
|
||||
DEFAULT_REQUEST_IMAGE_MAX_BYTES,
|
||||
DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
|
||||
} from './request-pricing.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
@@ -51,17 +53,22 @@ export {
|
||||
DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM,
|
||||
DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM,
|
||||
DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM,
|
||||
DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
|
||||
DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES,
|
||||
DEFAULT_MAX_IMAGES_PER_REQUEST,
|
||||
DEFAULT_MAX_REQUEST_FILES_BYTES,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_REQUEST_IMAGE_MAX_BYTES,
|
||||
DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
|
||||
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
||||
DeepSeekAdapter,
|
||||
} from './adapter.ts'
|
||||
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
|
||||
export {
|
||||
DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
|
||||
DEFAULT_MAX_IMAGES_PER_REQUEST,
|
||||
DEFAULT_MAX_REQUEST_FILES_BYTES,
|
||||
DEFAULT_REQUEST_IMAGE_MAX_BYTES,
|
||||
DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
|
||||
deepSeekImageRequestPricing,
|
||||
resolveRequestImagePolicy,
|
||||
} from './request-pricing.ts'
|
||||
export { deepSeekImageTokens } from './image-tokens.ts'
|
||||
export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts'
|
||||
export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts'
|
||||
export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts'
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Provider-side request-image pricing for DeepSeek routes: reproduces the
|
||||
* adapter's deterministic request projection (per-model pixel budget,
|
||||
* oldest-first offload under the raw-byte and count budgets) and prices every
|
||||
* retained image with the published v4 vision-token accounting. Consumed
|
||||
* synchronously by the token meter through `LlmAdapter.imageRequestPricing`;
|
||||
* provider usage remains the authoritative anchor for completed requests.
|
||||
*
|
||||
* @module dsh-llm-deepseek/request-pricing
|
||||
*/
|
||||
|
||||
import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm'
|
||||
import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm'
|
||||
import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef, ImageRequestPolicy } from '@deepseek-ai/dsh-attachment'
|
||||
import { deepSeekImageTokens } from './image-tokens.ts'
|
||||
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
|
||||
|
||||
/** Default bound on accumulated file-referenced image bytes per request. */
|
||||
export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024
|
||||
/** Provider request image-count limit. */
|
||||
export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600
|
||||
/** Total-pixel budget matching DeepSeek's normal vision projection. */
|
||||
export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000
|
||||
/** Total-pixel budget matching provider low-detail image input. */
|
||||
export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512
|
||||
/** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */
|
||||
export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
|
||||
|
||||
/**
|
||||
* Resolve the request-image budgets owned by one DeepSeek model route.
|
||||
* @param model - Advertised model route and its optional image overrides.
|
||||
* @returns Complete pixel and encoded-byte budgets.
|
||||
* @internal
|
||||
*/
|
||||
export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
|
||||
const maxPixels = model.imagePixelBudget === 'low'
|
||||
? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
|
||||
: model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
|
||||
return {
|
||||
maxPixels,
|
||||
maxBytes: model.imageMaxBytes === undefined
|
||||
? DEFAULT_REQUEST_IMAGE_MAX_BYTES
|
||||
: model.imageMaxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one occurrence a text-only route substitutes with deterministic text,
|
||||
* reproducing the `projectImagesForTextModel` substitution `LlmRuntime`
|
||||
* applies before dispatching to a route without the `image` modality.
|
||||
*/
|
||||
function textOnlyPrice(ref: ImageAttachmentRef): LlmImageRequestPrice {
|
||||
return { visualTokens: 0, text: textOnlyImageText(ref) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request-image pricing for one DeepSeek route from a validated
|
||||
* connection snapshot. Uncatalogued and text-only models price every
|
||||
* occurrence as its deterministic text substitution; image-capable models
|
||||
* reproduce the adapter's first-stage oldest-first offload from durable byte
|
||||
* lengths and price retained images by their projected request dimensions,
|
||||
* with each occurrence's handle or placeholder text built through the same
|
||||
* access resolution the serializer uses. The base64 fallback's tighter inline
|
||||
* budget is not reproduced, so a fallback request can only cost less than
|
||||
* this estimate; access paths resolve at pricing time, so a path that changes
|
||||
* before the request only shifts the text price by its own length.
|
||||
* @param connection - validated connection facts of the pricing resolution.
|
||||
* @param model - exact model id named by the request header.
|
||||
* @param resolveAccess - current execution-world access resolution shared with request serialization.
|
||||
* @returns synchronous per-occurrence pricing for the route.
|
||||
*/
|
||||
export function deepSeekImageRequestPricing(
|
||||
connection: DeepSeekConnectionOptions,
|
||||
model: string,
|
||||
resolveAccess?: ImageAttachmentAccessResolver,
|
||||
): LlmImageRequestPricing {
|
||||
const catalogModel = connection.models.find(entry => entry.id === model)
|
||||
if (catalogModel?.inputModalities?.includes('image') !== true) {
|
||||
return { priceImages: images => images.map(textOnlyPrice) }
|
||||
}
|
||||
const policy = resolveRequestImagePolicy(catalogModel)
|
||||
return {
|
||||
priceImages: (images) => {
|
||||
const offloaded = offloadedImagePrefixCount(
|
||||
images.map(ref => Math.min(ref.bytes, policy.maxBytes)),
|
||||
{
|
||||
maxBytes: connection.maxRequestFilesBytes,
|
||||
maxImages: connection.maxImagesPerRequest,
|
||||
byteQuantum: connection.imageOffloadByteQuantum,
|
||||
countQuantum: connection.imageOffloadCountQuantum,
|
||||
},
|
||||
)
|
||||
return images.map((ref, index) => {
|
||||
if (index < offloaded) {
|
||||
return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) }
|
||||
}
|
||||
const dimensions = requestImageDimensions(ref.width, ref.height, policy.maxPixels)
|
||||
return {
|
||||
visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height),
|
||||
text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)),
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-e
|
||||
import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { httpErrorCode, resolveRequestImagePolicy } from '../src/adapter.ts'
|
||||
import { httpErrorCode } from '../src/adapter.ts'
|
||||
import { resolveRequestImagePolicy } from '../src/request-pricing.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
import type { Behavior } from './mock-server.ts'
|
||||
@@ -157,6 +158,33 @@ describe('request image policy', () => {
|
||||
])('resolves route-owned defaults and overrides for %s', (model, expected) => {
|
||||
expect(resolveRequestImagePolicy(model)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('answers image request pricing from the current connection snapshot', () => {
|
||||
const adapter = adapterOf({
|
||||
models: [{ id: 'vision', inputModalities: ['text', 'image'] }],
|
||||
})
|
||||
const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([imageRef])
|
||||
expect(priced).toHaveLength(1)
|
||||
expect(priced?.[0]!.visualTokens).toBeGreaterThan(0)
|
||||
const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([imageRef])
|
||||
expect(textOnly?.[0]!.visualTokens).toBe(0)
|
||||
})
|
||||
|
||||
it('prices descriptor text through the serializer\'s access resolution', () => {
|
||||
const attachments = {} as AttachmentStore
|
||||
const adapter = new DeepSeekAdapter({
|
||||
options: () => resolveAdapterOptions({ models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }),
|
||||
resolveApiKey: () => Promise.resolve('k'),
|
||||
resolveUserId: () => TEST_USER_ID,
|
||||
resolveAttachments: () => attachments,
|
||||
resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef
|
||||
? { readonlyPath: '/world/img.png' }
|
||||
: undefined),
|
||||
prepareExtensions: noExtensions,
|
||||
})
|
||||
const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([imageRef])
|
||||
expect(priced?.[0]?.text).toContain('/world/img.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeepSeekAdapter against a mock server', () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deepSeekImageTokens } from '../src/image-tokens.ts'
|
||||
|
||||
describe('DeepSeek v4 image tokens', () => {
|
||||
// Reference values from the provider's published image token calculator
|
||||
// (api-docs.deepseek.com, Token & Token Usage), at the worst-case pad.
|
||||
it.each([
|
||||
[100, 100, 117],
|
||||
[384, 384, 117],
|
||||
[640, 480, 209],
|
||||
[800, 800, 349],
|
||||
[1024, 768, 357],
|
||||
[1920, 1080, 369],
|
||||
[2000, 2000, 349],
|
||||
[5000, 5000, 349],
|
||||
[300, 50, 101],
|
||||
])('prices %sx%s as %s tokens', (width, height, expected) => {
|
||||
expect(deepSeekImageTokens(width, height)).toBe(expected)
|
||||
})
|
||||
|
||||
it('caps every image at 384 tokens regardless of source size', () => {
|
||||
for (const [width, height] of [[2000, 2000], [5000, 5000], [8192, 8192], [16, 8192]]) {
|
||||
expect(deepSeekImageTokens(width!, height!)).toBeLessThanOrEqual(384)
|
||||
}
|
||||
})
|
||||
|
||||
it('prices small images at the documented scale-up floor', () => {
|
||||
// Below roughly 384x384 total pixels the provider scales up, so a tiny
|
||||
// square costs the same as a 384x384 one.
|
||||
expect(deepSeekImageTokens(100, 100)).toBe(deepSeekImageTokens(384, 384))
|
||||
})
|
||||
|
||||
it('clamps extreme width by the aspect-ratio bound', () => {
|
||||
// Width beyond 8x height projects onto the same clamped grid.
|
||||
expect(deepSeekImageTokens(9000, 1)).toBe(113)
|
||||
expect(deepSeekImageTokens(8192, 100)).toBe(113)
|
||||
})
|
||||
|
||||
it('solves a one-column grid for an extremely tall image', () => {
|
||||
// Height-dominant aspect drives the solver's single-column branch.
|
||||
expect(deepSeekImageTokens(16, 8192)).toBe(381)
|
||||
expect(deepSeekImageTokens(1, 9000)).toBe(381)
|
||||
})
|
||||
|
||||
it('trims an odd solved grid height to the even row count', () => {
|
||||
expect(deepSeekImageTokens(100, 4036)).toBe(253)
|
||||
})
|
||||
|
||||
it('converges through a second projection pass when the first is not a fixpoint', () => {
|
||||
expect(deepSeekImageTokens(4921, 353)).toBe(289)
|
||||
expect(deepSeekImageTokens(97, 7289)).toBe(245)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { offloadedImageText, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { deepSeekImageRequestPricing } from '../src/request-pricing.ts'
|
||||
import { resolveAdapterOptions } from '../src/index.ts'
|
||||
import type { Config } from '../src/index.ts'
|
||||
|
||||
const VISION_MODEL = {
|
||||
id: 'vision',
|
||||
inputModalities: ['text', 'image'] as Array<'text' | 'image'>,
|
||||
}
|
||||
|
||||
function ref(name: string, width: number, height: number, bytes = 1024): ImageAttachmentRef {
|
||||
return {
|
||||
attachmentId: AttachmentId(`sha256:${name.padEnd(8, '0')}`),
|
||||
mediaType: 'image/png',
|
||||
bytes,
|
||||
width,
|
||||
height,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
function connection(config: Omit<Config, 'models'> = {}): ReturnType<typeof resolveAdapterOptions> {
|
||||
return resolveAdapterOptions(Object.assign({ models: [VISION_MODEL] }, config))
|
||||
}
|
||||
|
||||
describe('DeepSeek request-image pricing', () => {
|
||||
it('prices an uncatalogued model as its text-only substitution', () => {
|
||||
const image = ref('photo', 1920, 1080)
|
||||
const prices = deepSeekImageRequestPricing(connection(), 'unlisted').priceImages([image])
|
||||
expect(prices).toEqual([{ visualTokens: 0, text: textOnlyImageText(image) }])
|
||||
})
|
||||
|
||||
it('prices a catalogued text-only model as its text-only substitution', () => {
|
||||
const image = ref('photo', 1920, 1080)
|
||||
const options = resolveAdapterOptions({ models: [{ id: 'text-only' }] })
|
||||
const prices = deepSeekImageRequestPricing(options, 'text-only').priceImages([image])
|
||||
expect(prices).toEqual([{ visualTokens: 0, text: textOnlyImageText(image) }])
|
||||
})
|
||||
|
||||
it('prices a retained image by its projected request dimensions plus its handle text', () => {
|
||||
const image = ref('photo', 1920, 1080)
|
||||
const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image])
|
||||
expect(prices).toEqual([{
|
||||
visualTokens: 369,
|
||||
text: requestImageHandleText(image, { width: 1066, height: 600 }),
|
||||
}])
|
||||
})
|
||||
|
||||
it('honors the low-detail pixel budget preset', () => {
|
||||
const image = ref('photo', 4096, 4096)
|
||||
const options = resolveAdapterOptions({
|
||||
models: [{ ...VISION_MODEL, imagePixelBudget: 'low' as const }],
|
||||
})
|
||||
const prices = deepSeekImageRequestPricing(options, 'vision').priceImages([image])
|
||||
expect(prices[0]!.visualTokens).toBe(201)
|
||||
})
|
||||
|
||||
it('builds handle and placeholder text through the supplied access resolution', () => {
|
||||
const access = { readonlyPath: '/world/attachments/photo.png' }
|
||||
const images = [ref('first', 800, 800), ref('second', 800, 800)]
|
||||
const prices = deepSeekImageRequestPricing(
|
||||
connection({ maxImagesPerRequest: 1, imageOffloadCountQuantum: 1 }),
|
||||
'vision',
|
||||
() => access,
|
||||
).priceImages(images)
|
||||
expect(prices[0]).toEqual({ visualTokens: 0, text: offloadedImageText(images[0]!, access) })
|
||||
expect(prices[1]).toEqual({
|
||||
visualTokens: 349,
|
||||
text: requestImageHandleText(images[1]!, { width: 800, height: 800 }, access),
|
||||
})
|
||||
expect(prices[1]?.text).toContain('/world/attachments/photo.png')
|
||||
})
|
||||
|
||||
it('prices count-offloaded oldest occurrences as their placeholder text', () => {
|
||||
const images = [ref('first', 800, 800), ref('second', 800, 800), ref('third', 800, 800)]
|
||||
const prices = deepSeekImageRequestPricing(
|
||||
connection({ maxImagesPerRequest: 2, imageOffloadCountQuantum: 1 }),
|
||||
'vision',
|
||||
).priceImages(images)
|
||||
expect(prices).toEqual([
|
||||
{ visualTokens: 0, text: offloadedImageText(images[0]!) },
|
||||
{ visualTokens: 349, text: requestImageHandleText(images[1]!, { width: 800, height: 800 }) },
|
||||
{ visualTokens: 349, text: requestImageHandleText(images[2]!, { width: 800, height: 800 }) },
|
||||
])
|
||||
})
|
||||
|
||||
it('caps each occurrence at the per-image byte target before the byte budget', () => {
|
||||
// Each 5 MiB source counts as the 1 MiB request target, so a 2 MiB budget
|
||||
// with a one-byte quantum removes exactly the oldest occurrence.
|
||||
const oversized = 5 * 1024 * 1024
|
||||
const images = [
|
||||
ref('first', 800, 800, oversized),
|
||||
ref('second', 800, 800, oversized),
|
||||
ref('third', 800, 800, oversized),
|
||||
]
|
||||
const prices = deepSeekImageRequestPricing(
|
||||
connection({ maxRequestFilesBytes: 2 * 1024 * 1024, imageOffloadByteQuantum: 1 }),
|
||||
'vision',
|
||||
).priceImages(images)
|
||||
expect(prices.map(price => price.visualTokens)).toEqual([0, 349, 349])
|
||||
expect(prices[0]!.text).toBe(offloadedImageText(images[0]!))
|
||||
})
|
||||
})
|
||||
@@ -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: ef58516790a2723bce34aa1bbae2e1629050f6a5
|
||||
README.zh.md: 8af1240cdb4f3b65f1c0f841ade620c85443129b
|
||||
README.md: 13c42a8ce0e158d721e78fa99f3f2a7b334de764
|
||||
README.zh.md: 803863d7474b034ed326b47ed94b94d89490ad77
|
||||
|
||||
@@ -57,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
|
||||
|
||||
Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers.
|
||||
|
||||
Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. `resolveImageAttachmentAccess()` separately combines an attachment provider's optional host object with a consumer-supplied mapping from that host path into the current tool execution world. The result never enters `RequestImageAttachment` or its `variantId`. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text.
|
||||
Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. `resolveImageAttachmentAccess()` separately combines an attachment provider's optional host object with a consumer-supplied mapping from that host path into the current tool execution world. The result never enters `RequestImageAttachment` or its `variantId`. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text, and the pure `offloadedImagePrefixCount()` exposes the same removal decision so route-owned request pricing reproduces it without building the projection. Adapters whose provider charges visual tokens declare per-route `imageRequestPricing`; `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for the token meter.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。
|
||||
|
||||
每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。`resolveImageAttachmentAccess()` 单独组合附件提供方可选的宿主对象,以及消费方给出的宿主路径到当前工具执行环境的映射。解析结果不进入 `RequestImageAttachment` 或其 `variantId`。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。
|
||||
每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。`resolveImageAttachmentAccess()` 单独组合附件提供方可选的宿主对象,以及消费方给出的宿主路径到当前工具执行环境的映射。解析结果不进入 `RequestImageAttachment` 或其 `variantId`。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本,纯函数 `offloadedImagePrefixCount()` 公开同一移除决策,使路由所属的请求定价无需构建投影即可复现它。提供方对图片收取视觉 token 的适配器声明按路由的 `imageRequestPricing`;`ctx.llm.imageRequestPricing(provider, model)` 为 token 计量服务同步解析它。
|
||||
|
||||
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。
|
||||
|
||||
|
||||
@@ -81,13 +81,13 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string {
|
||||
* attachment id, so one shared version may serve occurrences whose display
|
||||
* names differ.
|
||||
* @param ref - the occurrence's durable normalized attachment.
|
||||
* @param version - exact request image shown beside the text.
|
||||
* @param version - exact request-image dimensions shown beside the text.
|
||||
* @param access - optional path resolved for the current tool execution world.
|
||||
* @returns attachment handle and request-image dimensions.
|
||||
*/
|
||||
export function requestImageHandleText(
|
||||
ref: ImageAttachmentRef,
|
||||
version: RequestImageAttachment,
|
||||
version: Pick<RequestImageAttachment, 'width' | 'height'>,
|
||||
access?: ImageAttachmentAccess,
|
||||
): string {
|
||||
const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.`
|
||||
@@ -229,6 +229,39 @@ export function projectImagesForTextModel(messages: readonly Message[]): readonl
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of oldest image occurrences one request projection removes, in whole
|
||||
* count and byte quanta, once a route budget is exceeded. The result depends
|
||||
* only on the represented lengths, so provider request pricing reproduces the
|
||||
* exact serialization decision without building the projected messages.
|
||||
* @param lengths - represented byte length of every occurrence, in request order.
|
||||
* @param policy - count/byte budgets and removal quanta; unbounded when absent.
|
||||
* @returns how many leading occurrences the projection replaces with placeholders.
|
||||
*/
|
||||
export function offloadedImagePrefixCount(
|
||||
lengths: readonly number[],
|
||||
policy: Pick<RequestImageOffloadPolicy, 'maxImages' | 'maxBytes' | 'countQuantum' | 'byteQuantum'>,
|
||||
): number {
|
||||
const total = lengths.reduce((sum, bytes) => sum + bytes, 0)
|
||||
const excessCount = policy.maxImages === undefined ? 0 : Math.max(0, lengths.length - policy.maxImages)
|
||||
const excessBytes = policy.maxBytes === undefined ? 0 : Math.max(0, total - policy.maxBytes)
|
||||
if (excessCount === 0 && excessBytes === 0) return 0
|
||||
const countQuantum = policy.countQuantum ?? 1
|
||||
const byteQuantum = policy.byteQuantum ?? 1
|
||||
const removeCount = excessCount === 0 ? 0 : Math.ceil(excessCount / countQuantum) * countQuantum
|
||||
const removeBytes = excessBytes === 0 ? 0 : Math.ceil(excessBytes / byteQuantum) * byteQuantum
|
||||
let count = 0
|
||||
let removedBytes = 0
|
||||
for (const imageBytes of lengths) {
|
||||
const byteTargetMet = removeBytes === 0
|
||||
|| (byteQuantum === 1 ? removedBytes >= removeBytes : removedBytes > removeBytes)
|
||||
if (count >= removeCount && byteTargetMet) break
|
||||
removedBytes += imageBytes
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a deterministic transient projection whose oldest images are replaced
|
||||
* in whole count and byte quanta after a route budget is exceeded. The target
|
||||
@@ -246,23 +279,8 @@ export function offloadRequestImagesWithPolicy(
|
||||
): readonly Message[] {
|
||||
const lengths: number[] = []
|
||||
for (const message of messages) collectImageLengths(message.content, lengths, policy)
|
||||
const total = lengths.reduce((sum, bytes) => sum + bytes, 0)
|
||||
const excessCount = policy.maxImages === undefined ? 0 : Math.max(0, lengths.length - policy.maxImages)
|
||||
const excessBytes = policy.maxBytes === undefined ? 0 : Math.max(0, total - policy.maxBytes)
|
||||
if (excessCount === 0 && excessBytes === 0) return messages
|
||||
const countQuantum = policy.countQuantum ?? 1
|
||||
const byteQuantum = policy.byteQuantum ?? 1
|
||||
const removeCount = excessCount === 0 ? 0 : Math.ceil(excessCount / countQuantum) * countQuantum
|
||||
const removeBytes = excessBytes === 0 ? 0 : Math.ceil(excessBytes / byteQuantum) * byteQuantum
|
||||
let count = 0
|
||||
let removedBytes = 0
|
||||
for (const imageBytes of lengths) {
|
||||
const byteTargetMet = removeBytes === 0
|
||||
|| (byteQuantum === 1 ? removedBytes >= removeBytes : removedBytes > removeBytes)
|
||||
if (count >= removeCount && byteTargetMet) break
|
||||
removedBytes += imageBytes
|
||||
count += 1
|
||||
}
|
||||
const count = offloadedImagePrefixCount(lengths, policy)
|
||||
if (count === 0) return messages
|
||||
const remaining = { count }
|
||||
return messages.map((message) => {
|
||||
const content = replaceOldestImages(message.content, remaining, policy.placeholder)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
LlmConfigurableProvider,
|
||||
LlmDiscoveredModel,
|
||||
LlmFailure,
|
||||
LlmImageRequestPricing,
|
||||
LlmModelContext,
|
||||
LlmModelDiscoveryRequest,
|
||||
LlmModelInfo,
|
||||
@@ -207,6 +208,19 @@ export abstract class LlmAdapter {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve provider-side request-image pricing for one exact model route.
|
||||
* The default declares none, so consumers fall back to their own neutral
|
||||
* estimate. Implementations must answer synchronously without I/O; the
|
||||
* token meter resolves this per measurement.
|
||||
* @param _provider - a route passed to `registerAdapter()` for this instance.
|
||||
* @param _model - exact model id passed to {@link GenerateOptions.model}.
|
||||
* @returns route-owned image pricing, or `undefined` when the route declares none.
|
||||
*/
|
||||
imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* List models this adapter can currently advertise for one owned provider.
|
||||
* The result is advisory: an adapter may accept unlisted model ids, and
|
||||
@@ -594,6 +608,19 @@ export class LlmRuntime extends Service {
|
||||
return this.registration(provider).retryPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve provider-side request-image pricing for one exact route, or
|
||||
* `undefined` when the provider is unregistered or declares none. Unknown
|
||||
* providers degrade to `undefined` rather than throwing because callers
|
||||
* price durable history whose route may no longer be mounted.
|
||||
* @param provider - provider route named by a request header.
|
||||
* @param model - exact model id named by the same header.
|
||||
* @returns the owning adapter's image pricing for the route, when declared.
|
||||
*/
|
||||
imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined {
|
||||
return this.adapters.get(provider)?.adapter.imageRequestPricing(provider, model)
|
||||
}
|
||||
|
||||
/** Detach typed adapter-owned modality metadata. */
|
||||
private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined {
|
||||
return modalities === undefined ? undefined : [...modalities]
|
||||
|
||||
@@ -148,6 +148,36 @@ export interface TokenUsage {
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Request price of one ordered image occurrence under one exact model route's
|
||||
* request projection. Every occurrence resolves to the pair the wire actually
|
||||
* carries: provider visual tokens for a retained image, plus the model-visible
|
||||
* text sent with or instead of it (request-preview handle, offload placeholder,
|
||||
* or text-only substitution). The caller prices `text` with its own text
|
||||
* estimator so provider pricing never fixes a text tokenization.
|
||||
*/
|
||||
export interface LlmImageRequestPrice {
|
||||
/** Provider visual tokens for the retained request image; 0 when only text represents this occurrence. */
|
||||
visualTokens: number
|
||||
/** Model-visible text sent for this occurrence, to be priced by the caller's text estimator. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-side request-image pricing for one exact model route. Implemented
|
||||
* by adapters whose provider charges visual tokens; consumers (the token
|
||||
* meter) resolve it synchronously per measurement, so implementations must not
|
||||
* perform I/O.
|
||||
*/
|
||||
export interface LlmImageRequestPricing {
|
||||
/**
|
||||
* Price every image occurrence of one request projection.
|
||||
* @param images - durable image references in request order, one entry per occurrence.
|
||||
* @returns one price per occurrence, aligned by index with `images`.
|
||||
*/
|
||||
priceImages(images: readonly ImageAttachmentRef[]): readonly LlmImageRequestPrice[]
|
||||
}
|
||||
|
||||
/** Display metadata for one registered provider route. */
|
||||
export interface LlmProviderInfo {
|
||||
/** Provider route key used by {@link GenerateOptions.provider}. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CallId,
|
||||
createUserMessage,
|
||||
offloadedImageText,
|
||||
offloadedImagePrefixCount,
|
||||
offloadRequestImagesWithPolicy,
|
||||
projectImagesForTextModel,
|
||||
resolveImageAttachmentAccess,
|
||||
@@ -113,6 +114,19 @@ describe('base64 request-image offload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('offloadedImagePrefixCount', () => {
|
||||
it('removes nothing under unbounded budgets and whole quanta past them', () => {
|
||||
const lengths = [4, 4, 4, 4]
|
||||
expect(offloadedImagePrefixCount(lengths, {})).toBe(0)
|
||||
expect(offloadedImagePrefixCount(lengths, { maxBytes: 16 })).toBe(0)
|
||||
expect(offloadedImagePrefixCount(lengths, { maxImages: 4 })).toBe(0)
|
||||
// One excess image rounds up to the whole count quantum.
|
||||
expect(offloadedImagePrefixCount([...lengths, 4], { maxImages: 4, countQuantum: 2 })).toBe(2)
|
||||
// One excess byte removes a whole byte quantum, crossing the second image.
|
||||
expect(offloadedImagePrefixCount([...lengths, 1], { maxBytes: 16, byteQuantum: 5 })).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('offloadRequestImagesWithPolicy', () => {
|
||||
it('drops 129 MiB to 64 MiB and keeps the removed prefix stable through 192 MiB', () => {
|
||||
const mib = 1024 * 1024
|
||||
|
||||
@@ -266,3 +266,27 @@ describe('model discovery registry', () => {
|
||||
await expect(ctx.llm.discoverModels('llm-example', { provider: 'known-route' })).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('imageRequestPricing resolution', () => {
|
||||
it('resolves the owning adapter declaration and degrades everywhere else to undefined', async () => {
|
||||
const ctx = await setup()
|
||||
const pricing = { priceImages: () => [] }
|
||||
class PricingAdapter extends NoopAdapter {
|
||||
override imageRequestPricing(provider: string, model: string): typeof pricing | undefined {
|
||||
return provider === 'a' && model === 'vision' ? pricing : undefined
|
||||
}
|
||||
}
|
||||
const dispose = ctx.llm.registerAdapter(['a'], new PricingAdapter())
|
||||
ctx.llm.registerAdapter(['plain'], new NoopAdapter())
|
||||
|
||||
expect(ctx.llm.imageRequestPricing('a', 'vision')).toBe(pricing)
|
||||
expect(ctx.llm.imageRequestPricing('a', 'other')).toBeUndefined()
|
||||
// The base adapter declares none.
|
||||
expect(ctx.llm.imageRequestPricing('plain', 'vision')).toBeUndefined()
|
||||
// Unregistered providers degrade instead of throwing: callers price
|
||||
// durable history whose route may no longer be mounted.
|
||||
expect(ctx.llm.imageRequestPricing('missing', 'vision')).toBeUndefined()
|
||||
dispose()
|
||||
expect(ctx.llm.imageRequestPricing('a', 'vision')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/token-meter/README.md
|
||||
README.md: ee80412476c4730e409e6a854d3a78922912bba7
|
||||
README.zh.md: 332cc4df33e3d4da5c786fbaf88af210b02cbc85
|
||||
README.md: 399fad26527e046b163fe23abd8fd83312df29a1
|
||||
README.zh.md: ea5e755bb2b13601180ac40a921ad0ba429c8822
|
||||
|
||||
@@ -15,9 +15,9 @@ The estimator has no settings. It intentionally uses one fixed heuristic: four c
|
||||
- `measure(session, requestHeader?)` returns request pressure and the current priced surface at one consumed-log revision.
|
||||
- `estimateMessage(message)` prices one message with the fixed heuristic.
|
||||
|
||||
`measure()` synchronizes once and returns one detached, deeply immutable snapshot. `totalTokens` is request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override affects pressure fields only; the surface fields still describe the current session. Every call clones the positional nodes, so measurement is O(surface).
|
||||
`measure()` synchronizes once and returns one detached, deeply immutable snapshot. `totalTokens` is request-and-response pressure, while `surfaceTokens` is the surface-only route-priced total and equals the sum of `nodes[].tokens`. A `requestHeader` override selects the priced route and the pressure fields; the node set still describes the current session. Every call clones the positional nodes, so measurement is O(surface).
|
||||
|
||||
The fold tracks full request-header snapshots, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and the chunk seqs cited by each assistant message. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full heuristic anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
|
||||
The fold tracks full request-header snapshots, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and the chunk seqs cited by each assistant message. Each measurement resolves the effective envelope's provider/model to that route's declared request-image pricing through the optional `llm` service: image occurrences are then priced as the visual tokens plus model-visible text the routed request actually sends, while routes and compositions without declared pricing keep the fixed heuristic. Every node also carries `heuristicTokens`, the route-independent fixed price the shadow-price protocol uses for replacements. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements.
|
||||
|
||||
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty `sourceEventSeqs` list means a known empty provider stream, while an absent legacy list conservatively treats the durable assistant output as provider output.
|
||||
|
||||
@@ -33,7 +33,7 @@ Token-meter also owns the browser-safe pure fold from one complete Turn's durabl
|
||||
|
||||
`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero. Its O(1) fold in `surface-projection.ts` tracks appends and consumes the logged shadow price immediately before a replacement; on fully metered logs it agrees with the measurement service's positional plan/commit fold without retaining per-node prices. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`.
|
||||
|
||||
`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total.
|
||||
`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals the sum of `measure().nodes[].heuristicTokens` at every event boundary and compaction shrinks it by its logged shadow price. The route-priced `measure().surfaceTokens` diverges when the routed model reprices images. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total.
|
||||
|
||||
All three units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes all three keys. A composition without the projection seam keeps the measurement service's existing behavior.
|
||||
|
||||
@@ -52,7 +52,7 @@ The [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-proj
|
||||
- name: '@deepseek-ai/dsh-compaction-basic'
|
||||
```
|
||||
|
||||
Both plugins have usable defaults. The meter remains independent of model routing and optional compaction. A deployment configures capacity on its LLM adapter and compaction policy on `dsh-compaction-basic`.
|
||||
Both plugins have usable defaults. The meter consumes only the optional `llm` service, and only to resolve route-declared request-image pricing; compaction remains optional. A deployment configures capacity and image pricing on its LLM adapter and compaction policy on `dsh-compaction-basic`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -64,7 +64,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer.
|
||||
- **The fixed heuristic is approximate** — text without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer; only image occurrences on routes with declared pricing carry provider-exact visual tokens.
|
||||
- **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.
|
||||
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation.
|
||||
- **Missing legacy source seqs are handled conservatively** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
- `measure(session, requestHeader?)` 在同一个已消费日志 revision 上返回请求压力与当前已计价表层。
|
||||
- `estimateMessage(message)` 使用固定启发式规则为一条消息计价。
|
||||
|
||||
`measure()` 会同步一次,并返回一个独立且深度不可变的快照。`totalTokens` 是请求与响应压力,`surfaceTokens` 是仅表层启发式总量,等于 `nodes[].tokens` 之和。`requestHeader` 覆盖只影响压力字段;表层字段仍描述当前会话。每次调用都会克隆带位置的节点,因此测量是 O(surface)。
|
||||
`measure()` 会同步一次,并返回一个独立且深度不可变的快照。`totalTokens` 是请求与响应压力,`surfaceTokens` 是表层的路由定价总量,等于 `nodes[].tokens` 之和。`requestHeader` 覆盖会选择计价路由并影响压力字段;节点集合仍描述当前会话。每次调用都会克隆带位置的节点,因此测量是 O(surface)。
|
||||
|
||||
fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成功 assistant 消息、提供方用量,以及每条 assistant 消息引用的分片 seq。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配,且其总量不低于该调用的完整启发式锚点时,才会复用提供方用量;后续成功会替换较早锚点。否则会对当前 envelope 与表层进行完整估算。表层变更保持相对于匹配锚点的带符号值,包括缩减替换后的负 delta。
|
||||
fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成功 assistant 消息、提供方用量,以及每条 assistant 消息引用的分片 seq。每次计量都会通过可选的 `llm` 服务把生效 envelope 的 provider/model 解析为该路由声明的请求图片定价:图片出现处按路由请求实际发送的视觉 token 加模型可见文本计价,未声明定价的路由与组合保持固定启发式规则。每个节点还携带与路由无关的固定价格 `heuristicTokens`,供影子价协议为替换计价。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配,且其总量不低于该调用的完整路由定价锚点时,才会复用提供方用量;后续成功会替换较早锚点。否则会对当前 envelope 与表层进行完整估算。表层变更保持相对于匹配锚点(按同一路由重新定价)的带符号值,包括缩减替换后的负 delta。
|
||||
|
||||
用量计量会求和不重叠的输入、cache-read、cache-write 与输出 bucket;不会再次添加推理(reasoning)。每次成功调用都会记录一个 assistant 锚点,包括无内容调用。显式的空 `sourceEventSeqs` 列表表示已知空提供方流;遗留记录缺少该列表时,fold 会保守地将持久 assistant 输出视为提供方输出。
|
||||
|
||||
@@ -33,7 +33,7 @@ token-meter 还拥有一份可安全用于浏览器的纯 fold,将一个完整
|
||||
|
||||
`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,并将下界钳制为零。它在 `surface-projection.ts` 中的 O(1) 折叠会跟踪追加,并消费紧邻替换之前记录的影子价;在完整计量的日志上,它无需保留逐节点价格也能与测量服务的带位置 plan/commit 折叠一致。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到再完成一整个轮次为止。占用率展示读取 `projectedTokens`。
|
||||
|
||||
`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
|
||||
`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().nodes[].heuristicTokens` 之和,压缩会按记录的影子价缩小该值。路由定价的 `measure().surfaceTokens` 在路由模型重新为图片计价时会与该值不同。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值。它们加起来不等于 `projectedTokens`,后者的提供方锚点体现了这些明细行仍然带有的误差(按「4 字符 ≈ 1 token」计价时,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
|
||||
|
||||
三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。
|
||||
|
||||
@@ -52,7 +52,7 @@ token-meter 还拥有一份可安全用于浏览器的纯 fold,将一个完整
|
||||
- name: '@deepseek-ai/dsh-compaction-basic'
|
||||
```
|
||||
|
||||
两个插件都有可用默认值。meter 保持与模型路由和可选压缩无关。部署会在 LLM(大语言模型)适配器上配置容量,并在 `dsh-compaction-basic` 上配置压缩策略。
|
||||
两个插件都有可用默认值。meter 只消费可选的 `llm` 服务,且仅用于解析路由声明的请求图片定价;压缩保持可选。部署会在 LLM(大语言模型)适配器上配置容量与图片定价,并在 `dsh-compaction-basic` 上配置压缩策略。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -64,7 +64,7 @@ token-meter 还拥有一份可安全用于浏览器的纯 fold,将一个完整
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **固定启发式规则是近似值**:没有可复用提供方用量的内容按字符数加结构开销计价,而不是使用精确提供方 tokenizer 或请求 serializer。
|
||||
- **固定启发式规则是近似值**:没有可复用提供方用量的文本按字符数加结构开销计价,而不是使用精确提供方 tokenizer 或请求 serializer;只有声明了定价的路由上的图片出现处携带提供方精确的视觉 token。
|
||||
- **每次测量都会克隆当前表层**:一致且不可变的快照使读取成为 O(surface),包括低于阈值的压力检查。
|
||||
- **提供方用量只能为完全相同的规范 envelope 复用**:提示词、前缀、工具、提供方、模型或调用配置变更都会有意回退到完整启发式估算。
|
||||
- **保守处理缺少源事件 seq 的遗留记录**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确分片流。
|
||||
|
||||
@@ -46,9 +46,11 @@ const breakdownSchema = z.object({
|
||||
*
|
||||
* Envelope figures are last-wins per `request/header`; the message figure
|
||||
* rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy
|
||||
* projection uses — so fully metered logs equal `measure().surfaceTokens` at
|
||||
* every event boundary and compaction shrinks the figure by its logged shadow
|
||||
* price. A replacement without a claim preserves the previous total. The
|
||||
* projection uses — so fully metered logs equal the sum of
|
||||
* `measure().nodes[].heuristicTokens` at every event boundary and compaction
|
||||
* shrinks the figure by its logged shadow price; the route-priced
|
||||
* `measure().surfaceTokens` deliberately diverges by the routed model's image
|
||||
* repricing. A replacement without a claim preserves the previous total. The
|
||||
* state is a fixed handful of numbers, so the persisted checkpoint stays
|
||||
* O(1) over the session's life.
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,17 @@ const BLOCK_OVERHEAD = 4
|
||||
/** Role-field framing overhead added to every priced message. */
|
||||
export const ROLE_OVERHEAD = 4
|
||||
|
||||
/**
|
||||
* Structural JSON price of one block outside the typed pricing arms: the
|
||||
* fixed heuristic for merge-extended blocks and for image references, whose
|
||||
* request price is route-owned rather than fixed.
|
||||
* @param block - block to price without mutation.
|
||||
* @returns heuristic tokens for the block's JSON structure.
|
||||
*/
|
||||
export function estimateStructuralBlock(block: ContentBlock): number {
|
||||
return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Price content blocks recursively under the fixed density heuristic.
|
||||
* @param blocks - content blocks to price without mutation.
|
||||
@@ -40,9 +51,10 @@ export function estimateContent(blocks: readonly ContentBlock[]): number {
|
||||
tokens += estimateContent(block.content) + BLOCK_OVERHEAD
|
||||
break
|
||||
default:
|
||||
// ContentBlockMap is merge-extensible; unknown blocks retain a
|
||||
// ContentBlockMap is merge-extensible; unknown blocks (and image
|
||||
// references, whose request price is route-owned) retain a
|
||||
// conservative structural JSON price under the fixed heuristic.
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
|
||||
tokens += estimateStructuralBlock(block)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: resolves the optional projection registry Context declaration.
|
||||
@@ -16,27 +16,36 @@ import type {
|
||||
TokenMeasurement,
|
||||
TokenMeasurementBaseline,
|
||||
TokenMeterConfig,
|
||||
TokenSurfaceNode,
|
||||
} from './types.ts'
|
||||
import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts'
|
||||
import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts'
|
||||
import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts'
|
||||
import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts'
|
||||
import type { MeterSurfaceNode } from './surface-fold.ts'
|
||||
import { priceSurface } from './route-pricing.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
/**
|
||||
* Raw anchor facts captured at the latest successful call; the baseline is
|
||||
* derived per measurement so the anchored surface reprices under the same
|
||||
* route pricing as the current surface it is compared with.
|
||||
*/
|
||||
interface MeasurementAnchor {
|
||||
readonly header: EpochHeader | undefined
|
||||
readonly surfaceTokens: number
|
||||
readonly baseline: Exclude<TokenMeasurementBaseline, { kind: 'none' }>
|
||||
/** Surface snapshot the anchored request was derived from. */
|
||||
readonly nodes: readonly MeterSurfaceNode[]
|
||||
/** Fixed-heuristic price of the call's provider output. */
|
||||
readonly assistantTokens: number
|
||||
/** Provider usage of the call, when it reported one under a known header. */
|
||||
readonly usage: TokenUsage | undefined
|
||||
}
|
||||
|
||||
interface ReplayState {
|
||||
consumedEvents: number
|
||||
header: EpochHeader | undefined
|
||||
surface: TokenSurfaceNode[]
|
||||
surfaceTokens: number
|
||||
stepStart: { turn: number; step: number; surfaceTokens: number } | undefined
|
||||
surface: MeterSurfaceNode[]
|
||||
stepStart: { turn: number; step: number; nodes: readonly MeterSurfaceNode[] } | undefined
|
||||
anchor: MeasurementAnchor | undefined
|
||||
}
|
||||
|
||||
@@ -100,14 +109,18 @@ export class TokenMeter extends Service {
|
||||
/**
|
||||
* Measure current request pressure and surface through the durable tail.
|
||||
*
|
||||
* Provider usage is reused only when the latest successful call's canonical
|
||||
* request envelope matches `requestHeader` and its total is no lower than
|
||||
* that call's full heuristic anchor; otherwise the complete envelope and
|
||||
* surface are heuristically repriced.
|
||||
* The effective envelope's routed provider/model selects the request-image
|
||||
* pricing every node is priced under: a route whose adapter declares image
|
||||
* pricing charges each retained image its visual tokens plus its
|
||||
* model-visible text, while other routes keep the fixed heuristic. Provider
|
||||
* usage is reused only when the latest successful call's canonical request
|
||||
* envelope matches `requestHeader` and its total is no lower than that
|
||||
* call's full route-priced anchor; otherwise the complete envelope and
|
||||
* surface are repriced.
|
||||
*
|
||||
* `requestHeader` affects request pressure only; surface fields always
|
||||
* describe the current session surface. Every call clones those positional
|
||||
* nodes, so measurement is O(surface).
|
||||
* `requestHeader` replaces the latest logged envelope for pressure and node
|
||||
* pricing; the node set always describes the current session surface. Every
|
||||
* call clones those positional nodes, so measurement is O(surface).
|
||||
*
|
||||
* @param session - session to replay through its current durable tail.
|
||||
* @param requestHeader - optional effective request envelope replacing the latest logged header.
|
||||
@@ -118,20 +131,33 @@ export class TokenMeter extends Service {
|
||||
const header = requestHeader === undefined
|
||||
? state.header
|
||||
: canonicalHeader(requestHeader)
|
||||
const pricing = this._routeImagePricing(header)
|
||||
const surface = priceSurface(state.surface, pricing)
|
||||
const anchor = state.anchor
|
||||
|
||||
let baseline: TokenMeasurementBaseline
|
||||
let surfaceDeltaTokens: number
|
||||
if (anchor !== undefined && optionalHeaderEquals(anchor.header, header)) {
|
||||
baseline = anchor.baseline
|
||||
surfaceDeltaTokens = state.surfaceTokens - anchor.surfaceTokens
|
||||
} else if (header === undefined && state.surfaceTokens === 0) {
|
||||
// Matching headers share one route, so the anchored snapshot reprices
|
||||
// under the same pricing as the current surface and the signed delta
|
||||
// compares like with like.
|
||||
const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing).surfaceTokens
|
||||
+ anchor.assistantTokens
|
||||
const estimatedAnchorTokens = estimateHeader(header) + anchorSurfaceTokens
|
||||
const usage = anchor.usage
|
||||
// Signed heuristic deltas remain conservative only from an anchor
|
||||
// that is at least as large as the matching full heuristic price.
|
||||
baseline = usage !== undefined && usageTokens(usage) >= estimatedAnchorTokens
|
||||
? { kind: 'usage', tokens: usageTokens(usage), usage }
|
||||
: { kind: 'estimated', tokens: estimatedAnchorTokens }
|
||||
surfaceDeltaTokens = surface.surfaceTokens - anchorSurfaceTokens
|
||||
} else if (header === undefined && surface.surfaceTokens === 0) {
|
||||
baseline = { kind: 'none', tokens: 0 }
|
||||
surfaceDeltaTokens = 0
|
||||
} else {
|
||||
baseline = {
|
||||
kind: 'estimated',
|
||||
tokens: estimateHeader(header) + state.surfaceTokens,
|
||||
tokens: estimateHeader(header) + surface.surfaceTokens,
|
||||
}
|
||||
surfaceDeltaTokens = 0
|
||||
}
|
||||
@@ -141,11 +167,18 @@ export class TokenMeter extends Service {
|
||||
baseline,
|
||||
surfaceDeltaTokens,
|
||||
totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens),
|
||||
surfaceTokens: state.surfaceTokens,
|
||||
nodes: state.surface,
|
||||
surfaceTokens: surface.surfaceTokens,
|
||||
nodes: surface.nodes,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Resolve the routed model's image pricing, when the llm service and route declare one. */
|
||||
private _routeImagePricing(header: EpochHeader | undefined): LlmImageRequestPricing | undefined {
|
||||
const config = header?.config
|
||||
if (config === undefined) return undefined
|
||||
return this.ctx.get('llm')?.imageRequestPricing(config.provider, config.model)
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristically price one model-visible message (instance face of the pure
|
||||
* `estimateMessage` export from `estimate.ts`).
|
||||
@@ -164,7 +197,6 @@ export class TokenMeter extends Service {
|
||||
consumedEvents: 0,
|
||||
header: undefined,
|
||||
surface: [],
|
||||
surfaceTokens: 0,
|
||||
stepStart: undefined,
|
||||
anchor: undefined,
|
||||
}
|
||||
@@ -200,7 +232,7 @@ export class TokenMeter extends Service {
|
||||
`token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`,
|
||||
)
|
||||
}
|
||||
nextStepStart = { ...event.data, surfaceTokens: state.surfaceTokens }
|
||||
nextStepStart = { ...event.data, nodes: [...state.surface] }
|
||||
break
|
||||
case 'step/end':
|
||||
if (state.stepStart === undefined
|
||||
@@ -230,32 +262,18 @@ export class TokenMeter extends Service {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const eventTokens = plan!.tokens
|
||||
if (event.data.usage !== undefined && nextHeader !== undefined) {
|
||||
const providerAssistantTokens = this._estimateProviderAssistant(
|
||||
session,
|
||||
event,
|
||||
eventTokens,
|
||||
)
|
||||
const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens
|
||||
const providerTokens = usageTokens(event.data.usage)
|
||||
const estimatedAnchorTokens = estimateHeader(nextHeader) + anchorSurfaceTokens
|
||||
nextAnchor = {
|
||||
header: nextHeader,
|
||||
surfaceTokens: anchorSurfaceTokens,
|
||||
// Signed heuristic deltas remain conservative only from an anchor
|
||||
// that is at least as large as the matching full heuristic price.
|
||||
baseline: providerTokens >= estimatedAnchorTokens
|
||||
? { kind: 'usage', tokens: providerTokens, usage: event.data.usage }
|
||||
: { kind: 'estimated', tokens: estimatedAnchorTokens },
|
||||
nodes: stepStart.nodes,
|
||||
assistantTokens: this._estimateProviderAssistant(session, event, eventTokens),
|
||||
usage: event.data.usage,
|
||||
}
|
||||
} else {
|
||||
const anchorSurfaceTokens = stepStart.surfaceTokens + eventTokens
|
||||
nextAnchor = {
|
||||
header: nextHeader,
|
||||
surfaceTokens: anchorSurfaceTokens,
|
||||
baseline: {
|
||||
kind: 'estimated',
|
||||
tokens: estimateHeader(nextHeader) + anchorSurfaceTokens,
|
||||
},
|
||||
nodes: stepStart.nodes,
|
||||
assistantTokens: eventTokens,
|
||||
usage: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +282,6 @@ export class TokenMeter extends Service {
|
||||
state.stepStart = nextStepStart
|
||||
if (plan !== undefined) {
|
||||
commitSurfaceTokens(state.surface, plan)
|
||||
state.surfaceTokens += plan.deltaTokens
|
||||
}
|
||||
state.anchor = nextAnchor
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ export const inject = ['invariants']
|
||||
* not be monotone when a final sample corrects an earlier chunk, and the
|
||||
* composition fold prices through the same `estimate.ts` heuristic as the
|
||||
* measurement service and subtracts producer-logged shadow prices derived
|
||||
* from that service's own nodes, which makes its message figure equal
|
||||
* `measure().surfaceTokens` by construction rather than by a relation worth
|
||||
* observing at runtime.
|
||||
* from that service's own fixed-heuristic node prices, which makes its
|
||||
* message figure equal the sum of `measure().nodes[].heuristicTokens` by
|
||||
* construction rather than by a relation worth observing at runtime; the
|
||||
* route-priced `surfaceTokens` deliberately diverges by the routed model's
|
||||
* image repricing.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Route-aware surface pricing: projects the fold's fixed-heuristic nodes onto
|
||||
* the routed model's request, replacing every image occurrence's structural
|
||||
* price with the route's declared visual tokens plus the model-visible text it
|
||||
* actually sends. Without declared pricing every node keeps its fixed
|
||||
* heuristic price, so provider-neutral behavior is unchanged.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-token-meter/route-pricing
|
||||
*/
|
||||
|
||||
import type { LlmImageRequestPricing } from '@deepseek-ai/dsh-llm'
|
||||
import { estimateContent } from './estimate.ts'
|
||||
import type { MeterSurfaceNode } from './surface-fold.ts'
|
||||
import type { TokenSurfaceNode } from './types.ts'
|
||||
|
||||
/** One surface priced for a request route: public nodes plus their total. */
|
||||
export interface PricedSurface {
|
||||
/** Positional nodes carrying both the route price and the fixed-heuristic price. */
|
||||
readonly nodes: TokenSurfaceNode[]
|
||||
/** Sum of the route prices across the surface. */
|
||||
readonly surfaceTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one ordered surface under a route's request-image pricing.
|
||||
* @param nodes - the fold's current or snapshotted surface, in model-visible order.
|
||||
* @param pricing - the routed model's image pricing, or undefined to keep the fixed heuristic.
|
||||
* @returns detached public nodes and their route-priced total.
|
||||
* @throws when the pricing answers a different occurrence count than it was
|
||||
* asked — misalignment would silently misprice nodes, so it must fail loud.
|
||||
*/
|
||||
export function priceSurface(
|
||||
nodes: readonly MeterSurfaceNode[],
|
||||
pricing: LlmImageRequestPricing | undefined,
|
||||
): PricedSurface {
|
||||
const images = pricing === undefined ? [] : nodes.flatMap(node => node.images)
|
||||
if (pricing === undefined || images.length === 0) {
|
||||
let surfaceTokens = 0
|
||||
const publicNodes = nodes.map((node) => {
|
||||
surfaceTokens += node.heuristicTokens
|
||||
return { seq: node.seq, tokens: node.heuristicTokens, heuristicTokens: node.heuristicTokens }
|
||||
})
|
||||
return { nodes: publicNodes, surfaceTokens }
|
||||
}
|
||||
const prices = pricing.priceImages(images)
|
||||
if (prices.length !== images.length) {
|
||||
throw new Error(
|
||||
`token meter: route image pricing answered ${prices.length} prices for ${images.length} occurrences`,
|
||||
)
|
||||
}
|
||||
let cursor = 0
|
||||
let surfaceTokens = 0
|
||||
const publicNodes = nodes.map((node) => {
|
||||
let tokens = node.heuristicTokens
|
||||
if (node.images.length > 0) {
|
||||
tokens = node.imageFreeTokens
|
||||
for (let occurrence = 0; occurrence < node.images.length; occurrence += 1) {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- length equality is asserted above
|
||||
const price = prices[cursor]!
|
||||
cursor += 1
|
||||
tokens += price.visualTokens + estimateContent([{ type: 'text', text: price.text }])
|
||||
}
|
||||
}
|
||||
surfaceTokens += tokens
|
||||
return { seq: node.seq, tokens, heuristicTokens: node.heuristicTokens }
|
||||
})
|
||||
return { nodes: publicNodes, surfaceTokens }
|
||||
}
|
||||
@@ -4,20 +4,35 @@
|
||||
* units do NOT share this fold — their state must stay O(1) for the
|
||||
* persisted checkpoint, so they ride `surface-projection.ts`'s shadow-price
|
||||
* protocol; the two agree because both price through `estimate.ts` and every
|
||||
* logged shadow price derives from this fold's nodes.
|
||||
* logged shadow price derives from this fold's fixed-heuristic node prices.
|
||||
*
|
||||
* The fold is a plan/commit pair: {@link planSurfaceTokens} runs every
|
||||
* fallible step read-only and {@link commitSurfaceTokens} mutates in place,
|
||||
* so a throw leaves the caller's state untouched and the same malformed
|
||||
* event fails identically on every retry.
|
||||
* Nodes also carry their durable image occurrences and image-free heuristic
|
||||
* price, so `measure()` can reprice image content for the routed model.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-token-meter/surface-fold
|
||||
*/
|
||||
|
||||
import { deriveEventMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenSurfaceNode } from './types.ts'
|
||||
import { estimateMessage } from './estimate.ts'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { estimateMessage, estimateStructuralBlock } from './estimate.ts'
|
||||
|
||||
/** One priced surface node with the image occurrences route pricing replaces. */
|
||||
export interface MeterSurfaceNode {
|
||||
/** Durable sequence number of the surface event. */
|
||||
readonly seq: number
|
||||
/** Fixed-heuristic price of the node's exact message. */
|
||||
readonly heuristicTokens: number
|
||||
/** Fixed-heuristic price with every image occurrence's structural price removed. */
|
||||
readonly imageFreeTokens: number
|
||||
/** Durable image occurrences in message order; empty for image-free nodes. */
|
||||
readonly images: readonly ImageAttachmentRef[]
|
||||
}
|
||||
|
||||
/** One validated surface transition that has not mutated the priced surface yet. */
|
||||
export interface SurfaceTokenPlan {
|
||||
@@ -26,11 +41,39 @@ export interface SurfaceTokenPlan {
|
||||
/** Signed change in the surface total: `tokens` minus anything shadowed. */
|
||||
readonly deltaTokens: number
|
||||
/** The priced node the commit inserts for this event. */
|
||||
readonly node: TokenSurfaceNode
|
||||
readonly node: MeterSurfaceNode
|
||||
/** Commit position: `append`, or the inclusive replaced index range. */
|
||||
readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number }
|
||||
}
|
||||
|
||||
/** Collect image occurrences recursively and total their structural prices. */
|
||||
function collectImages(blocks: readonly ContentBlock[], images: ImageAttachmentRef[]): number {
|
||||
let structuralTokens = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'image') {
|
||||
images.push(block.attachment)
|
||||
structuralTokens += estimateStructuralBlock(block)
|
||||
} else if (block.type === 'tool-result') {
|
||||
structuralTokens += collectImages(block.content, images)
|
||||
}
|
||||
}
|
||||
return structuralTokens
|
||||
}
|
||||
|
||||
/** Build one priced node from a surface event's derived message. */
|
||||
function analyzeNode(seq: number, message: Message | null): MeterSurfaceNode {
|
||||
if (message === null) return { seq, heuristicTokens: 0, imageFreeTokens: 0, images: [] }
|
||||
const heuristicTokens = estimateMessage(message)
|
||||
const images: ImageAttachmentRef[] = []
|
||||
const imageStructuralTokens = collectImages(message.content, images)
|
||||
return {
|
||||
seq,
|
||||
heuristicTokens,
|
||||
imageFreeTokens: heuristicTokens - imageStructuralTokens,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and price one surface event without mutating the surface.
|
||||
* @param nodes - the priced surface preceding this event, in model-visible order.
|
||||
@@ -41,12 +84,11 @@ export interface SurfaceTokenPlan {
|
||||
* corruption and must fail loud rather than skip the event.
|
||||
*/
|
||||
export function planSurfaceTokens(
|
||||
nodes: readonly TokenSurfaceNode[],
|
||||
nodes: readonly MeterSurfaceNode[],
|
||||
event: SurfaceEvent,
|
||||
): SurfaceTokenPlan {
|
||||
const message = deriveEventMessage(event)
|
||||
const tokens = message === null ? 0 : estimateMessage(message)
|
||||
const node = { seq: event.seq, tokens }
|
||||
const node = analyzeNode(event.seq, deriveEventMessage(event))
|
||||
const tokens = node.heuristicTokens
|
||||
const op = event.surfaceOp
|
||||
if (op === 'append') {
|
||||
return { tokens, deltaTokens: tokens, node, target: 'append' }
|
||||
@@ -58,9 +100,9 @@ export function planSurfaceTokens(
|
||||
`token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`,
|
||||
)
|
||||
}
|
||||
let removed = 0
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- startIdx..endIdx are validated indices
|
||||
for (let index = startIdx; index <= endIdx; index += 1) removed += nodes[index]!.tokens
|
||||
const removed = nodes
|
||||
.slice(startIdx, endIdx + 1)
|
||||
.reduce((total, candidate) => total + candidate.heuristicTokens, 0)
|
||||
return { tokens, deltaTokens: tokens - removed, node, target: { startIdx, endIdx } }
|
||||
}
|
||||
|
||||
@@ -70,7 +112,7 @@ export function planSurfaceTokens(
|
||||
* @param nodes - the exact priced surface the plan was built against.
|
||||
* @param plan - the transition returned by {@link planSurfaceTokens}.
|
||||
*/
|
||||
export function commitSurfaceTokens(nodes: TokenSurfaceNode[], plan: SurfaceTokenPlan): void {
|
||||
export function commitSurfaceTokens(nodes: MeterSurfaceNode[], plan: SurfaceTokenPlan): void {
|
||||
if (plan.target === 'append') {
|
||||
nodes.push(plan.node)
|
||||
return
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface TokenMeasurement {
|
||||
readonly surfaceDeltaTokens: number
|
||||
/** Non-negative current request-and-response pressure. */
|
||||
readonly totalTokens: number
|
||||
/** Total heuristic tokens across the current surface. */
|
||||
/** Total route-priced request tokens across the current surface; equals the sum of the node prices. */
|
||||
readonly surfaceTokens: number
|
||||
/** Current surface nodes in positional head-to-tail order. */
|
||||
readonly nodes: readonly TokenSurfaceNode[]
|
||||
@@ -37,6 +37,17 @@ export interface TokenMeasurement {
|
||||
export interface TokenSurfaceNode {
|
||||
/** Durable sequence number of the surface event. */
|
||||
readonly seq: number
|
||||
/** Heuristic tokens for the exact message projected by this node. */
|
||||
/**
|
||||
* Request-pressure tokens for the exact message projected by this node under
|
||||
* the measured route: image occurrences carry the route's declared visual
|
||||
* price when the routed adapter declares one, and the fixed heuristic
|
||||
* otherwise. Trigger, retention, and range selection all read this price.
|
||||
*/
|
||||
readonly tokens: number
|
||||
/**
|
||||
* Fixed-heuristic tokens for the same message, independent of any route.
|
||||
* The shadow-price protocol prices replacements with this value so the O(1)
|
||||
* projection fold stays in agreement with its own appends.
|
||||
*/
|
||||
readonly heuristicTokens: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LlmRuntime, LlmAdapter, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmImageRequestPricing, Message, StreamChunk, TokenUsage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import { estimateContent, estimateMessage } from '../src/estimate.ts'
|
||||
|
||||
/** Adapter double declaring fixed per-occurrence image prices for one route. */
|
||||
class PricingAdapter extends LlmAdapter {
|
||||
constructor(private readonly pricing: (model: string) => LlmImageRequestPricing | undefined) {
|
||||
super()
|
||||
}
|
||||
|
||||
override imageRequestPricing(_provider: string, model: string): LlmImageRequestPricing | undefined {
|
||||
return this.pricing(model)
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('the pricing adapter double does not stream')
|
||||
}
|
||||
}
|
||||
|
||||
const VISUAL_TOKENS = 100
|
||||
const HANDLE_TEXT = 'Image handle text'
|
||||
|
||||
const fixedPricing: LlmImageRequestPricing = {
|
||||
priceImages: images => images.map(() => ({ visualTokens: VISUAL_TOKENS, text: HANDLE_TEXT })),
|
||||
}
|
||||
|
||||
function imageRef(name: string): ImageAttachmentRef {
|
||||
return {
|
||||
attachmentId: AttachmentId(`sha256:${name.padEnd(8, '0')}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 2048,
|
||||
width: 800,
|
||||
height: 800,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
function imageMessage(name: string, text = 'look at this'): UserMessage {
|
||||
return createUserMessage({
|
||||
content: [
|
||||
{ type: 'text', text },
|
||||
{ type: 'image', attachment: imageRef(name) },
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
}
|
||||
|
||||
function header(model: string): EpochHeader {
|
||||
return canonicalHeader({ config: { provider: 'mock', model } })
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
meter: TokenMeter
|
||||
session: Session
|
||||
}
|
||||
|
||||
async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
new SessionProjectionRegistry(ctx)
|
||||
const llm = new LlmRuntime(ctx)
|
||||
llm.registerAdapter(['mock'], new PricingAdapter(pricing))
|
||||
const meter = new TokenMeter(ctx)
|
||||
return { meter, session: Session.create(SessionId('route-priced')) }
|
||||
}
|
||||
|
||||
/** Route price of one image-bearing message under the fixed pricing double. */
|
||||
function routedMessageTokens(message: Message): number {
|
||||
const imageFree = estimateMessage({
|
||||
...message,
|
||||
content: message.content.filter(block => block.type !== 'image'),
|
||||
})
|
||||
return imageFree + VISUAL_TOKENS + estimateContent([{ type: 'text', text: HANDLE_TEXT }])
|
||||
}
|
||||
|
||||
function appendSuccessfulCall(session: Session, value: EpochHeader, usage?: TokenUsage): void {
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', { header: value, reason: 'initial' })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'answer' }],
|
||||
source: { kind: 'model', provider: value.config.provider, model: value.config.model },
|
||||
}),
|
||||
...usage === undefined ? {} : { usage },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
}
|
||||
|
||||
describe('route-aware image pricing', () => {
|
||||
it('prices a first multimodal request estimate with the routed visual tokens', async () => {
|
||||
const { meter, session } = await harness(() => fixedPricing)
|
||||
const message = imageMessage('photo')
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('request/header', { header: header('vision'), reason: 'initial' })
|
||||
|
||||
const measurement = meter.measure(session)
|
||||
const expectedNode = routedMessageTokens(message)
|
||||
expect(measurement.nodes).toHaveLength(1)
|
||||
const node = measurement.nodes[0]!
|
||||
expect(node.tokens).toBe(expectedNode)
|
||||
expect(node.heuristicTokens).toBe(estimateMessage(message))
|
||||
expect(node.tokens).toBeGreaterThan(node.heuristicTokens)
|
||||
expect(measurement.baseline.kind).toBe('estimated')
|
||||
expect(measurement.surfaceTokens).toBe(expectedNode)
|
||||
expect(measurement.totalTokens).toBe(expectedNode)
|
||||
})
|
||||
|
||||
it('adds a post-anchor image at its routed price on top of provider usage', async () => {
|
||||
const { meter, session } = await harness(() => fixedPricing)
|
||||
const usage: TokenUsage = { inputTokens: 5000, outputTokens: 50 }
|
||||
appendSuccessfulCall(session, header('vision'), usage)
|
||||
const before = meter.measure(session)
|
||||
expect(before.baseline).toMatchObject({ kind: 'usage', tokens: 5050 })
|
||||
|
||||
const message = imageMessage('fresh')
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
const after = meter.measure(session)
|
||||
expect(after.baseline).toMatchObject({ kind: 'usage', tokens: 5050 })
|
||||
expect(after.surfaceDeltaTokens - before.surfaceDeltaTokens).toBe(routedMessageTokens(message))
|
||||
expect(after.totalTokens).toBe(5050 + after.surfaceDeltaTokens)
|
||||
})
|
||||
|
||||
it('reprices the surface under the substitution pricing of a text-only route', async () => {
|
||||
const placeholder = '[image omitted for the text-only route]'
|
||||
const substitution: LlmImageRequestPricing = {
|
||||
priceImages: images => images.map(() => ({ visualTokens: 0, text: placeholder })),
|
||||
}
|
||||
const { meter, session } = await harness(model => (model === 'vision' ? fixedPricing : substitution))
|
||||
const message = imageMessage('photo')
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('request/header', { header: header('vision'), reason: 'initial' })
|
||||
|
||||
const textOnly = meter.measure(session, header('text-only'))
|
||||
const imageFree = estimateMessage({
|
||||
...message,
|
||||
content: message.content.filter(block => block.type !== 'image'),
|
||||
})
|
||||
expect(textOnly.nodes[0]!.tokens)
|
||||
.toBe(imageFree + estimateContent([{ type: 'text', text: placeholder }]))
|
||||
expect(textOnly.totalTokens).toBeLessThan(meter.measure(session).totalTokens)
|
||||
})
|
||||
|
||||
it('keeps the fixed heuristic for routes and services that declare no pricing', async () => {
|
||||
const { meter, session } = await harness(() => undefined)
|
||||
const message = imageMessage('photo')
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('request/header', { header: header('vision'), reason: 'initial' })
|
||||
const declared = meter.measure(session)
|
||||
expect(declared.nodes[0]!.tokens).toBe(estimateMessage(message))
|
||||
|
||||
const unknownRoute = meter.measure(
|
||||
session,
|
||||
canonicalHeader({ config: { provider: 'unregistered', model: 'any' } }),
|
||||
)
|
||||
expect(unknownRoute.nodes[0]!.tokens).toBe(estimateMessage(message))
|
||||
})
|
||||
|
||||
it('fails loud when a route answers a mismatched occurrence count', async () => {
|
||||
const broken: LlmImageRequestPricing = { priceImages: () => [] }
|
||||
const { meter, session } = await harness(() => broken)
|
||||
session.append('user/message', imageMessage('photo'), { surfaceOp: 'append' })
|
||||
session.append('request/header', { header: header('vision'), reason: 'initial' })
|
||||
expect(() => meter.measure(session))
|
||||
.toThrow('route image pricing answered 0 prices for 1 occurrences')
|
||||
})
|
||||
|
||||
it('prices nested tool-result images through the same route pricing', async () => {
|
||||
const { meter, session } = await harness(() => fixedPricing)
|
||||
const nested = createUserMessage({
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call-1' as never,
|
||||
content: [
|
||||
{ type: 'text', text: 'screenshot below' },
|
||||
{ type: 'image', attachment: imageRef('nested') },
|
||||
],
|
||||
}],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.append('user/message', nested, { surfaceOp: 'append' })
|
||||
session.append('request/header', { header: header('vision'), reason: 'initial' })
|
||||
const measurement = meter.measure(session)
|
||||
const imageFree = estimateMessage({
|
||||
...nested,
|
||||
content: [{
|
||||
...nested.content[0] as Extract<Message['content'][number], { type: 'tool-result' }>,
|
||||
content: [{ type: 'text', text: 'screenshot below' }],
|
||||
}],
|
||||
})
|
||||
expect(measurement.nodes[0]!.tokens)
|
||||
.toBe(imageFree + VISUAL_TOKENS + estimateContent([{ type: 'text', text: HANDLE_TEXT }]))
|
||||
})
|
||||
})
|
||||
@@ -185,7 +185,8 @@ describe('TokenMeter pricing', () => {
|
||||
expect(Object.isFrozen(snapshot.nodes[0])).toBe(true)
|
||||
expectSurfaceTotal(snapshot)
|
||||
expect(() => {
|
||||
;(snapshot.nodes as Array<{ seq: number; tokens: number }>).push({ seq: 99, tokens: 1 })
|
||||
;(snapshot.nodes as Array<{ seq: number; tokens: number; heuristicTokens: number }>)
|
||||
.push({ seq: 99, tokens: 1, heuristicTokens: 1 })
|
||||
}).toThrow(TypeError)
|
||||
expect(() => {
|
||||
;(snapshot.nodes[0] as { seq: number; tokens: number }).tokens = 1
|
||||
@@ -437,7 +438,7 @@ describe('replay anchors and surface folds', () => {
|
||||
})
|
||||
const measurement = meter().measure(session)
|
||||
const assistant = session.events.find(event => event.type === 'assistant/message')!
|
||||
expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0 }])
|
||||
expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0, heuristicTokens: 0 }])
|
||||
expect(measurement.surfaceTokens).toBe(0)
|
||||
expectSurfaceTotal(measurement)
|
||||
})
|
||||
@@ -486,12 +487,11 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
const service = meter()
|
||||
const states = (service as unknown as {
|
||||
states: WeakMap<Session, { surface: unknown[]; surfaceTokens: number }>
|
||||
states: WeakMap<Session, { surface: unknown[] }>
|
||||
}).states
|
||||
expectRepeatedFailure(service, session, /no matching step\/start/)
|
||||
const state = states.get(session)
|
||||
expect(state?.surface).toEqual([])
|
||||
expect(state?.surfaceTokens).toBe(0)
|
||||
})
|
||||
|
||||
it('clears completed step boundaries and rejects overlapping or late step events', () => {
|
||||
|
||||
@@ -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/sdk/client/README.md
|
||||
README.md: 61a015464da89a7b30f8dfe28e3b4ca2f8814239
|
||||
README.zh.md: 06177bc2fb7a88d52279d9d67ac99c37e897a606
|
||||
README.md: 4c8c77a8bbe555cb59f54bb862615d001a1c3a23
|
||||
README.zh.md: 3747243d7f2aefb4decda182963366421811b9ce
|
||||
|
||||
@@ -12,21 +12,23 @@ Composition customization stays in the profile system. Install persistent bundle
|
||||
|
||||
```ts
|
||||
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
await using harness = new DeepSeekHarness({
|
||||
profile: 'sdk',
|
||||
patches: ['./automation.cordis.yml'],
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
maxTokens: 49_152,
|
||||
})
|
||||
const result = await harness.run('say hi')
|
||||
console.log(result.finalResponse)
|
||||
```
|
||||
|
||||
The dsh process starts lazily on first use and stays owned across `run()` calls. `close()` (or `await using`) is required. `start()` memoizes the bounded `initialize` handshake; `initializeTimeoutMs` defaults to 10 seconds and its diagnostic names the selected profile with the retained stderr tail. A failed handshake reaps the runtime and lets a later call retry with a fresh process until terminal `close()`.
|
||||
The dsh process starts lazily on first use and stays owned across `run()` calls. `close()` (or `await using`) is required. `start()` memoizes the bounded `initialize` handshake, which carries the workspace cwd, provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. `initializeTimeoutMs` defaults to 10 seconds, and its diagnostic names the selected profile with the retained stderr tail. The server validates the exact route before accepting prompts; omitting the effort preserves the model's own default. A failed handshake reaps the runtime and lets a later call retry with a fresh process until terminal `close()`. The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
|
||||
|
||||
The handshake carries the absolute session workspace plus provider/model and optional positive `maxTokens`. `run(input, { sessionId?, onNotification? })` accepts text or `SdkPromptContentBlock[]`; inline raster blocks carry canonical base64 plus `mimeType` and become durable attachments inside the runtime. The call queues the prompt, waits for its durable inbox receipt, and collects until the whole root agent next becomes idle. It returns `RunResult { sessionId, finalResponse, events, notifications }`; `events` is root-scoped, while notifications also contain discovered descendants.
|
||||
The handshake carries the absolute session workspace plus provider/model, optional `reasoningEffort`, and optional positive `maxTokens`. `run(input, { sessionId?, onNotification? })` accepts text or `SdkPromptContentBlock[]`; inline raster blocks carry canonical base64 plus `mimeType` and become durable attachments inside the runtime. The call queues the prompt, waits for its durable inbox receipt, and collects until the whole root agent next becomes idle. It returns `RunResult { sessionId, finalResponse, events, notifications }`; `events` is root-scoped, while notifications also contain discovered descendants.
|
||||
|
||||
## HarnessClient
|
||||
|
||||
|
||||
@@ -12,21 +12,23 @@
|
||||
|
||||
```ts
|
||||
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
await using harness = new DeepSeekHarness({
|
||||
profile: 'sdk',
|
||||
patches: ['./automation.cordis.yml'],
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
maxTokens: 49_152,
|
||||
})
|
||||
const result = await harness.run('say hi')
|
||||
console.log(result.finalResponse)
|
||||
```
|
||||
|
||||
dsh 进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须调用 `close()`(或使用 `await using`)。`start()` 会记忆化有界的 `initialize` 握手;`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。握手失败会回收 runtime,之后的调用可以用新进程重试,直至终结性的 `close()`。
|
||||
dsh 进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须调用 `close()`(或使用 `await using`)。`start()` 会记忆化有界的 `initialize` 握手,其中包含工作区 cwd、提供方/模型路由、可选且由适配器持有的 `reasoningEffort`,以及可选的正整数 `maxTokens` 输出上限。`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。服务器会在接受提示词前校验确切路由;省略推理强度时保留模型自身的默认值。握手失败会回收运行时,之后的调用可以用新进程重试,直至终结性的 `close()`。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。
|
||||
|
||||
握手携带绝对 session workspace、provider/model 和可选的正整数 `maxTokens`。`run(input, { sessionId?, onNotification? })` 接受文本或 `SdkPromptContentBlock[]`;内联栅格图片块携带规范 base64 与 `mimeType`,并在运行时内成为持久附件。该调用将 prompt 入队,等待持久 inbox 回执,并收集到整个根 agent 下次 idle。它返回 `RunResult { sessionId, finalResponse, events, notifications }`;`events` 仅限根 session,notification 还包括发现的后代。
|
||||
握手携带绝对 session workspace、provider/model、可选的 `reasoningEffort` 和可选的正整数 `maxTokens`。`run(input, { sessionId?, onNotification? })` 接受文本或 `SdkPromptContentBlock[]`;内联栅格图片块携带规范 base64 与 `mimeType`,并在运行时内成为持久附件。该调用将 prompt 入队,等待持久 inbox 回执,并收集到整个根 agent 下次 idle。它返回 `RunResult { sessionId, finalResponse, events, notifications }`;`events` 仅限根 session,notification 还包括发现的后代。
|
||||
|
||||
## HarnessClient
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
private readonly cwd: string
|
||||
private readonly provider: string
|
||||
private readonly model: string
|
||||
private readonly reasoningEffort: DeepSeekHarnessOptions['reasoningEffort']
|
||||
private readonly maxTokens: number | undefined
|
||||
private initialized: Promise<void> | undefined
|
||||
private closed = false
|
||||
|
||||
/** @param options - dsh launch configuration plus the session route. */
|
||||
/** @param options - dsh launch configuration plus the session route, effort, and output cap. */
|
||||
constructor(options?: DeepSeekHarnessOptions)
|
||||
constructor(options: DeepSeekHarnessOptions = {}, clientFactory?: () => HarnessClient) {
|
||||
this.createClient = clientFactory ?? (() => new HarnessClient(options))
|
||||
@@ -40,6 +41,7 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
this.cwd = resolve(options.cwd ?? options.processCwd ?? process.cwd())
|
||||
this.provider = options.provider ?? 'deepseek-official'
|
||||
this.model = options.model ?? 'deepseek-v4-flash'
|
||||
this.reasoningEffort = options.reasoningEffort
|
||||
this.maxTokens = options.maxTokens
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
cwd: this.cwd,
|
||||
provider: this.provider,
|
||||
model: this.model,
|
||||
...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort },
|
||||
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-sdk-client/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SdkPromptContentBlock } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface DeepSeekHarnessOptions extends HarnessClientOptions {
|
||||
provider?: string
|
||||
/** Model for SDK-created agents (default `deepseek-v4-flash`). */
|
||||
model?: string
|
||||
/** Adapter-owned reasoning effort for the selected provider/model route. */
|
||||
reasoningEffort?: ReasoningEffortId
|
||||
/** Maximum output tokens for each conversation-model request. */
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
DeepSeekHarness,
|
||||
HarnessClient,
|
||||
@@ -155,13 +156,14 @@ describe('DeepSeekHarness', () => {
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
|
||||
it('sends the configured cwd/provider/model/reasoningEffort/maxTokens in the handshake exactly once', async () => {
|
||||
const dir = await tempDir('sdk-client-init-')
|
||||
const recordFile = join(dir, 'init.jsonl')
|
||||
const harness = createProcessDeepSeekHarness(fakeLaunch({ FAKE_RECORD_INIT: recordFile }), {
|
||||
cwd: dir,
|
||||
provider: 'custom-provider',
|
||||
model: 'custom-model',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
maxTokens: 4096,
|
||||
})
|
||||
cleanups.push(() => harness.close())
|
||||
@@ -173,6 +175,7 @@ describe('DeepSeekHarness', () => {
|
||||
cwd: dir,
|
||||
provider: 'custom-provider',
|
||||
model: 'custom-model',
|
||||
reasoningEffort: 'max',
|
||||
maxTokens: 4096,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -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/sdk/protocol/README.md
|
||||
README.md: 294d9f982d14077f0c84ee8848a5133aa3e48cb4
|
||||
README.zh.md: 6f993ca9830ad8509a6be880448f5115097ac6fe
|
||||
README.md: b24cc32fff7d96ecf13493e69a7bd485d1006c47
|
||||
README.zh.md: e7a51cf73c5341ad116eb8835a9e53e809333391
|
||||
|
||||
@@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
|
||||
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. `SdkPromptContentBlock` admits ordinary durable content plus `SdkEncodedImageBlock { type: "image", data, mimeType }`; the server converts encoded images to durable references before enqueue. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. `SdkPromptContentBlock` admits ordinary durable content plus `SdkEncodedImageBlock { type: "image", data, mimeType }`; the server converts encoded images to durable references before enqueue. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.reasoningEffort` is an optional non-empty adapter-owned identifier for the selected provider/model route; omission preserves that model's own default. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The server resolves the exact route during initialization and rejects `session/prompt` until that handshake succeeds, so a missing adapter, unavailable model, or unsupported effort cannot fall back to a prompt on constructor defaults. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
|
||||
|
||||
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。`SdkPromptContentBlock` 接受普通持久内容以及 `SdkEncodedImageBlock { type: "image", data, mimeType }`;服务器会在入队前把编码图片转换为持久引用。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
|
||||
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。`SdkPromptContentBlock` 接受普通持久内容以及 `SdkEncodedImageBlock { type: "image", data, mimeType }`;服务器会在入队前把编码图片转换为持久引用。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.reasoningEffort` 是所选提供方/模型路由可选的非空适配器自有标识符;省略时保留该模型自身的默认值。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。服务器会在初始化期间解析确切路由,并在握手成功前拒绝 `session/prompt`,因此缺少适配器、模型不可用或推理强度不受支持时,不会回退到使用构造期默认值的提示词。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module @deepseek-ai/dsh-sdk-protocol/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface InitializeParams {
|
||||
provider: string
|
||||
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkJsonRpcServer.initialize`). */
|
||||
model: string
|
||||
/** Optional adapter-owned reasoning effort for the selected provider/model route. */
|
||||
reasoningEffort?: ReasoningEffortId
|
||||
/** Optional positive output-token cap inherited by SDK-created agents and their in-process descendants. */
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
@@ -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/sdk/server/README.md
|
||||
README.md: f10c45e2df06383d6726f249bb6db2015a7fb418
|
||||
README.zh.md: 7bf3bcdab2a318fbfcba7b5a6c3a60ff883f47b6
|
||||
README.md: cdc57655567055bed8713064721c24bc96029099
|
||||
README.zh.md: a530709c218c3965c58273cdc8070d7c7639dad2
|
||||
|
||||
@@ -6,7 +6,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
|
||||
|
||||
## Wiring
|
||||
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding Loader composition.
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. The selected adapter resolves the exact model and optional reasoning effort before initialization succeeds. Other capabilities come from the surrounding Loader composition.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -22,7 +22,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s
|
||||
|
||||
## Wire notes
|
||||
|
||||
`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition.
|
||||
`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. The server validates the provider/model route and optional non-empty `reasoningEffort` through the selected adapter before storing them; omission stores no effort, so the model retains its own default. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. JSON-RPC requests may dispatch concurrently, so `session/prompt` rejects until one `initialize` has completed successfully; clients must await the handshake before sending prompts. An accepted prompt queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 组装
|
||||
|
||||
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他能力由外围 Loader 组合提供。
|
||||
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。初始化成功前,所选适配器会解析确切模型与可选推理强度。其他能力由外围 Loader 组合提供。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写
|
||||
|
||||
## 协议说明
|
||||
|
||||
`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由外围组合提供。
|
||||
`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。服务器会通过所选适配器校验提供方/模型路由与可选的非空 `reasoningEffort`,再保存这些值;省略时不会保存推理强度,因此模型保留自身默认值。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。JSON-RPC 请求可能并发分派,因此在一次 `initialize` 成功完成之前,`session/prompt` 会拒绝;客户端必须等待握手完成后再发送提示词。已接受的提示词会把一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由外围组合提供。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { admitEncodedImages, type EncodedImageAttachment, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, ReasoningEffortId, type ContentBlock, type LlmRuntime } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
@@ -76,6 +75,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
private cwd = process.cwd()
|
||||
private provider = 'deepseek-official'
|
||||
private model = 'deepseek-official'
|
||||
private reasoningEffort: ReturnType<typeof ReasoningEffortId> | undefined
|
||||
private maxTokens: number | undefined
|
||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||
private readonly sessions = new Map<string, SessionRecord>()
|
||||
@@ -83,6 +83,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
private readonly disposers: (() => void)[] = []
|
||||
private shutdownTask: Promise<Record<string, never>> | undefined
|
||||
private shuttingDown = false
|
||||
private initialized = false
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
@@ -126,23 +127,43 @@ export class HarnessSdkJsonRpcServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the SDK route, mounting the DeepSeek fallback only when unowned.
|
||||
* Validate and configure the SDK route, mounting the DeepSeek fallback only when unowned.
|
||||
* @param params - SDK handshake parameters.
|
||||
* @returns server identity for the handshake.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
if (params.reasoningEffort !== undefined
|
||||
&& (typeof params.reasoningEffort !== 'string' || params.reasoningEffort.length === 0)) {
|
||||
throw new TypeError('initialize reasoningEffort must be a non-empty string')
|
||||
}
|
||||
if (params.maxTokens !== undefined
|
||||
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
|
||||
throw new TypeError('initialize maxTokens must be a positive safe integer')
|
||||
}
|
||||
this.cwd = resolve(params.cwd)
|
||||
this.provider = params.provider
|
||||
this.model = params.model
|
||||
this.maxTokens = params.maxTokens
|
||||
if (!this.hasAdapterFor(this.provider)) {
|
||||
if (this.provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${this.provider}"`)
|
||||
const cwd = resolve(params.cwd)
|
||||
const provider = params.provider
|
||||
const model = params.model
|
||||
const reasoningEffort = params.reasoningEffort === undefined
|
||||
? undefined
|
||||
: ReasoningEffortId(params.reasoningEffort)
|
||||
if (!this.hasAdapterFor(provider)) {
|
||||
if (provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${provider}"`)
|
||||
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
|
||||
}
|
||||
// Adapter presence was read from this service above; a successful fallback mount also requires it.
|
||||
const llm = this.ctx.get('llm') as LlmRuntime
|
||||
await llm.resolveCallConfig({
|
||||
provider,
|
||||
model,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
...params.maxTokens === undefined ? {} : { maxTokens: params.maxTokens },
|
||||
})
|
||||
this.cwd = cwd
|
||||
this.provider = provider
|
||||
this.model = model
|
||||
this.reasoningEffort = reasoningEffort
|
||||
this.maxTokens = params.maxTokens
|
||||
this.initialized = true
|
||||
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
|
||||
}
|
||||
|
||||
@@ -152,6 +173,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
* @returns the durable message identity.
|
||||
*/
|
||||
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
|
||||
if (!this.initialized) throw new Error('SDK server is not initialized')
|
||||
const rec = await this.getOrCreateSession(params.sessionId)
|
||||
// An agent-loop-only reload disposes the loop's agents while this record
|
||||
// survives; a retained agent accepts followup() silently, so validate the
|
||||
@@ -259,6 +281,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
agentOptions: {
|
||||
provider: this.provider,
|
||||
model: this.model,
|
||||
...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort },
|
||||
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
@@ -123,6 +124,7 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
cwd: storageDir,
|
||||
provider: 'deepseek-official',
|
||||
model: 'dsagent-model',
|
||||
reasoningEffort: 'max',
|
||||
maxTokens: 321,
|
||||
}) as { serverInfo: { name: string } }
|
||||
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
|
||||
@@ -134,8 +136,14 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
expect((receipt as { messageId?: unknown }).messageId).toBeTypeOf('string')
|
||||
|
||||
await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
|
||||
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
|
||||
const body = llmServer.requests[0] as {
|
||||
model: string
|
||||
messages: { role: string }[]
|
||||
reasoning_effort?: string
|
||||
max_tokens?: number
|
||||
}
|
||||
expect(body.model).toBe('dsagent-model')
|
||||
expect(body.reasoning_effort).toBe('max')
|
||||
expect(body.max_tokens).toBe(321)
|
||||
expect(body.messages[0]?.role).toBe('system')
|
||||
expect(body.messages.at(-1)?.role).toBe('user')
|
||||
@@ -193,6 +201,8 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
// This isolated prompt test begins after the handshake boundary.
|
||||
;(server as unknown as { initialized: boolean }).initialized = true
|
||||
const prompt = (sessionId: string, text: string) => server.prompt({
|
||||
sessionId,
|
||||
contentBlocks: [{ type: 'text', text }],
|
||||
@@ -227,6 +237,8 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
get: (name: string) => name === 'attachments' ? { saveImages } : undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
// This isolated prompt test begins after the handshake boundary.
|
||||
;(server as unknown as { initialized: boolean }).initialized = true
|
||||
|
||||
await server.prompt({
|
||||
sessionId: 'image',
|
||||
@@ -254,6 +266,8 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
// This isolated prompt test begins after the handshake boundary.
|
||||
;(server as unknown as { initialized: boolean }).initialized = true
|
||||
|
||||
await expect(server.prompt({
|
||||
sessionId: 'image',
|
||||
@@ -283,6 +297,8 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
get: (name: string) => name === 'attachments' ? { saveImages } : undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
// This isolated prompt test begins after the handshake boundary.
|
||||
;(server as unknown as { initialized: boolean }).initialized = true
|
||||
|
||||
const prompting = server.prompt({
|
||||
sessionId: 'image-race',
|
||||
@@ -317,6 +333,8 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
// This isolated prompt test begins after the handshake boundary.
|
||||
;(server as unknown as { initialized: boolean }).initialized = true
|
||||
const prompt = (text: string) => server.prompt({
|
||||
sessionId: 'zombie',
|
||||
contentBlocks: [{ type: 'text', text }],
|
||||
@@ -924,6 +942,113 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects malformed initialize reasoningEffort values at the wire boundary', async () => {
|
||||
const ctx = new Context()
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
try {
|
||||
for (const reasoningEffort of ['', 42]) {
|
||||
await expect(server.handleRequest('initialize', {
|
||||
cwd: '.',
|
||||
provider: 'deepseek-official',
|
||||
model: 'model',
|
||||
reasoningEffort,
|
||||
})).rejects.toThrow('initialize reasoningEffort must be a non-empty string')
|
||||
}
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unavailable exact model during initialize', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-route-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
class RejectingAdapter extends LlmAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.reject(new Error(`model unavailable: ${provider}/${model}`))
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
}
|
||||
const disposeAdapter = ctx.llm.registerAdapter(['private'], new RejectingAdapter())
|
||||
try {
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'missing' }))
|
||||
.rejects.toThrow('model unavailable: private/missing')
|
||||
await expect(server.prompt({
|
||||
sessionId: 'invalid-route',
|
||||
contentBlocks: [{ type: 'text', text: 'must not run' }],
|
||||
})).rejects.toThrow('SDK server is not initialized')
|
||||
expect((server as unknown as { sessions: Map<string, unknown> }).sessions.size).toBe(0)
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
disposeAdapter()
|
||||
await ctx.fiber.dispose()
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects prompts while exact-route initialization is pending', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-pending-route-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
const resolution = Promise.withResolvers<LlmResolvedModelInfo>()
|
||||
const resolvedModel = { provider: 'private', id: 'selected', name: 'Selected' }
|
||||
let resolveModelCalled = false
|
||||
class PendingAdapter extends LlmAdapter {
|
||||
override resolveModel(): Promise<LlmResolvedModelInfo> {
|
||||
resolveModelCalled = true
|
||||
return resolution.promise
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
}
|
||||
const disposeAdapter = ctx.llm.registerAdapter(['private'], new PendingAdapter())
|
||||
try {
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
const initialization = server.initialize({ cwd: storageDir, provider: 'private', model: 'selected' })
|
||||
await vi.waitFor(() => { expect(resolveModelCalled).toBe(true) })
|
||||
|
||||
await expect(server.prompt({
|
||||
sessionId: 'too-early',
|
||||
contentBlocks: [{ type: 'text', text: 'must not run' }],
|
||||
})).rejects.toThrow('SDK server is not initialized')
|
||||
expect((server as unknown as { sessions: Map<string, unknown> }).sessions.size).toBe(0)
|
||||
|
||||
resolution.resolve(resolvedModel)
|
||||
await initialization
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
resolution.resolve(resolvedModel)
|
||||
disposeAdapter()
|
||||
await ctx.fiber.dispose()
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unsupported reasoning effort during initialize', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unsupported-reasoning-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
try {
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
|
||||
await expect(server.handleRequest('initialize', {
|
||||
cwd: storageDir,
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
reasoningEffort: 'impossible',
|
||||
})).rejects.toThrow('does not support reasoning effort "impossible"')
|
||||
expect((server as unknown as { sessions: Map<string, unknown> }).sessions.size).toBe(0)
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports no adapter when the LLM service is absent', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
@@ -995,23 +1120,35 @@ describe('HarnessSdkJsonRpcServer', () => {
|
||||
it('resolves a relative cwd before creating the session', async () => {
|
||||
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
|
||||
.mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
|
||||
const resolveCallConfig = vi.fn(async (config: unknown) => config)
|
||||
const ctx = {
|
||||
on: vi.fn(() => () => undefined),
|
||||
agents: { create, get: () => undefined },
|
||||
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
|
||||
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }], resolveCallConfig }),
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport()) as unknown as {
|
||||
initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown>
|
||||
initialize(params: { cwd: string; provider: string; model: string; reasoningEffort?: string; maxTokens?: number }): Promise<unknown>
|
||||
getOrCreateSession(sessionId: string): Promise<unknown>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
|
||||
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 })
|
||||
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', reasoningEffort: 'high', maxTokens: 123 })
|
||||
await server.getOrCreateSession('relative')
|
||||
|
||||
expect(resolveCallConfig).toHaveBeenCalledWith({
|
||||
provider: 'mock',
|
||||
model: 'model',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
maxTokens: 123,
|
||||
})
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
meta: { cwd: process.cwd() },
|
||||
agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 },
|
||||
agentOptions: {
|
||||
provider: 'mock',
|
||||
model: 'model',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
maxTokens: 123,
|
||||
},
|
||||
}))
|
||||
await server.shutdown()
|
||||
})
|
||||
|
||||
@@ -23,10 +23,6 @@
|
||||
* provider's fixed permission fact.
|
||||
* - `MOCK_CRASH_ON_INITIALIZE` — exit while the unpublished initialize
|
||||
* operation is active.
|
||||
* - `MOCK_CLOSE_PROTOCOL_ON_INITIALIZE` — close stdout while keeping the
|
||||
* process alive, producing initialize-stage transport.
|
||||
* - `MOCK_CLOSE_PROTOCOL_ON_PROMPT` — close stdout while keeping the process
|
||||
* alive, producing a prompt-stage transport failure.
|
||||
* - `MOCK_CRASH_AFTER_CHUNK` — exit after streaming the assistant chunk, so
|
||||
* the parent preserves partial output with process facts.
|
||||
* - `MOCK_ECHO_CWD` — if `1`, ignore MOCK_TEXT and stream two lines instead:
|
||||
@@ -98,10 +94,8 @@ const IGNORE_PERMISSION_DECISION = process.env.MOCK_PERMISSION_IGNORE_DECISION =
|
||||
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
|
||||
const THOUGHT = process.env.MOCK_THOUGHT === '1'
|
||||
const CRASH_ON_INITIALIZE = process.env.MOCK_CRASH_ON_INITIALIZE === '1'
|
||||
const CLOSE_PROTOCOL_ON_INITIALIZE = process.env.MOCK_CLOSE_PROTOCOL_ON_INITIALIZE === '1'
|
||||
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
|
||||
const CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1'
|
||||
const CLOSE_PROTOCOL_ON_PROMPT = process.env.MOCK_CLOSE_PROTOCOL_ON_PROMPT === '1'
|
||||
const CRASH_AFTER_CHUNK = process.env.MOCK_CRASH_AFTER_CHUNK === '1'
|
||||
const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
|
||||
const TOOL_KIND = process.env.MOCK_TOOL_KIND as ToolKind | undefined
|
||||
@@ -123,11 +117,6 @@ function makeAgent() {
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
if (CRASH_ON_INITIALIZE) process.exit(11)
|
||||
if (CLOSE_PROTOCOL_ON_INITIALIZE) {
|
||||
process.stdout.end()
|
||||
setInterval(() => { /* keep the process alive after protocol EOF */ }, 1000)
|
||||
return new Promise<InitializeResponse>(() => {})
|
||||
}
|
||||
return Promise.resolve({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentCapabilities: { promptCapabilities: { image: false, audio: false, embeddedContext: false } },
|
||||
@@ -152,11 +141,6 @@ function makeAgent() {
|
||||
},
|
||||
async prompt(params: PromptRequest, conn: AgentContext): Promise<PromptResponse> {
|
||||
if (CRASH_ON_PROMPT) process.exit(1)
|
||||
if (CLOSE_PROTOCOL_ON_PROMPT) {
|
||||
process.stdout.end()
|
||||
setInterval(() => { /* keep the process alive after protocol EOF */ }, 1000)
|
||||
return new Promise<PromptResponse>(() => {})
|
||||
}
|
||||
if (WANT_PERMISSION) {
|
||||
// Ask the client to approve before answering; honor its decision. Under
|
||||
// MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy
|
||||
|
||||
@@ -4,6 +4,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { PassThrough, type Readable } from 'node:stream'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -121,6 +122,63 @@ function tapBoundedExitWait(child: SubprocessHandle, onWait: () => void): Subpro
|
||||
}
|
||||
}
|
||||
|
||||
function replaceProtocolStreams(
|
||||
child: SubprocessHandle,
|
||||
stdin: PassThrough,
|
||||
stdout: Readable,
|
||||
): SubprocessHandle {
|
||||
if (child.stdin === undefined) throw new Error('expected piped child stdin')
|
||||
stdin.pipe(child.stdin)
|
||||
return {
|
||||
pid: child.pid,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: child.stderr,
|
||||
collected: child.collected,
|
||||
done: child.done,
|
||||
terminate: () => { child.terminate() },
|
||||
waitForExit: (signal?: AbortSignal) => child.waitForExit(signal),
|
||||
}
|
||||
}
|
||||
|
||||
function closeProtocolImmediately(child: SubprocessHandle): SubprocessHandle {
|
||||
const stdout = new PassThrough()
|
||||
stdout.end()
|
||||
return replaceProtocolStreams(child, new PassThrough(), stdout)
|
||||
}
|
||||
|
||||
function closeProtocolOnPrompt(child: SubprocessHandle, onClose: () => void = () => {}): SubprocessHandle {
|
||||
if (child.stdout === undefined) throw new Error('expected piped child stdout')
|
||||
const stdin = new PassThrough()
|
||||
const stdout = new PassThrough()
|
||||
child.stdout.pipe(stdout)
|
||||
let requestText = ''
|
||||
let closed = false
|
||||
stdin.on('data', (chunk: Buffer) => {
|
||||
if (closed) return
|
||||
requestText += chunk.toString('utf8')
|
||||
if (!requestText.includes('"session/prompt"')) return
|
||||
closed = true
|
||||
child.stdout?.unpipe(stdout)
|
||||
stdout.end()
|
||||
onClose()
|
||||
})
|
||||
return replaceProtocolStreams(child, stdin, stdout)
|
||||
}
|
||||
|
||||
function replaceProcessOutcome(child: SubprocessHandle, outcome: SubprocessOutcome): SubprocessHandle {
|
||||
return {
|
||||
pid: child.pid,
|
||||
stdin: child.stdin,
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
collected: child.collected,
|
||||
done: child.done.then(() => outcome),
|
||||
terminate: () => { child.terminate() },
|
||||
waitForExit: (signal?: AbortSignal) => child.waitForExit(signal),
|
||||
}
|
||||
}
|
||||
|
||||
describe('acpStopReason', () => {
|
||||
it('maps each ACP stop reason to the harness vocabulary', () => {
|
||||
expect(acpStopReason('end_turn')).toBe('completed')
|
||||
@@ -589,18 +647,16 @@ describe('dsh-subagent-acp', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Windows anonymous pipes do not surface a child stdout half-close while
|
||||
// the child stays alive.
|
||||
it.skipIf(process.platform === 'win32')('reports initialize-stage transport when the child closes the protocol but stays alive', async () => {
|
||||
it('reports initialize-stage transport when the child closes the protocol but stays alive', async () => {
|
||||
const error = await startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CLOSE_PROTOCOL_ON_INITIALIZE: '1' },
|
||||
env: {},
|
||||
disposeEofGraceMs: 50,
|
||||
disposeGraceMs: 50,
|
||||
spawn: spawnSubprocess,
|
||||
spawn: spec => closeProtocolImmediately(spawnSubprocess(spec)),
|
||||
}).catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe(
|
||||
@@ -938,18 +994,16 @@ describe('dsh-subagent-acp', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
// Windows anonymous pipes do not surface a child stdout half-close while
|
||||
// the child stays alive.
|
||||
it.skipIf(process.platform === 'win32')('classifies a prompt transport failure without copying SDK text', async () => {
|
||||
it('classifies a prompt transport failure without copying SDK text', async () => {
|
||||
const run = await startAcpRun(request('private prompt text'), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CLOSE_PROTOCOL_ON_PROMPT: '1' },
|
||||
env: { MOCK_HANG: '1' },
|
||||
disposeEofGraceMs: 100,
|
||||
disposeGraceMs: 100,
|
||||
spawn: spawnSubprocess,
|
||||
spawn: spec => closeProtocolOnPrompt(spawnSubprocess(spec)),
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result).toEqual({
|
||||
@@ -961,9 +1015,7 @@ describe('dsh-subagent-acp', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
// Windows anonymous pipes do not surface a child stdout half-close while
|
||||
// the child stays alive.
|
||||
it.skipIf(process.platform === 'win32')('lets local cancellation interrupt prompt-failure process observation', async () => {
|
||||
it('lets local cancellation interrupt prompt-failure process observation', async () => {
|
||||
const controller = new AbortController()
|
||||
const protocolEnded = Promise.withResolvers<undefined>()
|
||||
let boundedExitWaits = 0
|
||||
@@ -972,13 +1024,15 @@ describe('dsh-subagent-acp', () => {
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CLOSE_PROTOCOL_ON_PROMPT: '1' },
|
||||
env: { MOCK_HANG: '1' },
|
||||
disposeEofGraceMs: 100,
|
||||
disposeGraceMs: 5000,
|
||||
spawn: (spec) => {
|
||||
const child = spawnSubprocess(spec)
|
||||
child.stdout?.once('end', () => { protocolEnded.resolve(undefined) })
|
||||
return tapBoundedExitWait(child, () => { boundedExitWaits += 1 })
|
||||
return closeProtocolOnPrompt(
|
||||
tapBoundedExitWait(child, () => { boundedExitWaits += 1 }),
|
||||
() => { protocolEnded.resolve(undefined) },
|
||||
)
|
||||
},
|
||||
})
|
||||
await protocolEnded.promise
|
||||
@@ -1006,6 +1060,29 @@ describe('dsh-subagent-acp', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('reports a signal-only process outcome', async () => {
|
||||
const run = await startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CRASH_AFTER_CHUNK: '1' },
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
spawn: spec => replaceProcessOutcome(
|
||||
spawnSubprocess(spec),
|
||||
{ exitCode: null, signal: 'SIGTERM' },
|
||||
),
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result).toEqual({
|
||||
output: [{ type: 'text', text: 'mock child answer' }],
|
||||
diagnostic: expectedFailure('stage: process; category: process-exit; signal: SIGTERM'),
|
||||
stopReason: 'error',
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects a spawn failure after provider-owned cleanup', async () => {
|
||||
const privateCommand = '/nonexistent/private/SECRET_TOKEN/acp-agent'
|
||||
const error = await startAcpRun(
|
||||
|
||||
@@ -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/subagent/subagent-dsh-sdk/README.md
|
||||
README.md: 302baa05afed2b78c2041f57b1ef900713e350cd
|
||||
README.zh.md: e4e1460274170b0c990d9e454f1cbf54546676e9
|
||||
README.md: d9b29b594c13cd98cf4eaf3b4c8f93caf935a82c
|
||||
README.zh.md: 9e1167f4d76cd93860c73e239f689512df38cb2b
|
||||
|
||||
@@ -6,7 +6,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
|
||||
|
||||
## Start and ownership
|
||||
|
||||
`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route and optional `maxTokens` output cap) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
|
||||
`start(request)` resolves the child's working directory and one process-wide SDK route before spawning. Each declared `request.agentOptions` field (`provider`, `model`, `reasoningEffort`, or `maxTokens`) overrides the matching provider-instance default; omission preserves the configured provider/model and optional cap, while reasoning effort remains omitted unless the request supplies it. The provider then spawns through `DeepSeekHarness` and completes the child runtime's `initialize` handshake, including exact-model and effort validation, before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A route, spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
|
||||
|
||||
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. `dshHome` is separately required as an absolute path so a nested runtime cannot accidentally share its parent's profiles, plugin installation, or session storage.
|
||||
|
||||
@@ -20,7 +20,7 @@ The SDK client returns an owned child activity rather than a prompt result. The
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
The provider advertises no start-time capabilities (`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget.
|
||||
The provider advertises `agentOptions: true`, with `outputSchema`/`depthLimit`/`toolFilter`/`persona` false, and `inheritsParentContext: false`. Its immutable `agentRouteDefaults` publish the configured provider/model baseline to `dsh-tool-subagent` before model overrides and exact-route preflight; `start()` independently applies the same Config defaults for direct callers and maxTokens. Agent route values cross the SDK wire as an explicit whitelist; the child remains a fresh runtime in another process, and the only value derived from the parent Agent itself is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -40,6 +40,8 @@ The provider advertises no start-time capabilities (`agentOptions`/`outputSchema
|
||||
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
|
||||
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
|
||||
|
||||
Request `agentOptions` override `provider`, `model`, and `maxTokens` independently. `reasoningEffort` has no provider-instance default: an omitted request leaves it absent so the selected child model resolves its own default. The model-facing subagent tool can select provider/model/reasoning per call; `maxTokens` remains deployment-controlled through tool config or this provider's default.
|
||||
|
||||
```yaml
|
||||
- id: subagent-dsh-sdk
|
||||
name: '@deepseek-ai/dsh-subagent-dsh-sdk'
|
||||
@@ -68,7 +70,7 @@ The package has no default export. Cordis loader unwrapping would otherwise hide
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for `agentOptions`, persona, tool filtering, depth enforcement, or structured output instead of silently omitting them.
|
||||
The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. A parent tool call may choose the child provider, model, and reasoning effort for this run; the selected route and any deployment-owned output cap are fixed for the new child process. Persona, tool filtering, depth enforcement, and structured output remain unsupported and are rejected instead of silently omitted.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -95,6 +97,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child.
|
||||
- **No optional start-time capabilities** — the parent cannot apply `agentOptions` or enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead.
|
||||
- **No non-route start-time capabilities** — the parent can select the child Agent route but cannot enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead.
|
||||
- **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call/result (the seam's child-isolation rule); the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log.
|
||||
- **Local child processes only** — the resolved cwd is a local path; a remote runtime would need its own backend.
|
||||
|
||||
@@ -6,7 +6,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
|
||||
|
||||
## 启动与所有权
|
||||
|
||||
`start(request)` 先解析子进程工作目录,通过 `DeepSeekHarness` spawn 运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由及可选的 `maxTokens` 输出上限)。因此,履行意味着子运行时已就绪、所有权已移交给调用方。spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。
|
||||
`start(request)` 会在 spawn 前解析子进程工作目录与一条进程级 SDK 路由。`request.agentOptions` 中每个已声明字段(`provider`、`model`、`reasoningEffort` 或 `maxTokens`)都会覆盖对应的提供方实例默认值;省略时保留已配置的提供方/模型与可选上限,而推理强度只有在请求提供时才会出现。随后,提供方通过 `DeepSeekHarness` spawn 运行时,并在履行前完成子运行时的 `initialize` 握手,其中包括确切模型与推理强度校验。因此,履行意味着子运行时已就绪、所有权已移交给调用方。路由、spawn、握手或发布前取消失败时,只会在子进程被回收后拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。
|
||||
|
||||
工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.zh.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。`dshHome` 必须另外指定为绝对路径,使嵌套运行时不会意外共享父运行时的 profile、插件安装或会话存储。
|
||||
|
||||
@@ -20,7 +20,7 @@ SDK 客户端返回自有子活动,而不是提示词结果。提供方读取
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` 全为 false),且 `inheritsParentContext: false`:子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider 的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。
|
||||
提供方声明 `agentOptions: true`,同时保持 `outputSchema`/`depthLimit`/`toolFilter`/`persona` 为 false,并且 `inheritsParentContext: false`。不可变的 `agentRouteDefaults` 会在模型覆盖与确切路由预检前,把配置的 provider/model 基线公开给 `dsh-tool-subagent`;`start()` 则为直接调用方与 maxTokens 独立应用同一份 Config 默认值。Agent 路由值通过显式白名单跨越 SDK 协议;子进程仍是另一进程里的全新运行时,唯一从父 Agent 本身派生的值是工作区 cwd。基于本提供方的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -40,6 +40,8 @@ Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLi
|
||||
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限。 |
|
||||
| `disposeGraceMs` | `3000` | 终止后的退出确认窗口;POSIX 在 SIGTERM 之后、SIGKILL 之前也等待同样时长。 |
|
||||
|
||||
请求 `agentOptions` 会分别覆盖 `provider`、`model` 与 `maxTokens`。`reasoningEffort` 没有提供方实例默认值:请求省略时保持缺省,由所选子模型解析自身默认值。面向模型的 subagent 工具可在每次调用时选择提供方/模型/推理强度;`maxTokens` 仍由工具配置或本提供方默认值在部署侧控制。
|
||||
|
||||
```yaml
|
||||
- id: subagent-dsh-sdk
|
||||
name: '@deepseek-ai/dsh-subagent-dsh-sdk'
|
||||
@@ -68,7 +70,7 @@ Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLi
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。本提供方不声明可选的启动时能力,因此本地服务会拒绝要求 `agentOptions`、persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。
|
||||
子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。父级工具调用可以为本次运行选择子级提供方、模型与推理强度;所选路由和部署持有的可选输出上限会固定到这个新子进程。persona、工具过滤、深度强制与结构化输出仍不受支持,并会被拒绝而不是静默省略。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -95,6 +97,6 @@ Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLi
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **每次运行都使用全新的运行时进程**:不使用进程池;harness 运行时需要启动完整的插件树,因此每次运行的 spawn 成本高于 ACP 后端通常使用的子进程。
|
||||
- **不支持可选的启动时能力**:父级无法在子进程内应用 `agentOptions`,也无法强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。
|
||||
- **不支持路由之外的启动时能力**:父级可以选择子 Agent 路由,但无法在子进程内强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。
|
||||
- **子进程的 transcript(文本记录)保留在其自身的会话根目录中**:父级日志只记录委派工具调用/结果(seam 的子级隔离规则);流式 `session.event` 通道只用于提取输出,不会桥接到父级日志中。
|
||||
- **仅支持本地子进程**:解析出的 cwd 是本地路径;远程运行时需要独立的后端。
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-filesystem": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* Out-of-process SDK subagent backend. Each child is a complete DeepSeek
|
||||
* Harness runtime in its own process — own named profile and patch composition,
|
||||
* session, model route, and tools — driven over stdio JSON-RPC through the
|
||||
* TypeScript SDK client, so it shares no Cordis context and advertises no
|
||||
* parent-enforced start capabilities; the ONE thing it reads off
|
||||
* `request.parent` is the session's workspace cwd. This plugin uses named
|
||||
* TypeScript SDK client, so it shares no Cordis context. It accepts the
|
||||
* provider/model/reasoning/maxTokens subset of `agentOptions`; other start
|
||||
* features remain unsupported. The ONE thing it reads off `request.parent`
|
||||
* is the session's workspace cwd. This plugin uses named
|
||||
* exports only; a default would hide its loader metadata (see
|
||||
* `docs/postmortem/0001-acp-default-export-drops-inject.md`).
|
||||
* @module @deepseek-ai/dsh-subagent-dsh-sdk
|
||||
@@ -14,6 +15,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import { statSync } from 'node:fs'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
@@ -103,28 +105,50 @@ function resolveConfiguredFile(field: string, value: string): string {
|
||||
throw new TypeError(`subagent-dsh-sdk ${field} must name an existing file: ${path}`)
|
||||
}
|
||||
|
||||
/** DSH SDK can apply Agent route options while the other start features remain child-owned. */
|
||||
const SDK_START_CAPABILITIES: SubagentCapabilities = Object.freeze({
|
||||
...NO_START_CAPABILITIES,
|
||||
agentOptions: true,
|
||||
})
|
||||
|
||||
/** Merge the request's supported route fields over this provider instance's defaults. */
|
||||
function resolveSdkRoute(config: ResolvedConfig, requested: AgentOptions | undefined): Pick<
|
||||
SdkRunSpec,
|
||||
'provider' | 'model' | 'reasoningEffort' | 'maxTokens'
|
||||
> {
|
||||
const maxTokens = requested?.maxTokens ?? config.maxTokens
|
||||
return {
|
||||
provider: requested?.provider ?? config.provider,
|
||||
model: requested?.model ?? config.model,
|
||||
...requested?.reasoningEffort === undefined ? {} : { reasoningEffort: requested.reasoningEffort },
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK provider. Advertises NO start-time capabilities: an out-of-process
|
||||
* child cannot honor `agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/`persona` (the
|
||||
* service rejects a request needing any of them before `start` runs).
|
||||
* The SDK provider. It resolves Agent route options into the child runtime's
|
||||
* process-wide handshake; output schema, depth, tool filter, and persona stay
|
||||
* unsupported because their ownership does not cross this process boundary.
|
||||
*/
|
||||
class SdkSubagentProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
|
||||
readonly capabilities = SDK_START_CAPABILITIES
|
||||
readonly agentRouteDefaults: Readonly<{ provider: string; model: string }>
|
||||
// Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary.
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {
|
||||
this.agentRouteDefaults = Object.freeze({ provider: config.provider, model: config.model })
|
||||
}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const route = resolveSdkRoute(this.config, request.agentOptions)
|
||||
const spec: SdkRunSpec = {
|
||||
...this.config.dshBin === undefined ? {} : { dshBin: this.config.dshBin },
|
||||
profile: this.config.profile,
|
||||
patches: this.config.patches,
|
||||
dshHome: this.config.dshHome,
|
||||
cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd),
|
||||
provider: this.config.provider,
|
||||
model: this.config.model,
|
||||
...this.config.maxTokens === undefined ? {} : { maxTokens: this.config.maxTokens },
|
||||
...route,
|
||||
env: this.config.env,
|
||||
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
|
||||
disposeEofGraceMs: this.config.disposeEofGraceMs,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user