Jelajahi Sumber

Merge pull request #3924 from deepseek-harness/worktree/image-token-v41

fix(llm-deepseek): 图片 token 预估器改用官方计算器 v41 配置
CreatixChu 1 Minggu lalu
induk
melakukan
646c4c2f33

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md
+2026-09-10-deepseek-image-token-calculator-v41.md: d8042b04c1ed7824720485aa3ccf584f913d0726
+2026-09-10-deepseek-image-token-calculator-v41.zh.md: 6be386da1c66c469329d03e7b86c8c0e2c22ce3a

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek image-token estimator on the v41 calculator
+
+Status: implemented
+
+English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md)
+
+## Problem
+
+`deepSeekImageTokens()` in `llm-deepseek` ported the provider's published image-token calculator in its `v4` configuration: a 384×384 scale-up floor, a 384-token cap, an 8:1 width clamp, a grid layout that adds a row for odd row counts and parity corrections, and a pad-to-4 alignment charged at its worst case. The provider's Vision guide now documents a different projection for the current Flash model: images below roughly 544×544 total pixels scale up, larger images scale down to roughly 1300×1300 total pixels, and one image costs at most 1024 tokens. The published calculator carries this as a `v41` configuration and the docs page instantiates that one. The old port underprices an 800×800 request image by 73 tokens, which can delay automatic compaction in sessions containing these images. The error depends on dimensions: a 640×480 image is overestimated by 3 tokens.
+
+## Decision
+
+`image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged.
+
+The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged.
+
+## Alternatives considered
+
+**Keep both configurations and select by model id.** The provider states that requests to the retired `deepseek-v4-flash-vision-exp` id are served by the current Flash model, so no reachable route prices under the old configuration. Two configurations would keep dead branches and their tests alive.
+
+**Keep the generic class with the `isNLayout`, pad, and ratio-clamp switches.** A one-configuration port has fewer unreachable branches to exclude from coverage and states the shipped rule directly; a future provider revision changes this one module and its pinned vectors either way.
+
+**Raise the harness pixel budget in the same change.** The provider now accepts roughly 1300×1300 total pixels per image, so the harness's 640,000-pixel projection discards detail the model could use. That is a request-content change with its own snapshot impact, separate from pricing what is actually sent.
+
+## Consequences
+
+An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The 640,000-pixel budget does not imply a 422-token ceiling: an 8192×1 image stays within that pixel budget and costs 1024 tokens. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: DeepSeek 图片 token 估算器改用 v41 计算器
+
+Status: implemented
+
+[English](2026-09-10-deepseek-image-token-calculator-v41.md) | 中文
+
+## 问题
+
+`llm-deepseek` 中的 `deepSeekImageTokens()` 移植的是提供方公开图片 token 计算器的 `v4` 配置:384×384 放大下限、384 token 上限、8:1 宽度钳制、奇数行数额外加一行并做奇偶校正的网格布局,以及按最坏情况计价的 pad-to-4 对齐。提供方的图像理解指南现在为当前 Flash 模型记录了另一套投影规则:总像素小于约 544×544 的图片放大,更大的图片缩小到约 1300×1300 总像素,单张图片最多 1024 token。公开计算器以 `v41` 配置承载这套规则,文档页实例化的也是它。旧移植对一张 800×800 的请求图片低估 73 token,可能使包含这类图片的会话延迟触发自动压缩。误差取决于尺寸:一张 640×480 的图片会被高估 3 token。
+
+## 决策
+
+`image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。
+
+测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变。
+
+## 备选方案
+
+**保留两套配置并按模型 id 选择。** 提供方说明发往已下线的 `deepseek-v4-flash-vision-exp` 的请求由当前 Flash 模型承接,所以没有可达路由会按旧配置计价。两套配置会保留死分支及其测试。
+
+**保留带 `isNLayout`、pad 和宽高比钳制开关的通用类。** 单配置移植需要从覆盖率中排除的不可达分支更少,并直接陈述已上线的规则;提供方未来再修订时,两种写法都只改这一个模块和它固定的向量。
+
+**在同一改动中提高 harness 像素预算。** 提供方现在每张图接受约 1300×1300 总像素,harness 的 640,000 像素投影会丢弃模型本可利用的细节。那是请求内容的改动,有自己的快照影响,与为实际发送内容计价是两件事。
+
+## 后果
+
+800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。640,000 像素预算不意味着 422 token 上限:8192×1 图片在该像素预算内,仍计 1024 token。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。

+ 2 - 2
docs/subsystems/llm-streaming.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/subsystems/llm-streaming.md
-llm-streaming.md: cadfda5553ca5d5e0df78f40b6a7a75ef7cc71a6
-llm-streaming.zh.md: 5f73d544459268ce83a30218b710dd7c29f7a614
+llm-streaming.md: 77d3313b3c1a025f756e984453bd041abd7b0534
+llm-streaming.zh.md: cac7897ab412f45bd5b5ab20c6460644f18f4ff2

+ 1 - 1
docs/subsystems/llm-streaming.md

@@ -249,7 +249,7 @@ interface LlmFailure {
 
 ## Request-image pricing
 
-An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published v4 vision accounting, while provider usage remains the authoritative anchor for completed requests.
+An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests.
 
 ```ts type-equiv
 /**

+ 1 - 1
docs/subsystems/llm-streaming.zh.md

@@ -251,7 +251,7 @@ interface LlmFailure {
 
 ## 请求图片定价
 
-提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的 v4 视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
+提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。
 
 ```ts type-equiv
 /**

+ 2 - 2
packages/llm/llm-deepseek/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/llm-deepseek/README.md
-README.md: 73992b3d5e6b72215cb3994881c361147c6885d8
-README.zh.md: 47d6cad48cde5dec8139d6981436bc6023f276b1
+README.md: 41646097f80dfad9cc523595b9d2820c28fde66b
+README.zh.md: c7a1e40952dff7a804880dcd266114cecb20918f

+ 3 - 1
packages/llm/llm-deepseek/README.md

@@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history,
 
 #### Token effect
 
-Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it reproduces oldest-first image offload from durable byte lengths and prices each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while dropping over-budget images avoids paying those tokens again. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees.
+Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it reproduces oldest-first image offload from durable byte lengths and prices each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while dropping over-budget images avoids paying those tokens again. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees.
 
 #### KV Cache effect
 
@@ -190,6 +190,8 @@ These limits define where the adapter stops and future work begins. They are cur
 - **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery.
 - The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts).
 
+- The default request-image projection caps total pixels at 640,000, below the provider's roughly 1300×1300 processing budget, so it can discard usable detail. Each model's `imagePixelBudget` can override this default; changing the default affects request content and needs separate snapshot verification ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md)).
+
 <a id="dev-note"></a>
 ### Dev Note
 

+ 3 - 1
packages/llm/llm-deepseek/README.zh.md

@@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
 
 #### Token 影响
 
-提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:它根据持久记录中的字节长度复现最旧优先的图片 offload,并按投影后的尺寸使用公开的 v4 视觉计量规则(14 px patch 网格、3:1 降采样、单图 384 token 上限、最坏情况下的对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而丢弃超预算图片会避免再次为它们付费。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。
+提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:它根据持久记录中的字节长度复现最旧优先的图片 offload,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而丢弃超预算图片会避免再次为它们付费。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。
 
 #### KV Cache 影响
 
@@ -190,6 +190,8 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复
 - **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。
 - 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。
 
+- 默认请求图片投影限制为 640,000 总像素,低于提供方约 1300×1300 的处理预算,可能丢弃可用细节。每个模型的 `imagePixelBudget` 可以覆盖默认值。更改默认值会改变请求内容,需要单独验证快照([决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md))。
+
 <a id="dev-note"></a>
 ### 开发备注
 

+ 52 - 70
packages/llm/llm-deepseek/src/image-tokens.ts

@@ -1,10 +1,12 @@
 /**
- * DeepSeek v4 vision-token accounting: the provider's published image-token
- * calculator (api-docs.deepseek.com, Token & Token Usage) ported verbatim.
- * The provider resizes every request image onto a 14px-patch grid, downsamples
- * 3:1 per axis, and caps one image at 384 tokens; the port prices the
- * pad-to-4 alignment at its 3-token upper bound because request pricing has
- * no preceding-token position. Actual usage remains authoritative.
+ * DeepSeek vision-token accounting: the provider's published image-token
+ * calculator (api-docs.deepseek.com, Token & Token Usage) ported verbatim in
+ * its current `v41` configuration. The provider scales an image below
+ * 544×544 total pixels up, aligns it to a 14px-patch grid, downsamples 3:1
+ * per axis into token cells, and caps one image at 1024 tokens by solving the
+ * largest aspect-preserving grid inside that budget. The count is exact: this
+ * configuration has no alignment pad and no aspect-ratio clamp. Actual usage
+ * remains authoritative.
  *
  * @module dsh-llm-deepseek/image-tokens
  */
@@ -14,13 +16,11 @@ const PATCH_SIZE = 14
 /** Per-axis patch-to-token downsampling ratio. */
 const DOWNSAMPLE_RATIO = 3
 /** Provider cap on tokens for one request image. */
-const MAX_IMAGE_TOKENS = 384
-/** Token-alignment quantum; pricing charges its worst-case `QUANTUM - 1` pad. */
-const COMPRESS_PAD_TO = 4
-/** Width is clamped to this multiple of height before grid projection. */
-const MAX_WIDTH_HEIGHT_RATIO = 8
+const MAX_IMAGE_TOKENS = 1024
 /** Total-pixel floor; smaller images are scaled up before grid projection. */
-const MIN_PIXELS = 384 * 384
+const MIN_PIXELS = 544 * 544
+/** Pixels covered by one token cell along either axis. */
+const CELL_SIZE = PATCH_SIZE * DOWNSAMPLE_RATIO
 
 const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor)
 const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor)
@@ -33,12 +33,14 @@ interface GridResize {
   readonly numTokens: number
 }
 
-/** Token count of one grid, including row separators and framing. */
+/** Token count of one grid: every row carries a separator, plus two framing tokens. */
 function gridTokens(gridHeight: number, gridWidth: number): number {
-  let tokens = gridHeight * (gridWidth + 1) + 2
-  if (gridHeight % 2 === 1) tokens += gridWidth + 1
-  tokens += (ceilDiv(gridHeight, 2) * (gridWidth + 1) % 2) * 2
-  return tokens
+  return gridHeight * (gridWidth + 1) + 2
+}
+
+/** Token-cell count along one padded pixel axis. */
+function gridCells(paddedLength: number): number {
+  return ceilDiv(intDiv(paddedLength, PATCH_SIZE), DOWNSAMPLE_RATIO)
 }
 
 /** Solve the largest grid within `budget` tokens preserving the aspect ratio. */
@@ -50,80 +52,60 @@ function solveResizeRatio(height: number, width: number, budget: number): GridRe
   let bestWidth: number
   if (idealGridWidth < 1) {
     const solvedGridWidth = 1
-    let solvedGridHeight = intDiv(budget - 2, solvedGridWidth + 1)
-    // v8 ignore: at the provider budget the one-column solve always lands on
-    // the odd 189-row grid, so the even path is unreachable; kept for parity
-    // with the published solver.
-    /* v8 ignore next */
-    if (solvedGridHeight % 2 === 1) solvedGridHeight -= 1
-    bestWidth = solvedGridWidth * PATCH_SIZE * DOWNSAMPLE_RATIO
-    bestHeight = solvedGridHeight * PATCH_SIZE * DOWNSAMPLE_RATIO
-  /* v8 ignore start -- unreachable at the provider budget: idealGridWidth >= 1
-     bounds the aspect at (budget - 2) / 2, making idealGridHeight >= 2 for
-     every budget this module solves; kept for parity with the published
-     solver. */
-  } else if (idealGridHeight < 2) {
-    const solvedGridHeight = 2
+    const solvedGridHeight = intDiv(budget - 2, solvedGridWidth + 1)
+    bestWidth = solvedGridWidth * CELL_SIZE
+    bestHeight = solvedGridHeight * CELL_SIZE
+  } else if (idealGridHeight < 1) {
+    const solvedGridHeight = 1
     const solvedGridWidth = intDiv(budget - 2, solvedGridHeight) - 1
-    if (!(solvedGridWidth > 1)) throw new Error('deepseek image tokens: no grid fits the token budget')
-    bestWidth = solvedGridWidth * PATCH_SIZE * DOWNSAMPLE_RATIO
-    bestHeight = solvedGridHeight * PATCH_SIZE * DOWNSAMPLE_RATIO
-  /* v8 ignore stop */
+    bestWidth = solvedGridWidth * CELL_SIZE
+    bestHeight = solvedGridHeight * CELL_SIZE
   } else {
     const solvedGridWidth = Math.trunc(idealGridWidth)
-    let solvedGridHeight = Math.trunc(idealGridHeight)
-    if (solvedGridHeight % 2 === 1) solvedGridHeight -= 1
-    const widthScale = solvedGridWidth * PATCH_SIZE * DOWNSAMPLE_RATIO / width
-    const heightScale = solvedGridHeight * PATCH_SIZE * DOWNSAMPLE_RATIO / height
-    const scale = Math.min(widthScale, heightScale)
+    const solvedGridHeight = Math.trunc(idealGridHeight)
+    const scale = Math.min(solvedGridWidth * CELL_SIZE / width, solvedGridHeight * CELL_SIZE / height)
     bestWidth = Math.trunc(width * scale / PATCH_SIZE) * PATCH_SIZE
     bestHeight = Math.trunc(height * scale / PATCH_SIZE) * PATCH_SIZE
   }
-  const gridHeight = ceilDiv(intDiv(bestHeight, PATCH_SIZE), DOWNSAMPLE_RATIO)
-  const gridWidth = ceilDiv(intDiv(bestWidth, PATCH_SIZE), DOWNSAMPLE_RATIO)
+  const gridHeight = gridCells(bestHeight)
+  const gridWidth = gridCells(bestWidth)
   return { gridHeight, gridWidth, bestHeight, bestWidth, numTokens: gridTokens(gridHeight, gridWidth) }
 }
 
 /** Project padded pixel dimensions onto the largest in-budget token grid. */
 function safeResize(height: number, width: number, paddedHeight: number, paddedWidth: number): GridResize {
-  const gridHeight = ceilDiv(intDiv(paddedHeight, PATCH_SIZE), DOWNSAMPLE_RATIO)
-  const gridWidth = ceilDiv(intDiv(paddedWidth, PATCH_SIZE), DOWNSAMPLE_RATIO)
-  const pad = COMPRESS_PAD_TO - 1
-  const budget = MAX_IMAGE_TOKENS - pad
-  let result: GridResize = {
+  const gridHeight = gridCells(paddedHeight)
+  const gridWidth = gridCells(paddedWidth)
+  const direct: GridResize = {
     gridHeight,
     gridWidth,
     bestHeight: paddedHeight,
     bestWidth: paddedWidth,
     numTokens: gridTokens(gridHeight, gridWidth),
   }
-  if (result.numTokens > budget) {
-    result = solveResizeRatio(height, width, budget)
-    /* v8 ignore next 4 -- the published solver's safety net; the closed-form
-       solve stays within budget for every geometry the clamps admit. */
-    for (let reduced = budget; result.numTokens > budget; reduced -= 1) {
-      result = solveResizeRatio(height, width, reduced)
-    }
+  if (direct.numTokens <= MAX_IMAGE_TOKENS) return direct
+  const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS)
+  /* v8 ignore next 3 -- the published solver's assertion; the closed-form
+     solve stays within the budget for every positive geometry. */
+  if (solved.numTokens > MAX_IMAGE_TOKENS) {
+    throw new Error(`deepseek image tokens: no grid fits the token budget for ${width}x${height}`)
   }
-  return { ...result, numTokens: result.numTokens + pad }
+  return solved
 }
 
-/** One clamp-scale-pad-project pass; the caller iterates it to a fixpoint. */
+/** One scale-pad-project pass; the caller iterates it to a fixpoint. */
 function resizeOnce(width: number, height: number): GridResize {
-  let clampedWidth = width
-  let clampedHeight = height
-  if (clampedWidth > clampedHeight * MAX_WIDTH_HEIGHT_RATIO) {
-    clampedWidth = clampedHeight * MAX_WIDTH_HEIGHT_RATIO
-  }
-  const pixels = clampedWidth * clampedHeight
+  let scaledWidth = width
+  let scaledHeight = height
+  const pixels = scaledWidth * scaledHeight
   if (pixels < MIN_PIXELS && pixels > 0) {
     const scale = Math.sqrt(MIN_PIXELS / pixels)
-    clampedWidth = Math.trunc(clampedWidth * scale)
-    clampedHeight = Math.trunc(clampedHeight * scale)
+    scaledWidth = Math.trunc(scaledWidth * scale)
+    scaledHeight = Math.trunc(scaledHeight * scale)
   }
-  const paddedWidth = ceilDiv(clampedWidth, PATCH_SIZE) * PATCH_SIZE
-  const paddedHeight = ceilDiv(clampedHeight, PATCH_SIZE) * PATCH_SIZE
-  return safeResize(clampedHeight, clampedWidth, paddedHeight, paddedWidth)
+  const paddedWidth = ceilDiv(scaledWidth, PATCH_SIZE) * PATCH_SIZE
+  const paddedHeight = ceilDiv(scaledHeight, PATCH_SIZE) * PATCH_SIZE
+  return safeResize(scaledHeight, scaledWidth, paddedHeight, paddedWidth)
 }
 
 function sameResize(a: GridResize, b: GridResize): boolean {
@@ -135,11 +117,11 @@ function sameResize(a: GridResize, b: GridResize): boolean {
 }
 
 /**
- * Vision tokens DeepSeek v4 charges for one request image of the given
- * dimensions, at the worst-case alignment pad.
+ * Vision tokens DeepSeek charges for one request image of the given
+ * dimensions.
  * @param width - positive integer request-image width in pixels.
  * @param height - positive integer request-image height in pixels.
- * @returns the provider vision-token price, at most 384.
+ * @returns the provider vision-token price, at most 1024.
  */
 export function deepSeekImageTokens(width: number, height: number): number {
   let result = resizeOnce(width, height)

+ 2 - 2
packages/llm/llm-deepseek/src/request-pricing.ts

@@ -2,7 +2,7 @@
  * Provider-side request-image pricing for DeepSeek routes: reproduces the
  * adapter's deterministic request projection (per-model pixel budget,
  * oldest-first offload under the raw-byte and count budgets) and prices every
- * retained image with the published v4 vision-token accounting. Consumed
+ * retained image with the published vision-token accounting. Consumed
  * synchronously by the token meter through `LlmAdapter.imageRequestPricing`;
  * provider usage remains the authoritative anchor for completed requests.
  *
@@ -20,7 +20,7 @@ import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.
 export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024
 /** Provider request image-count limit. */
 export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600
-/** Total-pixel budget matching DeepSeek's normal vision projection. */
+/** Default total-pixel budget for harness request-image projection. */
 export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000
 /** Total-pixel budget matching provider low-detail image input. */
 export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512

+ 27 - 31
packages/llm/llm-deepseek/tests/image-tokens.spec.ts

@@ -1,53 +1,49 @@
 import { describe, expect, it } from 'vitest'
 import { deepSeekImageTokens } from '../src/image-tokens.ts'
 
-describe('DeepSeek v4 image tokens', () => {
+describe('DeepSeek image tokens', () => {
   // Reference values from the provider's published image token calculator
-  // (api-docs.deepseek.com, Token & Token Usage), at the worst-case pad.
+  // (api-docs.deepseek.com, Token & Token Usage) in its v41 configuration.
   it.each([
-    [100, 100, 117],
-    [384, 384, 117],
-    [640, 480, 209],
-    [800, 800, 349],
-    [1024, 768, 357],
-    [1920, 1080, 369],
-    [2000, 2000, 349],
-    [5000, 5000, 349],
-    [300, 50, 101],
+    [100, 100, 184],
+    [544, 544, 184],
+    [640, 480, 206],
+    [800, 800, 422],
+    [1024, 768, 496],
+    [1066, 600, 407],
+    [1300, 1300, 994],
+    [1920, 1080, 968],
+    [2000, 2000, 994],
+    [5000, 5000, 994],
+    [300, 50, 200],
+    [8192, 100, 593],
+    [16, 8192, 590],
   ])('prices %sx%s as %s tokens', (width, height, expected) => {
     expect(deepSeekImageTokens(width, height)).toBe(expected)
   })
 
-  it('caps every image at 384 tokens regardless of source size', () => {
-    for (const [width, height] of [[2000, 2000], [5000, 5000], [8192, 8192], [16, 8192]]) {
-      expect(deepSeekImageTokens(width!, height!)).toBeLessThanOrEqual(384)
+  it('caps every image at 1024 tokens regardless of source size', () => {
+    for (const [width, height] of [[2000, 2000], [5000, 5000], [8192, 8192], [16, 8192], [9000, 1], [1, 9000]]) {
+      expect(deepSeekImageTokens(width!, height!)).toBeLessThanOrEqual(1024)
     }
   })
 
   it('prices small images at the documented scale-up floor', () => {
-    // Below roughly 384x384 total pixels the provider scales up, so a tiny
-    // square costs the same as a 384x384 one.
-    expect(deepSeekImageTokens(100, 100)).toBe(deepSeekImageTokens(384, 384))
+    // Below roughly 544x544 total pixels the provider scales up, so a tiny
+    // square costs the same as a 544x544 one.
+    expect(deepSeekImageTokens(100, 100)).toBe(deepSeekImageTokens(544, 544))
   })
 
-  it('clamps extreme width by the aspect-ratio bound', () => {
-    // Width beyond 8x height projects onto the same clamped grid.
-    expect(deepSeekImageTokens(9000, 1)).toBe(113)
-    expect(deepSeekImageTokens(8192, 100)).toBe(113)
+  it('solves a one-row grid for an extremely wide image', () => {
+    expect(deepSeekImageTokens(9000, 1)).toBe(1024)
   })
 
   it('solves a one-column grid for an extremely tall image', () => {
-    // Height-dominant aspect drives the solver's single-column branch.
-    expect(deepSeekImageTokens(16, 8192)).toBe(381)
-    expect(deepSeekImageTokens(1, 9000)).toBe(381)
+    expect(deepSeekImageTokens(1, 9000)).toBe(1024)
   })
 
-  it('trims an odd solved grid height to the even row count', () => {
-    expect(deepSeekImageTokens(100, 4036)).toBe(253)
-  })
-
-  it('converges through a second projection pass when the first is not a fixpoint', () => {
-    expect(deepSeekImageTokens(4921, 353)).toBe(289)
-    expect(deepSeekImageTokens(97, 7289)).toBe(245)
+  it('converges through repeated projection passes when the first is not a fixpoint', () => {
+    expect(deepSeekImageTokens(12, 1123)).toBe(380)
+    expect(deepSeekImageTokens(89, 2076)).toBe(254)
   })
 })

+ 15 - 6
packages/llm/llm-deepseek/tests/request-pricing.spec.ts

@@ -44,18 +44,27 @@ describe('DeepSeek request-image pricing', () => {
     const image = ref('photo', 1920, 1080)
     const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image])
     expect(prices).toEqual([{
-      visualTokens: 369,
+      visualTokens: 407,
       text: requestImageHandleText(image, { width: 1066, height: 600 }),
     }])
   })
 
+  it.each([[8192, 1], [1, 8192]])('prices a %sx%s image at the token cap within the default pixel budget', (width, height) => {
+    const image = ref('thin', width, height)
+    const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image])
+    expect(prices).toEqual([{
+      visualTokens: 1024,
+      text: requestImageHandleText(image, { width, height }),
+    }])
+  })
+
   it('honors the low-detail pixel budget preset', () => {
     const image = ref('photo', 4096, 4096)
     const options = resolveAdapterOptions({
       models: [{ ...VISION_MODEL, imagePixelBudget: 'low' as const }],
     })
     const prices = deepSeekImageRequestPricing(options, 'vision').priceImages([image])
-    expect(prices[0]!.visualTokens).toBe(201)
+    expect(prices[0]!.visualTokens).toBe(184)
   })
 
   it('builds handle and placeholder text through the supplied access resolution', () => {
@@ -68,7 +77,7 @@ describe('DeepSeek request-image pricing', () => {
     ).priceImages(images)
     expect(prices[0]).toEqual({ visualTokens: 0, text: offloadedImageText(images[0]!, access) })
     expect(prices[1]).toEqual({
-      visualTokens: 349,
+      visualTokens: 422,
       text: requestImageHandleText(images[1]!, { width: 800, height: 800 }, access),
     })
     expect(prices[1]?.text).toContain('/world/attachments/photo.png')
@@ -82,8 +91,8 @@ describe('DeepSeek request-image pricing', () => {
     ).priceImages(images)
     expect(prices).toEqual([
       { visualTokens: 0, text: offloadedImageText(images[0]!) },
-      { visualTokens: 349, text: requestImageHandleText(images[1]!, { width: 800, height: 800 }) },
-      { visualTokens: 349, text: requestImageHandleText(images[2]!, { width: 800, height: 800 }) },
+      { visualTokens: 422, text: requestImageHandleText(images[1]!, { width: 800, height: 800 }) },
+      { visualTokens: 422, text: requestImageHandleText(images[2]!, { width: 800, height: 800 }) },
     ])
   })
 
@@ -100,7 +109,7 @@ describe('DeepSeek request-image pricing', () => {
       connection({ maxRequestFilesBytes: 2 * 1024 * 1024, imageOffloadByteQuantum: 1 }),
       'vision',
     ).priceImages(images)
-    expect(prices.map(price => price.visualTokens)).toEqual([0, 349, 349])
+    expect(prices.map(price => price.visualTokens)).toEqual([0, 422, 422])
     expect(prices[0]!.text).toBe(offloadedImageText(images[0]!))
   })
 })