105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
import { API_IMAGE_MAX_BASE64_SIZE } from '../constants/apiLimits.js'
|
|
import { logEvent } from '../services/analytics/index.js'
|
|
import { formatFileSize } from './format.js'
|
|
|
|
/**
|
|
* Information about an oversized image.
|
|
*/
|
|
export type OversizedImage = {
|
|
index: number
|
|
size: number
|
|
}
|
|
|
|
/**
|
|
* Error thrown when one or more images exceed the API size limit.
|
|
*/
|
|
export class ImageSizeError extends Error {
|
|
constructor(oversizedImages: OversizedImage[], maxSize: number) {
|
|
let message: string
|
|
const firstImage = oversizedImages[0]
|
|
if (oversizedImages.length === 1 && firstImage) {
|
|
message =
|
|
`Image base64 size (${formatFileSize(firstImage.size)}) exceeds API limit (${formatFileSize(maxSize)}). ` +
|
|
`Please resize the image before sending.`
|
|
} else {
|
|
message =
|
|
`${oversizedImages.length} images exceed the API limit (${formatFileSize(maxSize)}): ` +
|
|
oversizedImages
|
|
.map(img => `Image ${img.index}: ${formatFileSize(img.size)}`)
|
|
.join(', ') +
|
|
`. Please resize these images before sending.`
|
|
}
|
|
super(message)
|
|
this.name = 'ImageSizeError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if a block is a base64 image block
|
|
*/
|
|
function isBase64ImageBlock(
|
|
block: unknown,
|
|
): block is { type: 'image'; source: { type: 'base64'; data: string } } {
|
|
if (typeof block !== 'object' || block === null) return false
|
|
const b = block as Record<string, unknown>
|
|
if (b.type !== 'image') return false
|
|
if (typeof b.source !== 'object' || b.source === null) return false
|
|
const source = b.source as Record<string, unknown>
|
|
return source.type === 'base64' && typeof source.data === 'string'
|
|
}
|
|
|
|
/**
|
|
* Validates that all images in messages are within the API size limit.
|
|
* This is a safety net at the API boundary to catch any oversized images
|
|
* that may have slipped through upstream processing.
|
|
*
|
|
* Note: The API's 5MB limit applies to the base64-encoded string length,
|
|
* not the decoded raw bytes.
|
|
*
|
|
* Works with both UserMessage/AssistantMessage types (which have { type, message })
|
|
* and raw MessageParam types (which have { role, content }).
|
|
*
|
|
* @param messages - Array of messages to validate
|
|
* @throws ImageSizeError if any image exceeds the API limit
|
|
*/
|
|
export function validateImagesForAPI(messages: unknown[]): void {
|
|
const oversizedImages: OversizedImage[] = []
|
|
let imageIndex = 0
|
|
|
|
for (const msg of messages) {
|
|
if (typeof msg !== 'object' || msg === null) continue
|
|
|
|
const m = msg as Record<string, unknown>
|
|
|
|
// Handle wrapped message format { type: 'user', message: { role, content } }
|
|
// Only check user messages
|
|
if (m.type !== 'user') continue
|
|
|
|
const innerMessage = m.message as Record<string, unknown> | undefined
|
|
if (!innerMessage) continue
|
|
|
|
const content = innerMessage.content
|
|
if (typeof content === 'string' || !Array.isArray(content)) continue
|
|
|
|
for (const block of content) {
|
|
if (isBase64ImageBlock(block)) {
|
|
imageIndex++
|
|
// Check the base64-encoded string length directly (not decoded bytes)
|
|
// The API limit applies to the base64 payload size
|
|
const base64Size = block.source.data.length
|
|
if (base64Size > API_IMAGE_MAX_BASE64_SIZE) {
|
|
logEvent('tengu_image_api_validation_failed', {
|
|
base64_size_bytes: base64Size,
|
|
max_bytes: API_IMAGE_MAX_BASE64_SIZE,
|
|
})
|
|
oversizedImages.push({ index: imageIndex, size: base64Size })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (oversizedImages.length > 0) {
|
|
throw new ImageSizeError(oversizedImages, API_IMAGE_MAX_BASE64_SIZE)
|
|
}
|
|
}
|