From 5183bc2b652f324a5e30fd52aa70e68e7ce84d92 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 19:48:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(compaction):=20=E6=91=98=E8=A6=81=E6=94=B6?= =?UTF-8?q?=E7=BC=A9=E6=94=B9=E6=8C=89=E8=B7=AF=E7=94=B1=E4=BB=B7=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=9A=E4=BB=B7=E8=AE=BF=E9=97=AE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot 首轮意见修复: - 摘要收缩比较改用所选节点的路由价 shadowedRouteTokenCount,修复图片消息启发式价低于带框摘要时压缩被误拒;日志影子价仍为启发式 - DeepSeek 定价经序列化器同一套 access 解析构建句柄与占位文本,消除逐图数十 token 的低估;uncatalogued 分支 JSDoc 指明复现 projectImagesForTextModel 替换 - llm-replay 在加载时拒绝纯文本模型上的 imageRequestTokens 声明 - contextBreakdown 的 README 与 JSDoc 改为等于 heuristicTokens 之和,不再声称等于路由价 surfaceTokens --- ...te-priced-image-request-pressure.i18n.yaml | 4 +-- ...-24-route-priced-image-request-pressure.md | 8 +++--- ...-route-priced-image-request-pressure.zh.md | 8 +++--- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 6 ++-- docs/config-catalog.zh.md | 4 ++- .../compaction-basic/README.i18n.yaml | 4 +-- .../compaction/compaction-basic/README.md | 2 +- .../compaction/compaction-basic/README.zh.md | 2 +- .../compaction/compaction-basic/src/region.ts | 13 +++++++-- .../tests/compaction-basic.spec.ts | 28 +++++++++++++++++++ packages/llm/llm-deepseek/src/adapter.ts | 10 ++++++- .../llm/llm-deepseek/src/request-pricing.ts | 25 ++++++++++++----- .../llm/llm-deepseek/tests/adapter.spec.ts | 16 +++++++++++ .../tests/request-pricing.spec.ts | 16 +++++++++++ packages/llm/token-meter/README.i18n.yaml | 4 +-- packages/llm/token-meter/README.md | 2 +- packages/llm/token-meter/README.zh.md | 2 +- .../token-meter/src/breakdown-projection.ts | 8 ++++-- .../test-support/llm-replay/README.i18n.yaml | 4 +-- packages/test-support/llm-replay/README.md | 2 +- packages/test-support/llm-replay/README.zh.md | 2 +- packages/test-support/llm-replay/src/index.ts | 13 ++++++++- .../llm-replay/tests/llm-replay.spec.ts | 9 ++++++ 24 files changed, 154 insertions(+), 42 deletions(-) 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],