Explorar o código

fix(llm-deepseek): 请求图片按 V4.1 token 网格投影

ImageRequestPolicy 改为投影规则加可选单边上限加字节目标,投影是 pixel-budget 或 token-grid 的封闭联合。token 网格求解器放在 dsh-attachment 的 request-projection 里,deepSeekImageTokens 复用同一个求解器计价。DeepSeek 路由默认按 14 px patch、3:1 降采样、1024 token 上限的网格投影,每张请求图片加 4096 像素单边上限,字节目标提高到 2 MiB;显式 imagePixelBudget 与 pi-ai 路由仍走总像素预算。缩放只按长边给定,短边由编码器四舍五入,与投影预测一致。变换版本升到 request-image-v6。

Closes #3929
creatixchu hai 1 semana
pai
achega
06c491508f

+ 4 - 8
packages/attachment/attachment-local/src/normalization.ts

@@ -79,14 +79,10 @@ function preparedPipeline(data: Uint8Array, width: number, height: number): Shar
 
 /** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */
 function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } {
-  const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels)
-  const longEdge = Math.max(budgeted.width, budgeted.height)
-  if (longEdge <= policy.maxDimension) return budgeted
-  const scale = policy.maxDimension / longEdge
-  return {
-    width: Math.max(1, Math.floor(budgeted.width * scale)),
-    height: Math.max(1, Math.floor(budgeted.height * scale)),
-  }
+  return requestImageDimensions(detected.width, detected.height, {
+    projection: { kind: 'pixel-budget', maxPixels: policy.maxPixels },
+    maxDimension: policy.maxDimension,
+  })
 }
 
 /**

+ 31 - 7
packages/attachment/attachment-local/src/request-image.ts

@@ -9,6 +9,7 @@ import type {
   ImageMediaType,
   ImageAttachmentRef,
   ImageRequestPolicy,
+  ImageRequestProjection,
   RequestImageAttachment,
   StoredImageAttachment,
 } from '@deepseek-ai/dsh-attachment'
@@ -22,7 +23,7 @@ import {
 import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts'
 
 /** Transform version included in every cache and upload-index identity. */
-export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5'
+export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v6'
 
 interface EncodedRequestImage {
   data: Uint8Array
@@ -47,15 +48,36 @@ function checkedInteger(value: number, name: string): number {
 }
 
 function validatePolicy(policy: ImageRequestPolicy): void {
-  checkedInteger(policy.maxPixels, 'Image request maxPixels')
+  const { projection } = policy
+  if (projection.kind === 'pixel-budget') {
+    checkedInteger(projection.maxPixels, 'Image request maxPixels')
+  } else {
+    checkedInteger(projection.patchSize, 'Image request patchSize')
+    checkedInteger(projection.downsampleRatio, 'Image request downsampleRatio')
+    checkedInteger(projection.maxTokens, 'Image request maxTokens')
+  }
+  if (policy.maxDimension !== undefined) checkedInteger(policy.maxDimension, 'Image request maxDimension')
   checkedInteger(policy.maxBytes, 'Image request maxBytes')
 }
 
+/** Projection fields in a fixed key order so equal policies digest identically. */
+function projectionDescriptor(projection: ImageRequestProjection): Record<string, number | string> {
+  return projection.kind === 'pixel-budget'
+    ? { kind: projection.kind, maxPixels: projection.maxPixels }
+    : {
+      kind: projection.kind,
+      patchSize: projection.patchSize,
+      downsampleRatio: projection.downsampleRatio,
+      maxTokens: projection.maxTokens,
+    }
+}
+
 function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string {
   return JSON.stringify({
     transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION,
     attachmentId: attachment.attachmentId,
-    routePixelBudget: policy.maxPixels,
+    projection: projectionDescriptor(policy.projection),
+    maxDimension: policy.maxDimension ?? null,
     encodedByteBudget: policy.maxBytes,
     encoding: {
       webpQualities: IMAGE_ENCODING_QUALITIES,
@@ -70,7 +92,7 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy):
 /**
  * Complete deterministic identity for one attachment and route-owned request policy.
  * @param attachment - provider-independent durable normalized attachment reference.
- * @param policy - route-owned pixel and byte policy.
+ * @param policy - route-owned projection, per-side cap, and byte policy.
  * @returns branded digest over every request transform input.
  */
 export function requestImageVariantId(
@@ -80,9 +102,11 @@ export function requestImageVariantId(
   return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`)
 }
 
+/** Resize by the source long edge only, so the encoder rounds the short edge exactly as the projection did. */
 function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp {
+  const byWidth = attachment.ref.width >= attachment.ref.height
   return sourcePipeline(attachment)
-    .resize({ width, height, fit: 'inside', withoutEnlargement: true })
+    .resize({ ...byWidth ? { width } : { height }, withoutEnlargement: true })
 }
 
 function sourcePipeline(attachment: StoredImageAttachment): Sharp {
@@ -94,7 +118,7 @@ async function createRequestImage(
   policy: ImageRequestPolicy,
   hasAlpha: boolean,
 ): Promise<EncodedRequestImage> {
-  const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels)
+  const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy)
   if (dimensions.width === attachment.ref.width
     && dimensions.height === attachment.ref.height
     && attachment.data.byteLength <= policy.maxBytes) {
@@ -126,7 +150,7 @@ async function readCached(
   try {
     const data = new Uint8Array(await readFile(path, { signal }))
     const detected = await probeImage(data)
-    const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels)
+    const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy)
     if (detected.depth !== 'uchar' || detected.space !== 'srgb'
       || detected.width > maximum.width || detected.height > maximum.height
       || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined

+ 1 - 1
packages/attachment/attachment-local/tests/index.spec.ts

@@ -85,7 +85,7 @@ describe('local attachment service', () => {
         String(ref.attachmentId).slice('sha256:'.length),
       ))
       await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data))
-      const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 })
+      const request = await service.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1024 })
       expect(request).not.toHaveProperty('access')
 
       const fileData = Uint8Array.of(0, 1, 2, 255)

+ 1 - 1
packages/attachment/attachment-local/tests/request-image-verification.spec.ts

@@ -38,7 +38,7 @@ describe('request image verification', () => {
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
     control.mismatch = true
 
-    await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }))
+    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 }))
       .rejects.toMatchObject({
         code: 'ATTACHMENT_WRITE_FAILED',
         message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.',

+ 52 - 17
packages/attachment/attachment-local/tests/request-image.spec.ts

@@ -48,7 +48,7 @@ describe('local request-image cache', () => {
     const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
     const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })
     const firstStored = await attachments.readImage(first)
-    const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 }
+    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 1_000 }, maxBytes: 1024 * 1024 }
 
     const request = await attachments.readImageRequest(first, policy)
     const batch = await Promise.all([first, second].map(
@@ -63,17 +63,52 @@ describe('local request-image cache', () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
 
-    await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 }))
+    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 0 }, maxBytes: 100 }))
       .rejects.toThrow('Image request maxPixels must be a positive integer')
-    await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 }))
+    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxBytes: 0 }))
       .rejects.toThrow('Image request maxBytes must be a positive integer')
+    await expect(attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 100 }, maxDimension: 0, maxBytes: 100 }))
+      .rejects.toThrow('Image request maxDimension must be a positive integer')
+    for (const field of ['patchSize', 'downsampleRatio', 'maxTokens'] as const) {
+      const projection = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024, [field]: 1.5 }
+      await expect(attachments.readImageRequest(attachment, { projection, maxBytes: 100 }))
+        .rejects.toThrow(`Image request ${field} must be a positive integer`)
+    }
+  })
+
+  it('projects onto a token grid, caps the long edge, and keys the cache by projection', async () => {
+    const attachments = await store()
+    const grid = { kind: 'token-grid' as const, patchSize: 14, downsampleRatio: 3, maxTokens: 1024 }
+    const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png' })
+    const small = await attachments.saveImage({ data: await image(800, 800), mediaType: 'image/png' })
+    const thin = await attachments.saveImage({ data: await image(8000, 40), mediaType: 'image/png' })
+    const wide = await attachments.saveImage({ data: await image(1920, 1080), mediaType: 'image/png' })
+    const tall = await attachments.saveImage({ data: await image(1080, 1920), mediaType: 'image/png' })
+
+    const squareRequest = await attachments.readImageRequest(square, { projection: grid, maxBytes: 2 * 1024 * 1024 })
+    const smallRequest = await attachments.readImageRequest(small, { projection: grid, maxBytes: 2 * 1024 * 1024 })
+    const thinRequest = await attachments.readImageRequest(thin, { projection: grid, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 })
+    const budgeted = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 1302 * 1302 }, maxBytes: 2 * 1024 * 1024 })
+    const uncapped = await attachments.readImageRequest(thin, { projection: grid, maxBytes: 2 * 1024 * 1024 })
+    const wideRequest = await attachments.readImageRequest(wide, { projection: grid, maxBytes: 2 * 1024 * 1024 })
+    const tallRequest = await attachments.readImageRequest(tall, { projection: grid, maxBytes: 2 * 1024 * 1024 })
+
+    expect(squareRequest).toMatchObject({ width: 1302, height: 1302, mediaType: 'image/jpeg' })
+    expect(smallRequest).toMatchObject({ width: 800, height: 800, mediaType: 'image/png' })
+    expect(smallRequest.data).toEqual((await attachments.readImage(small)).data)
+    expect(thinRequest).toMatchObject({ width: 4096, height: 20 })
+    expect(uncapped).toMatchObject({ width: 8000, height: 40 })
+    expect(wideRequest).toMatchObject({ width: 1708, height: 961 })
+    expect(tallRequest).toMatchObject({ width: 961, height: 1708 })
+    expect(budgeted.variantId).not.toBe(squareRequest.variantId)
+    expect(uncapped.variantId).not.toBe(thinRequest.variantId)
   })
 
   it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })
+    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 })
 
     expect(request.mediaType).toBe('image/jpeg')
     expect(request.bytes).toBeGreaterThan(1)
@@ -83,7 +118,7 @@ describe('local request-image cache', () => {
   it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => {
     const attachments = await store()
     const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })
-    const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
+    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 4_096 }
     const initial = await attachments.readImageRequest(attachment, policy)
     const hash = String(initial.variantId).slice('sha256:'.length)
     const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash)
@@ -132,10 +167,10 @@ describe('local request-image cache', () => {
       data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png',
     })
 
-    const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
-    const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
-    const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
-    const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 })
+    const squareRequest = await attachments.readImageRequest(square, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
+    const wideRequest = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
+    const repeated = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
+    const low = await attachments.readImageRequest(wide, { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxBytes: 1024 * 1024 })
 
     expect(squareRequest).toMatchObject({ width: 800, height: 800 })
     expect(wideRequest).toMatchObject({ width: 1130, height: 565 })
@@ -175,8 +210,8 @@ describe('local request-image cache', () => {
     const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })
     const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })
 
-    const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 })
-    const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 })
+    const photoRequest = await attachments.readImageRequest(photo, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 1024 * 1024 })
+    const alphaRequest = await attachments.readImageRequest(alpha, { projection: { kind: 'pixel-budget' as const, maxPixels: 128 * 128 }, maxBytes: 4_096 })
 
     expect(photoRequest.mediaType).toBe('image/jpeg')
     expect(alphaRequest.mediaType).toBe('image/webp')
@@ -192,7 +227,7 @@ describe('local request-image cache', () => {
     }).toColourspace('rgb16').png().toBuffer())
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 })
 
     expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
     expect(request.width * request.height).toBeLessThanOrEqual(16 * 16)
@@ -206,7 +241,7 @@ describe('local request-image cache', () => {
     const source = await complexOpaqueAlphaImage(64, 32)
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 16 * 16 }, maxBytes: 1024 * 1024 })
 
     expect(request.mediaType).toBe('image/webp')
     await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false })
@@ -228,7 +263,7 @@ describe('local request-image cache', () => {
     }).png().toBuffer())
     const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
 
-    const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
+    const request = await attachments.readImageRequest(attachment, { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 })
 
     expect(request).toMatchObject({ width: 800, height: 800 })
     expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
@@ -241,7 +276,7 @@ describe('local request-image cache', () => {
     })
     const run = vi.spyOn(CompressionLimiter.prototype, 'run')
     const controller = new AbortController()
-    const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
+    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }
 
     const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
     const completed = attachments.readImageRequest(attachment, policy)
@@ -271,7 +306,7 @@ describe('local request-image cache', () => {
     const controller = new AbortController()
     const request = attachments.readImageRequest(
       attachment,
-      { maxPixels: 640_000, maxBytes: 1024 * 1024 },
+      { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 },
       controller.signal,
     )
     await vi.waitFor(() => {
@@ -304,7 +339,7 @@ describe('local request-image cache', () => {
       return actualRead(ref, signal)
     })
     const controller = new AbortController()
-    const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
+    const policy = { projection: { kind: 'pixel-budget' as const, maxPixels: 640_000 }, maxBytes: 1024 * 1024 }
     const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
     await vi.waitFor(() => {
       expect(calls).toBe(1)

+ 5 - 2
packages/attachment/attachment/src/index.ts

@@ -22,7 +22,8 @@ export { AttachmentId, ImageVariantId } from './brand.ts'
 export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts'
 export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
 export { admitEncodedFile, admitEncodedImages } from './admission.ts'
-export { requestImageDimensions } from './request-projection.ts'
+export { requestImageDimensions, tokenGridProjection } from './request-projection.ts'
+export type { ProjectedDimensions } from './request-projection.ts'
 export type {
   AttachmentId as AttachmentIdType,
   AdmittedPromptContentPart,
@@ -33,6 +34,7 @@ export type {
   ImageAttachmentLimits,
   ImageAttachmentRef,
   ImageRequestPolicy,
+  ImageRequestProjection,
   ImageMediaType,
   PromptContentPart,
   RequestImageAttachment,
@@ -40,6 +42,7 @@ export type {
   SaveFileStreamAttachment,
   SaveImageAttachment,
   StoredImageAttachment,
+  TokenGridProjection,
 } from './types.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -241,7 +244,7 @@ export abstract class AttachmentStore extends Service {
   /**
    * Generate or read one deterministic model-request version from the stored normalized image.
    * @param ref - durable provider-independent normalized attachment reference.
-   * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.
+   * @param policy - route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output.
    * @param signal - optional cancellation.
    * @returns request bytes and the cache/upload identity covering every transform input.
    */

+ 114 - 12
packages/attachment/attachment/src/request-projection.ts

@@ -3,18 +3,19 @@
  * provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection
  */
 
-/**
- * Compute aspect-preserving integer dimensions within a hard total-pixel budget.
- * @param width - positive source width.
- * @param height - positive source height.
- * @param maxPixels - positive width-times-height cap.
- * @returns inward-rounded dimensions; small images are not enlarged.
- */
-export function requestImageDimensions(
-  width: number,
-  height: number,
-  maxPixels: number,
-): { width: number; height: number } {
+import type { ImageRequestPolicy, ImageRequestProjection, TokenGridProjection } from './types.ts'
+
+/** Integer width and height of one projected image. */
+export interface ProjectedDimensions {
+  width: number
+  height: number
+}
+
+const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor)
+const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor)
+
+/** Aspect-preserving integer dimensions within a hard total-pixel budget; small images are not enlarged. */
+function pixelBudgetDimensions(width: number, height: number, maxPixels: number): ProjectedDimensions {
   const scale = Math.min(1, Math.sqrt(maxPixels / (width * height)))
   if (scale === 1) return { width, height }
   if (width >= height) {
@@ -34,3 +35,104 @@ export function requestImageDimensions(
   }
   return { width: projectedWidth, height: projectedHeight }
 }
+
+/** Token count of one grid: every row carries a separator, plus two framing tokens. */
+function gridTokens(rows: number, columns: number): number {
+  return rows * (columns + 1) + 2
+}
+
+/**
+ * Project one image onto the token grid of a `token-grid` projection. The
+ * provider pads each edge up to a whole patch, groups patches into token
+ * cells, and keeps the padded source when its cell grid fits `maxTokens`;
+ * otherwise it solves the largest aspect-preserving grid inside the cap, whose
+ * edges are whole patches. The closed-form solve always lands inside the cap.
+ * @param width - positive integer source width in pixels.
+ * @param height - positive integer source height in pixels.
+ * @param grid - patch size, per-axis downsampling ratio, and token cap.
+ * @returns the dimensions the provider retains and the tokens it charges.
+ */
+export function tokenGridProjection(
+  width: number,
+  height: number,
+  grid: Extract<ImageRequestProjection, { kind: 'token-grid' }>,
+): TokenGridProjection {
+  const { patchSize, downsampleRatio, maxTokens } = grid
+  const cells = (paddedLength: number): number => ceilDiv(intDiv(paddedLength, patchSize), downsampleRatio)
+  const paddedWidth = ceilDiv(width, patchSize) * patchSize
+  const paddedHeight = ceilDiv(height, patchSize) * patchSize
+  const directTokens = gridTokens(cells(paddedHeight), cells(paddedWidth))
+  if (directTokens <= maxTokens) {
+    return { width: paddedWidth, height: paddedHeight, tokens: directTokens, unscaled: true }
+  }
+  const cellSize = patchSize * downsampleRatio
+  const aspect = height / width
+  const idealColumns = Math.sqrt((maxTokens - 2) / aspect + 0.25) - 0.5
+  const idealRows = idealColumns * aspect
+  let bestWidth: number
+  let bestHeight: number
+  if (idealColumns < 1) {
+    bestWidth = cellSize
+    bestHeight = intDiv(maxTokens - 2, 2) * cellSize
+  } else if (idealRows < 1) {
+    bestWidth = (maxTokens - 3) * cellSize
+    bestHeight = cellSize
+  } else {
+    const columns = Math.trunc(idealColumns)
+    const rows = Math.trunc(idealRows)
+    const scale = Math.min(columns * cellSize / width, rows * cellSize / height)
+    bestWidth = Math.trunc(width * scale / patchSize) * patchSize
+    bestHeight = Math.trunc(height * scale / patchSize) * patchSize
+  }
+  return {
+    width: bestWidth,
+    height: bestHeight,
+    tokens: gridTokens(cells(bestHeight), cells(bestWidth)),
+    unscaled: false,
+  }
+}
+
+/** Aspect-preserving dimensions with an exact long edge; the short edge rounds to the nearest pixel. */
+function fromLongEdge(width: number, height: number, longEdge: number): ProjectedDimensions {
+  return width >= height
+    ? { width: longEdge, height: Math.max(1, Math.round(longEdge * height / width)) }
+    : { width: Math.max(1, Math.round(longEdge * width / height)), height: longEdge }
+}
+
+function projectDimensions(width: number, height: number, projection: ImageRequestProjection): ProjectedDimensions {
+  switch (projection.kind) {
+    case 'pixel-budget':
+      return pixelBudgetDimensions(width, height, projection.maxPixels)
+    case 'token-grid': {
+      const fit = tokenGridProjection(width, height, projection)
+      if (fit.unscaled) return { width, height }
+      return fromLongEdge(width, height, width >= height ? fit.width : fit.height)
+    }
+    /* v8 ignore next 4 -- ImageRequestProjection is a closed union; this branch is only the static exhaustiveness guard. */
+    default: {
+      const unreachable: never = projection
+      throw new Error(`unknown image request projection ${JSON.stringify(unreachable)}`)
+    }
+  }
+}
+
+/**
+ * Compute the aspect-preserving integer dimensions of one request image:
+ * the policy projection first, then the optional per-side cap. Small images
+ * are never enlarged. A downscaled result keeps the source long edge exact
+ * and rounds the short edge, matching a long-edge-only encoder resize.
+ * @param width - positive source width.
+ * @param height - positive source height.
+ * @param policy - route projection rule and optional per-side cap.
+ * @returns integer dimensions.
+ */
+export function requestImageDimensions(
+  width: number,
+  height: number,
+  policy: Pick<ImageRequestPolicy, 'projection' | 'maxDimension'>,
+): ProjectedDimensions {
+  const projected = projectDimensions(width, height, policy.projection)
+  const longEdge = Math.max(projected.width, projected.height)
+  if (policy.maxDimension === undefined || longEdge <= policy.maxDimension) return projected
+  return fromLongEdge(width, height, policy.maxDimension)
+}

+ 38 - 2
packages/attachment/attachment/src/types.ts

@@ -132,10 +132,46 @@ export interface StoredImageAttachment {
   data: Uint8Array
 }
 
+/** Aspect-preserving downscale rule of one request image; small images are never enlarged. */
+export type ImageRequestProjection =
+  | {
+    /** Hard cap on width multiplied by height. */
+    kind: 'pixel-budget'
+    /** Maximum width multiplied by height after projection. */
+    maxPixels: number
+  }
+  | {
+    /**
+     * Largest aspect-preserving patch grid whose token count
+     * `rows × (columns + 1) + 2` fits `maxTokens`; the DeepSeek published vision layout.
+     */
+    kind: 'token-grid'
+    /** Patch edge in pixels; a downscaled edge is a whole number of patches. */
+    patchSize: number
+    /** Patches per token cell along each axis. */
+    downsampleRatio: number
+    /** Token cap for one image. */
+    maxTokens: number
+  }
+
+/** Dimensions a `token-grid` projection retains for one image and the tokens it charges. */
+export interface TokenGridProjection {
+  /** Retained width: the patch-padded source when it fits, otherwise the solved width. */
+  width: number
+  /** Retained height: the patch-padded source when it fits, otherwise the solved height. */
+  height: number
+  /** Tokens charged for the retained grid. */
+  tokens: number
+  /** Whether the patch-padded source already fits `maxTokens` without downscaling. */
+  unscaled: boolean
+}
+
 /** Deterministic request-image policy selected by one exact model route. */
 export interface ImageRequestPolicy {
-  /** Maximum width multiplied by height after aspect-preserving projection. */
-  maxPixels: number
+  /** Downscale rule applied before the per-side cap. */
+  projection: ImageRequestProjection
+  /** Maximum width and maximum height after projection; omission bounds the long edge by the projection alone. */
+  maxDimension?: number
   /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */
   maxBytes: number
 }

+ 2 - 2
packages/attachment/attachment/tests/index.spec.ts

@@ -154,12 +154,12 @@ describe('AttachmentStore.readImageRequest', () => {
   it('reports unsupported request projection while preserving cancellation', async () => {
     const store = new UnsupportedProjectionStore(new Context())
     const ref = await new RecordingStore(new Context()).saveImage(image(1))
-    await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }))
+    await expect(store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }))
       .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' })
     const controller = new AbortController()
     const reason = new Error('cancel unsupported projection')
     controller.abort(reason)
-    expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason)
+    expect(() => store.readImageRequest(ref, { projection: { kind: 'pixel-budget' as const, maxPixels: 1 }, maxBytes: 1 }, controller.signal)).toThrow(reason)
   })
 
   it('rejects generic-file storage and exposes no provider-owned host path by default', async () => {

+ 61 - 5
packages/attachment/attachment/tests/request-projection.spec.ts

@@ -1,14 +1,18 @@
 import { describe, expect, it } from 'vitest'
-import { requestImageDimensions } from '../src/index.ts'
+import { requestImageDimensions, tokenGridProjection } from '../src/index.ts'
+import type { ImageRequestProjection } from '../src/index.ts'
 
-describe('request image dimensions', () => {
+const PIXEL_BUDGET: ImageRequestProjection = { kind: 'pixel-budget', maxPixels: 640_000 }
+const TOKEN_GRID = { kind: 'token-grid', patchSize: 14, downsampleRatio: 3, maxTokens: 1024 } as const
+
+describe('pixel-budget projection', () => {
   it.each([
     [4096, 4096, 800, 800],
     [4096, 2048, 1130, 565],
     [3840, 2160, 1066, 600],
     [320, 240, 320, 240],
   ])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => {
-    const projected = requestImageDimensions(width, height, 640_000)
+    const projected = requestImageDimensions(width, height, { projection: PIXEL_BUDGET })
     expect(projected).toEqual({
       width: expectedWidth,
       height: expectedHeight,
@@ -17,13 +21,65 @@ describe('request image dimensions', () => {
   })
 
   it('projects a portrait within the same total-pixel budget', () => {
-    const projected = requestImageDimensions(2160, 3840, 640_000)
+    const projected = requestImageDimensions(2160, 3840, { projection: PIXEL_BUDGET })
 
     expect(projected).toEqual({ width: 600, height: 1066 })
     expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
   })
 
   it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => {
-    expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
+    expect(requestImageDimensions(2, 4, { projection: { kind: 'pixel-budget', maxPixels: 5 } })).toEqual({ width: 1, height: 2 })
+  })
+})
+
+describe('token-grid projection', () => {
+  it('keeps the patch-padded source when its grid fits the token cap', () => {
+    expect(tokenGridProjection(800, 800, TOKEN_GRID)).toEqual({ width: 812, height: 812, tokens: 422, unscaled: true })
+    expect(tokenGridProjection(1302, 1302, TOKEN_GRID)).toEqual({ width: 1302, height: 1302, tokens: 994, unscaled: true })
+  })
+
+  it.each([
+    [1303, 1303, 1302, 1302, 994],
+    [4096, 4096, 1302, 1302, 994],
+    [3840, 2160, 1708, 966, 968],
+    [2160, 3840, 966, 1708, 986],
+    [4000, 1000, 2520, 630, 917],
+  ])('solves %sx%s onto the largest in-cap grid %sx%s', (width, height, expectedWidth, expectedHeight, tokens) => {
+    expect(tokenGridProjection(width, height, TOKEN_GRID)).toEqual({
+      width: expectedWidth,
+      height: expectedHeight,
+      tokens,
+      unscaled: false,
+    })
+    expect(expectedWidth % TOKEN_GRID.patchSize).toBe(0)
+    expect(expectedHeight % TOKEN_GRID.patchSize).toBe(0)
+  })
+
+  it('solves one-row and one-column grids for extreme aspect ratios', () => {
+    expect(tokenGridProjection(100_000, 1, TOKEN_GRID)).toEqual({ width: 42_882, height: 42, tokens: 1024, unscaled: false })
+    expect(tokenGridProjection(1, 100_000, TOKEN_GRID)).toEqual({ width: 42, height: 21_462, tokens: 1024, unscaled: false })
+  })
+
+  it('sends a fitting source at its own dimensions and a larger source at the solved grid', () => {
+    expect(requestImageDimensions(800, 800, { projection: TOKEN_GRID })).toEqual({ width: 800, height: 800 })
+    expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID })).toEqual({ width: 8192, height: 78 })
+    expect(requestImageDimensions(2048, 1024, { projection: TOKEN_GRID })).toEqual({ width: 1848, height: 924 })
+  })
+
+  it('keeps the solved long edge and rounds the short edge to the source aspect ratio', () => {
+    expect(requestImageDimensions(3840, 2160, { projection: TOKEN_GRID })).toEqual({ width: 1708, height: 961 })
+    expect(requestImageDimensions(1080, 2400, { projection: TOKEN_GRID })).toEqual({ width: 838, height: 1862 })
+  })
+})
+
+describe('per-side cap', () => {
+  it('scales the projected image down to the cap on its long edge', () => {
+    expect(requestImageDimensions(8192, 78, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 4096, height: 39 })
+    expect(requestImageDimensions(1, 8192, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1, height: 4096 })
+    expect(requestImageDimensions(10_000, 100, { projection: PIXEL_BUDGET, maxDimension: 4096 })).toEqual({ width: 4096, height: 41 })
+  })
+
+  it('leaves an image within the cap untouched', () => {
+    expect(requestImageDimensions(2048, 2048, { projection: TOKEN_GRID, maxDimension: 4096 })).toEqual({ width: 1302, height: 1302 })
   })
 })

+ 6 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -550,7 +550,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise<RequestImageAttachment>',
         description: 'Generate or read one deterministic model-request version from the stored normalized image.',
-        parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }],
+        parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'route projection, optional per-side cap, and byte target; an unmet target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }],
         returns: 'request bytes and the cache/upload identity covering every transform input.',
       },
     ],
@@ -4360,7 +4360,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ImageRequestPolicy',
-    declaration: 'export interface ImageRequestPolicy {\n    maxPixels: number;\n    maxBytes: number;\n}',
+    declaration: 'export interface ImageRequestPolicy {\n    projection: ImageRequestProjection;\n    maxDimension?: number;\n    maxBytes: number;\n}',
+  },
+  {
+    name: 'ImageRequestProjection',
+    declaration: 'export type ImageRequestProjection = {\n    kind: \'pixel-budget\';\n    maxPixels: number;\n} | {\n    kind: \'token-grid\';\n    patchSize: number;\n    downsampleRatio: number;\n    maxTokens: number;\n};',
   },
   {
     name: 'ImageVariantId',

+ 5 - 1
packages/llm/llm-deepseek/src/adapter.ts

@@ -60,7 +60,11 @@ export interface DeepSeekCatalogModel {
   maxTokens?: number
   /** Accepted request modalities; omission is text-only. */
   inputModalities?: ModelModality[]
-  /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */
+  /**
+   * Total-pixel budget replacing the published token-grid projection for one
+   * deterministic request preview, or the 512-by-512 `low` preset; omission
+   * projects onto the token grid.
+   */
   imagePixelBudget?: number | 'low'
   /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */
   imageMaxBytes?: number

+ 27 - 107
packages/llm/llm-deepseek/src/image-tokens.ts

@@ -1,119 +1,39 @@
 /**
  * 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.
+ * calculator (api-docs.deepseek.com, Token & Token Usage) in its current
+ * `v41` configuration. The provider scales an image below 544×544 total
+ * pixels up, then projects it onto the token grid shared with request
+ * projection, iterating to a fixpoint. 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
  */
 
-/** Vision patch edge in pixels. */
-const PATCH_SIZE = 14
-/** Per-axis patch-to-token downsampling ratio. */
-const DOWNSAMPLE_RATIO = 3
-/** Provider cap on tokens for one request image. */
-const MAX_IMAGE_TOKENS = 1024
-/** Total-pixel floor; smaller images are scaled up before grid projection. */
-const MIN_PIXELS = 544 * 544
-/** Pixels covered by one token cell along either axis. */
-const CELL_SIZE = PATCH_SIZE * DOWNSAMPLE_RATIO
+import { tokenGridProjection } from '@deepseek-ai/dsh-attachment'
+import type { ImageRequestProjection, TokenGridProjection } from '@deepseek-ai/dsh-attachment'
 
-const intDiv = (value: number, divisor: number): number => Math.floor(value / divisor)
-const ceilDiv = (value: number, divisor: number): number => Math.floor((value + divisor - 1) / divisor)
+/** Published DeepSeek vision grid: 14px patches, 3:1 per-axis downsampling, at most 1024 tokens per image. */
+export const DEEPSEEK_IMAGE_TOKEN_GRID = {
+  kind: 'token-grid',
+  patchSize: 14,
+  downsampleRatio: 3,
+  maxTokens: 1024,
+} as const satisfies ImageRequestProjection
 
-interface GridResize {
-  readonly gridHeight: number
-  readonly gridWidth: number
-  readonly bestHeight: number
-  readonly bestWidth: number
-  readonly numTokens: number
-}
-
-/** Token count of one grid: every row carries a separator, plus two framing tokens. */
-function gridTokens(gridHeight: number, gridWidth: number): number {
-  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. */
-function solveResizeRatio(height: number, width: number, budget: number): GridResize {
-  const aspect = height / width
-  const idealGridWidth = Math.sqrt((budget - 2) / aspect + 0.25) - 0.5
-  const idealGridHeight = idealGridWidth * aspect
-  let bestHeight: number
-  let bestWidth: number
-  if (idealGridWidth < 1) {
-    const solvedGridWidth = 1
-    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
-    bestWidth = solvedGridWidth * CELL_SIZE
-    bestHeight = solvedGridHeight * CELL_SIZE
-  } else {
-    const solvedGridWidth = Math.trunc(idealGridWidth)
-    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 = 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 = gridCells(paddedHeight)
-  const gridWidth = gridCells(paddedWidth)
-  const direct: GridResize = {
-    gridHeight,
-    gridWidth,
-    bestHeight: paddedHeight,
-    bestWidth: paddedWidth,
-    numTokens: gridTokens(gridHeight, gridWidth),
-  }
-  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 solved
-}
+/** Total-pixel floor; smaller images are scaled up before grid projection. */
+const MIN_PIXELS = 544 * 544
 
-/** One scale-pad-project pass; the caller iterates it to a fixpoint. */
-function resizeOnce(width: number, height: number): GridResize {
-  let scaledWidth = width
-  let scaledHeight = height
-  const pixels = scaledWidth * scaledHeight
-  if (pixels < MIN_PIXELS && pixels > 0) {
-    const scale = Math.sqrt(MIN_PIXELS / pixels)
-    scaledWidth = Math.trunc(scaledWidth * scale)
-    scaledHeight = Math.trunc(scaledHeight * scale)
-  }
-  const paddedWidth = ceilDiv(scaledWidth, PATCH_SIZE) * PATCH_SIZE
-  const paddedHeight = ceilDiv(scaledHeight, PATCH_SIZE) * PATCH_SIZE
-  return safeResize(scaledHeight, scaledWidth, paddedHeight, paddedWidth)
+/** One scale-up-then-project pass; the caller iterates it to a fixpoint. */
+function resizeOnce(width: number, height: number): TokenGridProjection {
+  const pixels = width * height
+  if (pixels >= MIN_PIXELS) return tokenGridProjection(width, height, DEEPSEEK_IMAGE_TOKEN_GRID)
+  const scale = Math.sqrt(MIN_PIXELS / pixels)
+  return tokenGridProjection(Math.trunc(width * scale), Math.trunc(height * scale), DEEPSEEK_IMAGE_TOKEN_GRID)
 }
 
-function sameResize(a: GridResize, b: GridResize): boolean {
-  return a.gridHeight === b.gridHeight
-    && a.gridWidth === b.gridWidth
-    && a.bestHeight === b.bestHeight
-    && a.bestWidth === b.bestWidth
-    && a.numTokens === b.numTokens
+function sameResize(a: TokenGridProjection, b: TokenGridProjection): boolean {
+  return a.width === b.width && a.height === b.height && a.tokens === b.tokens
 }
 
 /**
@@ -126,8 +46,8 @@ function sameResize(a: GridResize, b: GridResize): boolean {
 export function deepSeekImageTokens(width: number, height: number): number {
   let result = resizeOnce(width, height)
   for (let iteration = 1; iteration < 10; iteration += 1) {
-    const next = resizeOnce(result.bestWidth, result.bestHeight)
-    if (sameResize(next, result)) return result.numTokens
+    const next = resizeOnce(result.width, result.height)
+    if (sameResize(next, result)) return result.tokens
     result = next
   }
   /* v8 ignore next 2 -- the published solver's non-convergence guard; every

+ 3 - 11
packages/llm/llm-deepseek/src/index.ts

@@ -38,11 +38,9 @@ import {
 } from './adapter.ts'
 import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
 import {
-  DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET,
   DEFAULT_MAX_IMAGES_PER_REQUEST,
   DEFAULT_MAX_REQUEST_FILES_BYTES,
   DEFAULT_REQUEST_IMAGE_MAX_BYTES,
-  DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
 } from './request-pricing.ts'
 
 export {
@@ -65,11 +63,11 @@ export {
   DEFAULT_MAX_IMAGES_PER_REQUEST,
   DEFAULT_MAX_REQUEST_FILES_BYTES,
   DEFAULT_REQUEST_IMAGE_MAX_BYTES,
-  DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
+  REQUEST_IMAGE_MAX_DIMENSION,
   deepSeekImageRequestPricing,
   resolveRequestImagePolicy,
 } from './request-pricing.ts'
-export { deepSeekImageTokens } from './image-tokens.ts'
+export { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts'
 export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts'
 export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts'
 export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts'
@@ -95,8 +93,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
     name: 'DeepSeek-V41-Flash',
     contextWindow: DEFAULT_CONTEXT_WINDOW,
     inputModalities: ['text', 'image'],
-    imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
-    imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
     systemPromptUpdate: 'in-history',
   },
   {
@@ -116,8 +112,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
     name: 'DeepSeek-V4-Flash-Vision-Exp',
     contextWindow: DEFAULT_CONTEXT_WINDOW,
     inputModalities: ['text', 'image'],
-    imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
-    imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
   },
 ]
 
@@ -285,9 +279,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
       inputModalities: [...inputModalities],
       ...hasImage
         ? {
-          imagePixelBudget: model.imagePixelBudget === 'low'
-            ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
-            : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
+          ...model.imagePixelBudget === undefined ? {} : { imagePixelBudget: model.imagePixelBudget },
           imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES,
         }
         : {},

+ 23 - 16
packages/llm/llm-deepseek/src/request-pricing.ts

@@ -1,6 +1,6 @@
 /**
  * Provider-side request-image pricing for DeepSeek routes: reproduces the
- * adapter's deterministic request projection (per-model pixel budget,
+ * adapter's deterministic request projection (per-model projection,
  * oldest-first offload under the raw-byte and count budgets) and prices every
  * retained image with the published vision-token accounting. Consumed
  * synchronously by the token meter through `LlmAdapter.imageRequestPricing`;
@@ -12,36 +12,43 @@
 import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm'
 import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm'
 import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
-import type { ImageAttachmentRef, ImageRequestPolicy } from '@deepseek-ai/dsh-attachment'
-import { deepSeekImageTokens } from './image-tokens.ts'
+import type { ImageAttachmentRef, ImageRequestPolicy, ImageRequestProjection } from '@deepseek-ai/dsh-attachment'
+import { DEEPSEEK_IMAGE_TOKEN_GRID, deepSeekImageTokens } from './image-tokens.ts'
 import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
 
 /** Default bound on accumulated file-referenced image bytes per request. */
 export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024
 /** Provider request image-count limit. */
 export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600
-/** 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
 /** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */
-export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
+export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 2 * 1024 * 1024
+/**
+ * Provider per-side limit for a request carrying 15 or more images, applied
+ * to every request image so the image count never changes a projection.
+ */
+export const REQUEST_IMAGE_MAX_DIMENSION = 4096
 
 /**
- * Resolve the request-image budgets owned by one DeepSeek model route.
+ * Resolve the request-image policy owned by one DeepSeek model route: the
+ * published token grid unless the model overrides it with a pixel budget,
+ * the provider per-side limit, and the encoded-byte target.
  * @param model - Advertised model route and its optional image overrides.
- * @returns Complete pixel and encoded-byte budgets.
+ * @returns Complete projection, per-side cap, and encoded-byte target.
  * @internal
  */
 export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy {
-  const maxPixels = model.imagePixelBudget === 'low'
-    ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET
-    : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
+  const projection: ImageRequestProjection = model.imagePixelBudget === undefined
+    ? DEEPSEEK_IMAGE_TOKEN_GRID
+    : {
+      kind: 'pixel-budget',
+      maxPixels: model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget,
+    }
   return {
-    maxPixels,
-    maxBytes: model.imageMaxBytes === undefined
-      ? DEFAULT_REQUEST_IMAGE_MAX_BYTES
-      : model.imageMaxBytes,
+    projection,
+    maxDimension: REQUEST_IMAGE_MAX_DIMENSION,
+    maxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES,
   }
 }
 
@@ -95,7 +102,7 @@ export function deepSeekImageRequestPricing(
         if (index < offloaded) {
           return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) }
         }
-        const dimensions = requestImageDimensions(ref.width, ref.height, policy.maxPixels)
+        const dimensions = requestImageDimensions(ref.width, ref.height, policy)
         return {
           visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height),
           text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)),

+ 8 - 8
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -20,7 +20,7 @@ import { SessionId } from '@deepseek-ai/dsh-session'
 import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
 import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
-import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
+import { DEEPSEEK_IMAGE_TOKEN_GRID, DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
 import { httpErrorCode } from '../src/adapter.ts'
 import { resolveRequestImagePolicy } from '../src/request-pricing.ts'
 import { assemble } from './assemble.ts'
@@ -145,15 +145,15 @@ describe('request image policy', () => {
   it.each([
     [
       { id: 'default' },
-      { maxPixels: 640_000, maxBytes: 1024 * 1024 },
+      { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
     ],
     [
       { id: 'low', imagePixelBudget: 'low' as const },
-      { maxPixels: 512 * 512, maxBytes: 1024 * 1024 },
+      { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
     ],
     [
       { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 },
-      { maxPixels: 320_000, maxBytes: 512_000 },
+      { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 512_000 },
     ],
   ])('resolves route-owned defaults and overrides for %s', (model, expected) => {
     expect(resolveRequestImagePolicy(model)).toEqual(expected)
@@ -407,7 +407,7 @@ describe('DeepSeekAdapter against a mock server', () => {
       bytes: 3,
     }])
     expect(signalSeen[0]).toBeInstanceOf(AbortSignal)
-    expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }])
+    expect(policies).toEqual([{ projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 }])
   })
 
   it('falls back to one all-base64 request when Files API resolution fails', async () => {
@@ -621,7 +621,7 @@ describe('DeepSeekAdapter against a mock server', () => {
 
     expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith(
       recent,
-      { maxPixels: 640_000, maxBytes: 1024 * 1024 },
+      { projection: DEEPSEEK_IMAGE_TOKEN_GRID, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
       expect.any(AbortSignal),
     )
     const body = server.requests[0] as { messages: unknown[] }
@@ -672,13 +672,13 @@ describe('DeepSeekAdapter against a mock server', () => {
     expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith(
       1,
       imageRef,
-      { maxPixels: 512 * 512, maxBytes: 512_000 },
+      { projection: { kind: 'pixel-budget' as const, maxPixels: 512 * 512 }, maxDimension: 4096, maxBytes: 512_000 },
       expect.any(AbortSignal),
     )
     expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith(
       2,
       imageRef,
-      { maxPixels: 320_000, maxBytes: 1024 * 1024 },
+      { projection: { kind: 'pixel-budget' as const, maxPixels: 320_000 }, maxDimension: 4096, maxBytes: 2 * 1024 * 1024 },
       expect.any(AbortSignal),
     )
   })

+ 22 - 7
packages/llm/llm-deepseek/tests/request-pricing.spec.ts

@@ -44,17 +44,32 @@ describe('DeepSeek request-image pricing', () => {
     const image = ref('photo', 1920, 1080)
     const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image])
     expect(prices).toEqual([{
-      visualTokens: 407,
-      text: requestImageHandleText(image, { width: 1066, height: 600 }),
+      visualTokens: 968,
+      text: requestImageHandleText(image, { width: 1708, height: 961 }),
     }])
   })
 
-  it.each([[8192, 1], [1, 8192]])('prices a %sx%s image at the token cap within the default pixel budget', (width, height) => {
+  it.each([
+    [8192, 1, 4096, 1, 832],
+    [1, 8192, 1, 4096, 1024],
+  ])('prices a %sx%s image at its per-side-capped %sx%s request dimensions', (width, height, cappedWidth, cappedHeight, tokens) => {
     const image = ref('thin', width, height)
     const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image])
     expect(prices).toEqual([{
-      visualTokens: 1024,
-      text: requestImageHandleText(image, { width, height }),
+      visualTokens: tokens,
+      text: requestImageHandleText(image, { width: cappedWidth, height: cappedHeight }),
+    }])
+  })
+
+  it('honors a numeric pixel budget override', () => {
+    const image = ref('photo', 4096, 4096)
+    const options = resolveAdapterOptions({
+      models: [{ ...VISION_MODEL, imagePixelBudget: 640_000 }],
+    })
+    const prices = deepSeekImageRequestPricing(options, 'vision').priceImages([image])
+    expect(prices).toEqual([{
+      visualTokens: 422,
+      text: requestImageHandleText(image, { width: 800, height: 800 }),
     }])
   })
 
@@ -97,7 +112,7 @@ describe('DeepSeek request-image pricing', () => {
   })
 
   it('caps each occurrence at the per-image byte target before the byte budget', () => {
-    // Each 5 MiB source counts as the 1 MiB request target, so a 2 MiB budget
+    // Each 5 MiB source counts as the 2 MiB request target, so a 4 MiB budget
     // with a one-byte quantum removes exactly the oldest occurrence.
     const oversized = 5 * 1024 * 1024
     const images = [
@@ -106,7 +121,7 @@ describe('DeepSeek request-image pricing', () => {
       ref('third', 800, 800, oversized),
     ]
     const prices = deepSeekImageRequestPricing(
-      connection({ maxRequestFilesBytes: 2 * 1024 * 1024, imageOffloadByteQuantum: 1 }),
+      connection({ maxRequestFilesBytes: 4 * 1024 * 1024, imageOffloadByteQuantum: 1 }),
       'vision',
     ).priceImages(images)
     expect(prices.map(price => price.visualTokens)).toEqual([0, 422, 422])

+ 1 - 1
packages/llm/llm-pi-ai/src/adapter.ts

@@ -373,7 +373,7 @@ export class PiAiAdapter extends LlmAdapter {
           resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref),
           maxRequestImageBytes: profile.maxRequestImageBytes,
           requestImagePolicy: {
-            maxPixels: profile.requestImagePixelBudget,
+            projection: { kind: 'pixel-budget', maxPixels: profile.requestImagePixelBudget },
             maxBytes: profile.requestImageMaxBytes,
           },
         }, onReplayDegrade)

+ 1 - 1
packages/llm/llm-pi-ai/src/context.ts

@@ -272,7 +272,7 @@ async function toPiContextWithImages(
 ): Promise<PiContext> {
   const { attachments, resolveImageAccess, maxRequestImageBytes } = images
   const requestImagePolicy = images.requestImagePolicy ?? {
-    maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
+    projection: { kind: 'pixel-budget', maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET },
     maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
   }
   assertSupportedImageRoles(options.messages)

+ 1 - 1
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -325,7 +325,7 @@ describe('PiAiAdapter provider routing', () => {
 
     expect(result.finish.kind).toBe('error')
     expect(readImageRequest).toHaveBeenCalledWith(ref, {
-      maxPixels: 2048 * 2048,
+      projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 },
       maxBytes: 1024 * 1024,
     }, expect.any(AbortSignal))
     expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH)

+ 1 - 1
packages/llm/llm-pi-ai/tests/convert.spec.ts

@@ -119,7 +119,7 @@ describe('toPiContext', () => {
 
     expect(readImageRequest).toHaveBeenCalledWith(
       attachment,
-      { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 },
+      { projection: { kind: 'pixel-budget' as const, maxPixels: 2048 * 2048 }, maxBytes: 1024 * 1024 },
       undefined,
     )
     expect(context.messages[0]).toEqual({