fix: stop rejecting common tall screenshots in Read

Common desktop screenshots can be taller than the old 2000px local resize threshold while still fitting provider request limits. Align the local image dimension cap with the Claude Vision API rejection threshold so Read and desktop attachments pass through already-small screenshots, while oversized or unprocessable images still use the existing fallback paths.

Constraint: Claude Vision API allows image dimensions up to 8000px and still has a 5MB base64 image payload limit
Rejected: Keep 2000px as a client-side hard limit | it rejects common screenshots that providers can handle
Confidence: high
Scope-risk: narrow
Related: #615
Related: #677
Tested: bun test tests/imageResizer.test.ts
Tested: bun test tests/imageResizer.test.ts tests/mediaRecoveryAndEstimation.test.ts src/services/api/errors.test.ts src/server/__tests__/proxy-transform.test.ts
Tested: bun test src/server/__tests__/conversation-attachments.test.ts tests/imageResizer.test.ts
Tested: bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 16:41:55 +08:00
parent aa3cba3334
commit 92b01565a3
3 changed files with 103 additions and 16 deletions

View File

@ -4,8 +4,8 @@
* These constants define server-side limits enforced by the Anthropic API.
* Keep this file dependency-free to prevent circular imports.
*
* Last verified: 2025-12-22
* Source: api/api/schemas/messages/blocks/ and api/api/config.py
* Last verified: 2026-06-01
* Source: Claude Vision API docs and api/api/config.py
*
* Future: See issue #13240 for dynamic limits fetching from server.
*/
@ -29,18 +29,18 @@ export const API_IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024 // 5 MB
export const IMAGE_TARGET_RAW_SIZE = (API_IMAGE_MAX_BASE64_SIZE * 3) / 4 // 3.75 MB
/**
* Client-side maximum dimensions for image resizing.
* API-enforced maximum dimensions for image inputs.
*
* Note: The API internally resizes images larger than 1568px (source:
* encoding/full_encoding.py), but this is handled server-side and doesn't
* cause errors. These client-side limits (2000px) are slightly larger to
* preserve quality when beneficial.
* Note: The API internally resizes images whose long edge is larger than
* 1568px, but that is handled server-side and doesn't cause errors. Keep this
* limit aligned with the API's rejection threshold so common tall screenshots
* are not locally resized or rejected just because they exceed 2000px.
*
* The API_IMAGE_MAX_BASE64_SIZE (5MB) is the actual hard limit that causes
* API errors if exceeded.
* API errors for ordinary screenshots before dimensions usually matter.
*/
export const IMAGE_MAX_WIDTH = 2000
export const IMAGE_MAX_HEIGHT = 2000
export const IMAGE_MAX_WIDTH = 8000
export const IMAGE_MAX_HEIGHT = 8000
// =============================================================================
// PDF LIMITS

View File

@ -59,7 +59,7 @@ afterEach(async () => {
})
describe('ConversationService attachment materialization', () => {
test('inlines normalized image data attachments as SDK image blocks', async () => {
test('inlines image data attachments without resizing when already within API limits', async () => {
const svc = new ConversationService()
const sent: unknown[] = []
const sessionId = 'session-image-normalize'
@ -96,13 +96,13 @@ describe('ConversationService attachment materialization', () => {
expect(textBlocks.some((block) => block.text?.includes('@"'))).toBe(false)
expect(imageBlocks).toHaveLength(1)
expect(imageBlocks[0]?.source?.media_type).toBe('image/png')
expect(imageBlocks[0]?.source?.data).toBe(Buffer.from('resized-image').toString('base64'))
expect(imageBlocks[0]?.source?.data).toBe(original.toString('base64'))
const metadataText = textBlocks.find((block) => block.text?.startsWith('[Image:'))?.text
const uploadPath = metadataText?.match(/source: ([^,\]]+)/)?.[1]
expect(uploadPath).toBeTruthy()
expect(uploadPath?.endsWith('.png')).toBe(true)
expect(await fs.readFile(uploadPath!)).toEqual(Buffer.from('resized-image'))
expect(await fs.readFile(uploadPath!)).toEqual(original)
})
test('falls back to an upload path when image normalization cannot produce a block', async () => {
@ -176,7 +176,7 @@ describe('ConversationService attachment materialization', () => {
expect(textBlocks.some((block) => block.text?.includes(`source: ${imagePath}`))).toBe(true)
expect(imageBlocks).toHaveLength(1)
expect(imageBlocks[0]?.source?.media_type).toBe('image/png')
expect(imageBlocks[0]?.source?.data).toBe(Buffer.from('resized-image').toString('base64'))
expect(imageBlocks[0]?.source?.data).toBe(original.toString('base64'))
})
})
@ -186,7 +186,7 @@ function createOversizedPngHeader(): Buffer {
buffer[1] = 0x50
buffer[2] = 0x4e
buffer[3] = 0x47
buffer.writeUInt32BE(3000, 16)
buffer.writeUInt32BE(4000, 20)
buffer.writeUInt32BE(9000, 16)
buffer.writeUInt32BE(9000, 20)
return buffer
}

View File

@ -0,0 +1,87 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
type ProcessorMode = 'metadata' | 'throw'
let processorMode: ProcessorMode = 'metadata'
let resizeCalls: Array<{ width: number; height: number }> = []
const originalPngDimensions = { width: 1394, height: 4404, format: 'png' }
mock.module('../src/tools/FileReadTool/imageProcessor.js', () => ({
getImageProcessor: async () => {
if (processorMode === 'throw') {
throw new Error('image processor unavailable')
}
return (_input: Buffer) => {
const instance = {
metadata: async () => originalPngDimensions,
resize: (width: number, height: number) => {
resizeCalls.push({ width, height })
return instance
},
jpeg: () => instance,
png: () => instance,
webp: () => instance,
toBuffer: async () => Buffer.from('resized-image'),
}
return instance
}
},
}))
const { maybeResizeAndDownsampleImageBuffer } = await import(
'../src/utils/imageResizer.js'
)
function makePngHeader(width: number, height: number): Buffer {
const buffer = Buffer.alloc(32)
buffer[0] = 0x89
buffer[1] = 0x50
buffer[2] = 0x4e
buffer[3] = 0x47
buffer.writeUInt32BE(width, 16)
buffer.writeUInt32BE(height, 20)
return buffer
}
beforeEach(() => {
processorMode = 'metadata'
resizeCalls = []
})
describe('maybeResizeAndDownsampleImageBuffer', () => {
test('passes through a tall screenshot when bytes are already within API limits', async () => {
const imageBuffer = Buffer.alloc(1024, 1)
const result = await maybeResizeAndDownsampleImageBuffer(
imageBuffer,
imageBuffer.length,
'png',
)
expect(result.buffer).toBe(imageBuffer)
expect(result.mediaType).toBe('png')
expect(resizeCalls).toEqual([])
expect(result.dimensions).toEqual({
originalWidth: 1394,
originalHeight: 4404,
displayWidth: 1394,
displayHeight: 4404,
})
})
test('falls back to the original tall screenshot if local image processing is unavailable', async () => {
processorMode = 'throw'
const imageBuffer = makePngHeader(1394, 4404)
const result = await maybeResizeAndDownsampleImageBuffer(
imageBuffer,
imageBuffer.length,
'png',
)
expect(result.buffer).toBe(imageBuffer)
expect(result.mediaType).toBe('png')
})
})