diff --git a/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.i18n.yaml
index 952d4b2ce4..0329350a6e 100644
--- a/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.md
-2026-08-24-route-priced-image-request-pressure.md: ab1e586028b89a0e09b404e7b1e18ef56dd01925
-2026-08-24-route-priced-image-request-pressure.zh.md: d9cb2b60472c9177618b3f5fff5ae06d6845210a
+2026-08-24-route-priced-image-request-pressure.md: 45a29211730474369607ed5fb933f380d640bf27
+2026-08-24-route-priced-image-request-pressure.zh.md: cf005a3ee343edf5774d554a4ec78cb876703774
diff --git a/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.md b/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.md
index ab1e586028..45a2921173 100644
--- a/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.md
+++ b/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.md
@@ -12,9 +12,9 @@ The token meter priced an `ImageBlock` as the structural JSON of its durable ref
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()` 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 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, and range selection) 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 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.
@@ -32,8 +32,8 @@ The test-support replay adapter declares a flat per-model `imageRequestTokens` s
## 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, 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).
+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, and range selection read the route price while the logged shadow price stays heuristic. The keyless `image-compaction` ACP snapshot exercises the assembled application end to end.
+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.
diff --git a/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md b/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md
index d9cb2b6047..cf005a3ee3 100644
--- a/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-24-route-priced-image-request-pressure.zh.md
@@ -12,9 +12,9 @@ token 计量服务把 `ImageBlock` 按其持久引用的 JSON 结构计价,约
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`,供提供方与定价共享。
+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` 投影有意保持固定启发式规则。
+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 按启发式价格遮蔽了图片消息。
@@ -32,8 +32,8 @@ test-support 的回放适配器按模型声明固定的 `imageRequestTokens`,
## Consequences
-自动 compaction 现在按路由模型下一次请求实际携带的压力触发:图片密集的 DeepSeek 会话在溢出之前而非之后压缩,纯文本路由收取替换文本而非幻影视觉 token,被 offload 的图片按占位文本计费。最坏对齐 pad 对单图最多多计三个 token,未复现的 base64 回退预算只会多计——两种误差都偏保守,请求完成后 provider usage 仍是权威锚点。公布的 v4 计算器常量只存在于 `llm-deepseek`;提供方若修订其视觉投影,改动点就是这一个模块与其钉死的向量。每次计量多一次定价解析与一次图片出现处遍历,仍为 O(surface)。
+自动 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 快照端到端验证装配后的应用。
+`image-tokens.spec.ts` 的公式向量钉死公布计算器的输出,覆盖宽高比钳制、放大下限、单列求解、奇数网格裁剪与第二遍收敛的用例,开发期间与参考实现在尺寸网格及五万点模糊测试上对拍。`request-pricing.spec.ts` 覆盖纯文本替换、低细节预设以及数量与字节驱动的 offload 边界。token-meter 测试覆盖首次多模态估算、usage 之上的锚后图片 delta、标头覆盖下的纯文本重定价、无定价器时的中性行为、出现处数量不匹配与嵌套工具结果图片。compaction 测试证明触发、保留、选段与摘要收缩比较读取路由价格而记录的影子价保持启发式,包括一个只有路由定价收缩才接受的摘要。访问解析的传递在定价函数与适配器覆写两处都有覆盖。keyless 的 `image-compaction` ACP 快照端到端验证装配后的应用。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index fcca5f1b4d..dff8257b3f 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: d14c0a559219c2708e56eeead115c8a602cbb862
-config-catalog.zh.md: 1401d65c39ab6b922339a17b4e43d9b926e05068
+config-catalog.md: ebe1e1616b1e6152b7c1057e79f9165afe0e9dc4
+config-catalog.zh.md: 70b452ee841d15901cddbe571d9b94d08ca41bea
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index d14c0a5592..ebe1e1616b 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1284,7 +1284,9 @@ export interface ReplayModelConfig {
* 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. Absent declares no image pricing.
+ * 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. */
@@ -1299,7 +1301,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:867`](../packages/test-support/llm-replay/src/index.ts)
+Source: [`packages/test-support/llm-replay/src/index.ts:869`](../packages/test-support/llm-replay/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 1401d65c39..70b452ee84 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1286,7 +1286,9 @@ export interface ReplayModelConfig {
* 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. Absent declares no image pricing.
+ * 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. */
diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml
index c76d23c88a..966336d917 100644
--- a/packages/compaction/compaction-basic/README.i18n.yaml
+++ b/packages/compaction/compaction-basic/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compaction/compaction-basic/README.md
-README.md: b83b7a4ebafdf329fb91bc2c0f9f353f12c4a360
-README.zh.md: 33bedb6cb76283eeed31c38c1a36f2129fa98660
+README.md: e45228080db414c503420d22f5faca3daf1d3966
+README.zh.md: 1ff7bede73f36ec81d1dba0f2b414736ade09457
diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md
index b83b7a4eba..e45228080d 100644
--- a/packages/compaction/compaction-basic/README.md
+++ b/packages/compaction/compaction-basic/README.md
@@ -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, 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, and range selection all read the same per-node prices, while the logged shadow price of a replaced range stays on the route-independent fixed heuristic so pure projection folds remain consistent.
+- **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.
diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md
index 33bedb6cb7..1ff7bede73 100644
--- a/packages/compaction/compaction-basic/README.zh.md
+++ b/packages/compaction/compaction-basic/README.zh.md
@@ -10,7 +10,7 @@
该后端拥有压缩策略:
-- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上,计量最新一份规范化已记录 envelope 与当前表层的 token 用量;当路由模型的适配器声明了请求图片定价时,按该定价计量。因此,步骤边界的压力计量会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文、steering(中途引导)与按路由定价的图片历史;触发、近期尾部保留与范围选择读取同一套逐节点价格,而被替换范围记录的影子价保持在与路由无关的固定启发式规则上,使纯投影 fold 保持一致。
+- **测量**:单例 `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 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts
index 2c81f09e49..f5fba599a4 100644
--- a/packages/compaction/compaction-basic/src/region.ts
+++ b/packages/compaction/compaction-basic/src/region.ts
@@ -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
}
@@ -353,8 +355,10 @@ function prepareCompaction(
selectedNodes,
// The shadow-price protocol prices replacements with the fixed heuristic
// so the O(1) projection fold stays in agreement with its own appends;
- // retention and range selection read the route-priced `tokens` instead.
+ // 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),
}
}
@@ -373,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 {
diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
index 1894987cf5..be4fd10a65 100644
--- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
+++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
@@ -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 {
@@ -1966,6 +1967,33 @@ describe('route-priced image pressure', () => {
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()
diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts
index 0ca6791798..41e9e0fbd8 100644
--- a/packages/llm/llm-deepseek/src/adapter.ts
+++ b/packages/llm/llm-deepseek/src/adapter.ts
@@ -347,7 +347,15 @@ export class DeepSeekAdapter extends LlmAdapter {
}
override imageRequestPricing(_provider: string, model: string): ReturnType {
- return deepSeekImageRequestPricing(this.config.options(), model)
+ // 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 {
diff --git a/packages/llm/llm-deepseek/src/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts
index 5bc6ca5383..71dd0ac291 100644
--- a/packages/llm/llm-deepseek/src/request-pricing.ts
+++ b/packages/llm/llm-deepseek/src/request-pricing.ts
@@ -10,7 +10,7 @@
*/
import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm'
-import type { LlmImageRequestPrice, LlmImageRequestPricing } 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'
@@ -45,7 +45,11 @@ export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageReq
}
}
-/** Price one occurrence a text-only route substitutes with deterministic text. */
+/**
+ * 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) }
}
@@ -54,17 +58,22 @@ function textOnlyPrice(ref: ImageAttachmentRef): LlmImageRequestPrice {
* 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 oldest-first offload and price retained images by
- * their projected request dimensions. The base64 fallback's tighter inline
+ * 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.
+ * 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) {
@@ -83,11 +92,13 @@ export function deepSeekImageRequestPricing(
},
)
return images.map((ref, index) => {
- if (index < offloaded) return { visualTokens: 0, text: offloadedImageText(ref) }
+ 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),
+ text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)),
}
})
},
diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts
index 66491ac708..f87627128e 100644
--- a/packages/llm/llm-deepseek/tests/adapter.spec.ts
+++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts
@@ -169,6 +169,22 @@ describe('request image policy', () => {
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', () => {
diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts
index 7e7d28a17e..a9d81c574a 100644
--- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts
+++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts
@@ -58,6 +58,22 @@ describe('DeepSeek request-image pricing', () => {
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(
diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml
index 9af0d481dc..f17cf2328a 100644
--- a/packages/llm/token-meter/README.i18n.yaml
+++ b/packages/llm/token-meter/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
-README.md: 5712ac132d95b9d8ff651a9102edc6541306a506
-README.zh.md: 83951322be53e5bb8e1afa53774ffe79cd31894a
+README.md: b0095c3c84f1e1d01b5620597b7d57687ce5e9fd
+README.zh.md: a8ad4ead8352cf4c0aa66f656f5b4955369ef4a2
diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md
index 5712ac132d..b0095c3c84 100644
--- a/packages/llm/token-meter/README.md
+++ b/packages/llm/token-meter/README.md
@@ -31,7 +31,7 @@ When the composition provides `ctx.sessionProjections`, token-meter registers th
`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 and folded through the same `surface-fold.ts` the measurement service replays. 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 `surface-fold.ts` — the same positional fold `measure()` runs — so it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. 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 shadow-price fold consistent with `surface-fold.ts`'s fixed-heuristic node prices, so 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 by the routed model's image repricing. 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.
diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md
index 83951322be..a8ad4ead83 100644
--- a/packages/llm/token-meter/README.zh.md
+++ b/packages/llm/token-meter/README.zh.md
@@ -31,7 +31,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到再完成一整个轮次为止。占用率展示读取 `projectedTokens`。
-`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——也就是 `measure()` 运行的同一个带位置 fold——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
+`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `surface-fold.ts` 固定启发式节点价一致的影子价 fold,因此它在每个事件边界上都等于 `measure().nodes[].heuristicTokens` 之和,压缩按其记录的影子价缩小它;路由定价的 `measure().surfaceTokens` 会因路由模型的图片重定价而偏离。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。
diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts
index e0c980e843..ab67c0600c 100644
--- a/packages/llm/token-meter/src/breakdown-projection.ts
+++ b/packages/llm/token-meter/src/breakdown-projection.ts
@@ -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.
*/
diff --git a/packages/test-support/llm-replay/README.i18n.yaml b/packages/test-support/llm-replay/README.i18n.yaml
index 5923e94ae7..b37ccc6d02 100644
--- a/packages/test-support/llm-replay/README.i18n.yaml
+++ b/packages/test-support/llm-replay/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/test-support/llm-replay/README.md
-README.md: 574678b5e5cef3311aed1a2081b97c40fb822946
-README.zh.md: bc0d2d6b7bdf06184f9a750236e7fd0267c41a59
+README.md: 5e2354cb3ae7ad0ffca6a85c461c7d4b24d8ed31
+README.zh.md: fb2e927fce17e13ed97c49110f9ae6558f117e9d
diff --git a/packages/test-support/llm-replay/README.md b/packages/test-support/llm-replay/README.md
index 574678b5e5..5e2354cb3a 100644
--- a/packages/test-support/llm-replay/README.md
+++ b/packages/test-support/llm-replay/README.md
@@ -31,7 +31,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). |
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
-| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow`, an `inputModalities` array containing only `text` and `image`, and a positive-integer `imageRequestTokens` flat visual-token price the route declares for every retained request image; invalid modalities or a non-positive price fail during plugin loading. Configured routes dispatch through the replay adapter and never perform provider I/O. |
+| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow`, an `inputModalities` array containing only `text` and `image`, and a positive-integer `imageRequestTokens` flat visual-token price the route declares for every retained request image (its model must also declare the `image` modality); invalid modalities, a non-positive price, or visual pricing on a text-only model fail during plugin loading. Configured routes dispatch through the replay adapter and never perform provider I/O. |
| `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. |
```yaml
diff --git a/packages/test-support/llm-replay/README.zh.md b/packages/test-support/llm-replay/README.zh.md
index bc0d2d6b7b..fb2e927fce 100644
--- a/packages/test-support/llm-replay/README.zh.md
+++ b/packages/test-support/llm-replay/README.zh.md
@@ -31,7 +31,7 @@ fixture 是持久化会话日志(`/session.jsonl`)的投影:它
| `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 |
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES`(以路径分隔符分隔) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 |
-| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`、仅包含 `text`、`image` 的 `inputModalities` 数组,以及正整数 `imageRequestTokens`(该路由为每张保留请求图片声明的固定视觉 token 价格);模态配置无效或价格非正时,插件加载会失败。已配置路由通过回放适配器分派,绝不执行提供方 I/O。 |
+| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`、仅包含 `text`、`image` 的 `inputModalities` 数组,以及正整数 `imageRequestTokens`(该路由为每张保留请求图片声明的固定视觉 token 价格,其模型必须同时声明 `image` 模态);模态配置无效、价格非正或在纯文本模型上声明视觉定价时,插件加载会失败。已配置路由通过回放适配器分派,绝不执行提供方 I/O。 |
| `paceMs` | number | 无(突发) | 可选的每分片延迟(单位为毫秒),使下游传输(例如真实浏览器观察到的 Web SSE(Server-Sent Events)多路复用器)看到真正的增量传递。它只是用于提高真实性的调节项,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 |
```yaml
diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts
index e693724624..240d8f53a4 100644
--- a/packages/test-support/llm-replay/src/index.ts
+++ b/packages/test-support/llm-replay/src/index.ts
@@ -66,7 +66,9 @@ export interface ReplayModelConfig {
* 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. Absent declares no image pricing.
+ * 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. */
@@ -900,6 +902,15 @@ function validateConfiguredModels(providers: ReplayProviderConfig[] | undefined)
+ 'must be a positive safe integer',
)
}
+ // A text-only route never sends visual tokens: LlmRuntime substitutes
+ // its images with deterministic text before dispatch, so declared
+ // visual pricing would contradict the actual request projection.
+ if (imageRequestTokens !== undefined && model.inputModalities?.includes('image') !== true) {
+ throw new Error(
+ `llm-replay: provider "${provider.id}" model "${model.id}" imageRequestTokens `
+ + 'requires inputModalities to include "image"',
+ )
+ }
}
}
}
diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts
index 055d2d8e95..070ce6e24f 100644
--- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts
+++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts
@@ -1268,6 +1268,15 @@ describe('apply (the plugin entry)', () => {
expect(ctx.llm.imageRequestPricing('deepseek', 'plain')).toBeUndefined()
})
+ it('rejects imageRequestTokens on a model without the image modality during load', () => {
+ const ctx = new Context()
+ const providers = [{ id: 'm', models: [{ id: 'm', imageRequestTokens: 384 }] }] as unknown as
+ NonNullable
+ expect(() => { apply(ctx, { file, providers }) }).toThrow(
+ 'llm-replay: provider "m" model "m" imageRequestTokens requires inputModalities to include "image"',
+ )
+ })
+
it.each([
['zero', 0],
['a float', 1.5],