mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
aa3cba3334
commit
92b01565a3
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
87
tests/imageResizer.test.ts
Normal file
87
tests/imageResizer.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user