diff --git a/src/constants/apiLimits.ts b/src/constants/apiLimits.ts index 9746b030..609df1f7 100644 --- a/src/constants/apiLimits.ts +++ b/src/constants/apiLimits.ts @@ -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 diff --git a/src/server/__tests__/conversation-attachments.test.ts b/src/server/__tests__/conversation-attachments.test.ts index 5662046c..fb89d82a 100644 --- a/src/server/__tests__/conversation-attachments.test.ts +++ b/src/server/__tests__/conversation-attachments.test.ts @@ -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 } diff --git a/tests/imageResizer.test.ts b/tests/imageResizer.test.ts new file mode 100644 index 00000000..62b010c8 --- /dev/null +++ b/tests/imageResizer.test.ts @@ -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') + }) +})