@@ -16,6 +16,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/works
1616import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1717import { isHeifContainer , transcodeHeicToJpeg } from '@/lib/uploads/server/heic'
1818import {
19+ formatFileSize ,
1920 isImageFileType ,
2021 MODEL_SUPPORTED_IMAGE_MIME_TYPES ,
2122 resolveEffectiveMimeType ,
@@ -39,6 +40,15 @@ const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
3940// produce huge extracted text; reject up front to avoid wasting a
4041// download + parse only to blow past the tool-result budget.
4142const MAX_PARSEABLE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
43+ /** Source-image byte ceiling. Sits above the 20MB HEIC transcode ceiling, so a HEIF is bounded by the tighter of the two. */
44+ const MAX_IMAGE_SOURCE_BYTES = 25 * 1024 * 1024
45+ /**
46+ * Pixel ceiling on the decoded raster. libvips materialises the whole raster, and
47+ * an allocation this large OOM-kills the process rather than throwing, so it has to
48+ * be refused up front. 100MP caps the decode near 400MB while clearing every real
49+ * camera — a 48MP iPhone still is 8064x6048.
50+ */
51+ const MAX_IMAGE_INPUT_PIXELS = 100_000_000
4252const MAX_IMAGE_DIMENSION = 1568
4353const IMAGE_RESIZE_DIMENSIONS = [ 1568 , 1280 , 1024 , 768 ]
4454const IMAGE_QUALITY_STEPS = [ 85 , 70 , 55 , 40 ]
@@ -85,6 +95,20 @@ interface PreparedVisionImage {
8595 resized : boolean
8696}
8797
98+ /**
99+ * Shown to the model verbatim in the read placeholder, so each value names the one
100+ * thing that actually failed rather than a disjunction of everything that might have.
101+ */
102+ const VisionImageRejection = {
103+ Undecodable : 'It could not be decoded.' ,
104+ TooManyPixels : 'It is too large to decode safely.' ,
105+ TooLargeAfterResize : 'It still exceeded the 5MB vision limit after resizing.' ,
106+ } as const
107+
108+ type VisionImageResult =
109+ | { ok : true ; image : PreparedVisionImage }
110+ | { ok : false ; reason : ( typeof VisionImageRejection ) [ keyof typeof VisionImageRejection ] }
111+
88112/**
89113 * Prepare an image for vision models: detect media type, optionally
90114 * resize/compress with sharp, and return the prepared buffer.
@@ -98,7 +122,7 @@ interface PreparedVisionImage {
98122async function prepareImageForVision (
99123 sourceBuffer : Buffer ,
100124 claimedType : string
101- ) : Promise < PreparedVisionImage | null > {
125+ ) : Promise < VisionImageResult > {
102126 return getVfsTracer ( ) . startActiveSpan (
103127 TraceSpan . CopilotVfsPrepareImage ,
104128 {
@@ -107,7 +131,7 @@ async function prepareImageForVision(
107131 [ TraceAttr . CopilotVfsInputMediaTypeClaimed ] : claimedType ,
108132 } ,
109133 } ,
110- async ( span ) => {
134+ async ( span ) : Promise < VisionImageResult > => {
111135 try {
112136 const detectedType = detectImageMime ( sourceBuffer , claimedType )
113137 span . setAttribute ( TraceAttr . CopilotVfsInputMediaTypeDetected , detectedType )
@@ -128,11 +152,17 @@ async function prepareImageForVision(
128152 TraceAttr . CopilotVfsOutcome ,
129153 fitsWithoutSharp ? 'passthrough_no_sharp' : 'rejected_no_sharp'
130154 )
131- return fitsWithoutSharp
132- ? { buffer : sourceBuffer , mediaType : detectedType , resized : false }
133- : null
155+ if ( ! fitsWithoutSharp ) return { ok : false , reason : VisionImageRejection . Undecodable }
156+ return {
157+ ok : true ,
158+ image : { buffer : sourceBuffer , mediaType : detectedType , resized : false } ,
159+ }
134160 }
135161
162+ // Left unguarded deliberately: metadata() only parses the header, so it
163+ // allocates nothing proportional to the declared dimensions, and enabling the
164+ // guard here would route an oversized image into the passthrough branch below
165+ // — which hands the bytes to the model instead of refusing them.
136166 const readMetadata = ( candidate : Buffer ) =>
137167 sharpModule ( candidate , { limitInputPixels : false } )
138168 . metadata ( )
@@ -171,7 +201,8 @@ async function prepareImageForVision(
171201 TraceAttr . CopilotVfsOutcome ,
172202 passthroughViable ? 'passthrough_no_metadata' : 'rejected_no_metadata'
173203 )
174- return passthroughViable ? { buffer, mediaType, resized : false } : null
204+ if ( ! passthroughViable ) return { ok : false , reason : VisionImageRejection . Undecodable }
205+ return { ok : true , image : { buffer, mediaType, resized : false } }
175206 }
176207
177208 const width = metadata . width ?? 0
@@ -181,6 +212,20 @@ async function prepareImageForVision(
181212 [ TraceAttr . CopilotVfsInputHeight ] : height ,
182213 } )
183214
215+ const pixels = width * height
216+ if ( pixels > MAX_IMAGE_INPUT_PIXELS ) {
217+ logger . warn ( 'Rejected image above the decode pixel budget' , {
218+ mediaType,
219+ width,
220+ height,
221+ pixels,
222+ budget : MAX_IMAGE_INPUT_PIXELS ,
223+ bytes : buffer . length ,
224+ } )
225+ span . setAttribute ( TraceAttr . CopilotVfsOutcome , 'rejected_pixel_budget' )
226+ return { ok : false , reason : VisionImageRejection . TooManyPixels }
227+ }
228+
184229 // A format the model cannot decode has to be re-encoded even when it is
185230 // already small enough — the ladder below emits JPEG or WebP, both of
186231 // which it accepts.
@@ -196,7 +241,7 @@ async function prepareImageForVision(
196241 [ TraceAttr . CopilotVfsOutputBytes ] : buffer . length ,
197242 [ TraceAttr . CopilotVfsOutputMediaType ] : mediaType ,
198243 } )
199- return { buffer, mediaType, resized : false }
244+ return { ok : true , image : { buffer, mediaType, resized : false } }
200245 }
201246
202247 const hasAlpha = Boolean (
@@ -208,16 +253,19 @@ async function prepareImageForVision(
208253 span . setAttribute ( TraceAttr . CopilotVfsHasAlpha , hasAlpha )
209254
210255 let attempts = 0
256+ let decodeFailed = false
211257 for ( const dimension of IMAGE_RESIZE_DIMENSIONS ) {
212258 for ( const quality of IMAGE_QUALITY_STEPS ) {
213259 attempts += 1
214260 try {
215- const pipeline = sharpModule ( buffer , { limitInputPixels : false } ) . rotate ( ) . resize ( {
216- width : dimension ,
217- height : dimension ,
218- fit : 'inside' ,
219- withoutEnlargement : true ,
220- } )
261+ const pipeline = sharpModule ( buffer , { limitInputPixels : MAX_IMAGE_INPUT_PIXELS } )
262+ . rotate ( )
263+ . resize ( {
264+ width : dimension ,
265+ height : dimension ,
266+ fit : 'inside' ,
267+ withoutEnlargement : true ,
268+ } )
221269
222270 const transformed = hasAlpha
223271 ? {
@@ -262,12 +310,20 @@ async function prepareImageForVision(
262310 [ TraceAttr . CopilotVfsOutcome ] : CopilotVfsOutcome . Resized ,
263311 } )
264312 return {
265- buffer : transformed . buffer ,
266- mediaType : transformed . mediaType ,
267- resized : true ,
313+ ok : true ,
314+ image : {
315+ buffer : transformed . buffer ,
316+ mediaType : transformed . mediaType ,
317+ resized : true ,
318+ } ,
268319 }
269320 }
270321 } catch ( err ) {
322+ // Move to the next dimension rather than the next quality: the quality
323+ // rungs re-decode the identical source and only change the encoder, so
324+ // repeating a failed decode there is pure waste. A smaller dimension is
325+ // worth trying — libvips shrinks JPEG on load, so it decodes less.
326+ decodeFailed = true
271327 logger . warn ( 'Failed image resize attempt for VFS read' , {
272328 mediaType,
273329 dimension,
@@ -279,6 +335,7 @@ async function prepareImageForVision(
279335 [ TraceAttr . CopilotVfsResizeQuality ] : quality ,
280336 [ TraceAttr . ErrorMessage ] : toError ( err ) . message . slice ( 0 , 500 ) ,
281337 } )
338+ break
282339 }
283340 }
284341 }
@@ -288,7 +345,12 @@ async function prepareImageForVision(
288345 [ TraceAttr . CopilotVfsResizeAttempts ] : attempts ,
289346 [ TraceAttr . CopilotVfsOutcome ] : CopilotVfsOutcome . RejectedTooLargeAfterResize ,
290347 } )
291- return null
348+ return {
349+ ok : false ,
350+ reason : decodeFailed
351+ ? VisionImageRejection . Undecodable
352+ : VisionImageRejection . TooLargeAfterResize ,
353+ }
292354 } catch ( err ) {
293355 recordSpanError ( span , err )
294356 throw err
@@ -342,33 +404,45 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
342404 // image down the binary path where the model never sees it.
343405 if ( isImageFileType ( resolveEffectiveMimeType ( record . type , record . name ) ) ) {
344406 span . setAttribute ( TraceAttr . CopilotVfsReadPath , CopilotVfsReadPath . Image )
345- const originalBuffer = await fetchWorkspaceFileBuffer ( record )
407+ // `record.size` is client-declared, so it only buys the friendly placeholder;
408+ // the download's own `maxBytes` is what actually bounds the bytes read.
409+ if ( record . size > MAX_IMAGE_SOURCE_BYTES ) {
410+ span . setAttribute ( TraceAttr . CopilotVfsReadOutcome , CopilotVfsReadOutcome . ImageTooLarge )
411+ return {
412+ content : `[Image too large to read inline: ${ record . name } (${ record . size } bytes, limit ${ MAX_IMAGE_SOURCE_BYTES } )]` ,
413+ totalLines : 1 ,
414+ }
415+ }
416+ const originalBuffer = await fetchWorkspaceFileBuffer ( record , {
417+ maxBytes : MAX_IMAGE_SOURCE_BYTES ,
418+ } )
346419 const prepared = await prepareImageForVision ( originalBuffer , record . type )
347- if ( ! prepared ) {
420+ if ( ! prepared . ok ) {
348421 span . setAttribute ( TraceAttr . CopilotVfsReadOutcome , CopilotVfsReadOutcome . ImageTooLarge )
349422 return {
350- content : `[Image unavailable: ${ record . name } (${ ( record . size / 1024 / 1024 ) . toFixed ( 1 ) } MB ). It could not be decoded, or still exceeded the 5MB vision limit after resizing. ]` ,
423+ content : `[Image unavailable: ${ record . name } (${ formatFileSize ( record . size ) } ). ${ prepared . reason } ]` ,
351424 totalLines : 1 ,
352425 }
353426 }
354- const sizeKb = ( prepared . buffer . length / 1024 ) . toFixed ( 1 )
355- const resizeNote = prepared . resized ? ', resized for vision' : ''
427+ const { buffer, mediaType, resized } = prepared . image
428+ const sizeKb = ( buffer . length / 1024 ) . toFixed ( 1 )
429+ const resizeNote = resized ? ', resized for vision' : ''
356430 span . setAttributes ( {
357431 [ TraceAttr . CopilotVfsReadOutcome ] : CopilotVfsReadOutcome . ImagePrepared ,
358- [ TraceAttr . CopilotVfsReadOutputBytes ] : prepared . buffer . length ,
359- [ TraceAttr . CopilotVfsReadOutputMediaType ] : prepared . mediaType ,
360- [ TraceAttr . CopilotVfsReadImageResized ] : prepared . resized ,
432+ [ TraceAttr . CopilotVfsReadOutputBytes ] : buffer . length ,
433+ [ TraceAttr . CopilotVfsReadOutputMediaType ] : mediaType ,
434+ [ TraceAttr . CopilotVfsReadImageResized ] : resized ,
361435 } )
362436 return {
363- content : `Image: ${ record . name } (${ sizeKb } KB, ${ prepared . mediaType } ${ resizeNote } )` ,
437+ content : `Image: ${ record . name } (${ sizeKb } KB, ${ mediaType } ${ resizeNote } )` ,
364438 totalLines : 1 ,
365439 attachment : {
366440 type : 'image' ,
367441 name : record . name ,
368442 source : {
369443 type : 'base64' as const ,
370- media_type : prepared . mediaType ,
371- data : prepared . buffer . toString ( 'base64' ) ,
444+ media_type : mediaType ,
445+ data : buffer . toString ( 'base64' ) ,
372446 } ,
373447 } ,
374448 }
@@ -384,7 +458,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
384458 }
385459 }
386460
387- const buffer = await fetchWorkspaceFileBuffer ( record )
461+ const buffer = await fetchWorkspaceFileBuffer ( record , { maxBytes : MAX_TEXT_READ_BYTES } )
388462 const content = buffer . toString ( 'utf-8' )
389463 const lines = content . split ( '\n' ) . length
390464 span . setAttributes ( {
@@ -408,7 +482,9 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
408482 totalLines : 1 ,
409483 }
410484 }
411- const buffer = await fetchWorkspaceFileBuffer ( record )
485+ const buffer = await fetchWorkspaceFileBuffer ( record , {
486+ maxBytes : MAX_PARSEABLE_READ_BYTES ,
487+ } )
412488 try {
413489 const { parseBuffer } = await import ( '@/lib/file-parsers' )
414490 const result = await parseBuffer ( buffer , ext )
0 commit comments