imageValidation.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import { API_IMAGE_MAX_BASE64_SIZE } from '../constants/apiLimits.js'
  2. import { logEvent } from '../services/analytics/index.js'
  3. import { formatFileSize } from './format.js'
  4. /**
  5. * Information about an oversized image.
  6. */
  7. export type OversizedImage = {
  8. index: number
  9. size: number
  10. }
  11. /**
  12. * Error thrown when one or more images exceed the API size limit.
  13. */
  14. export class ImageSizeError extends Error {
  15. constructor(oversizedImages: OversizedImage[], maxSize: number) {
  16. let message: string
  17. const firstImage = oversizedImages[0]
  18. if (oversizedImages.length === 1 && firstImage) {
  19. message =
  20. `Image base64 size (${formatFileSize(firstImage.size)}) exceeds API limit (${formatFileSize(maxSize)}). ` +
  21. `Please resize the image before sending.`
  22. } else {
  23. message =
  24. `${oversizedImages.length} images exceed the API limit (${formatFileSize(maxSize)}): ` +
  25. oversizedImages
  26. .map(img => `Image ${img.index}: ${formatFileSize(img.size)}`)
  27. .join(', ') +
  28. `. Please resize these images before sending.`
  29. }
  30. super(message)
  31. this.name = 'ImageSizeError'
  32. }
  33. }
  34. /**
  35. * Type guard to check if a block is a base64 image block
  36. */
  37. function isBase64ImageBlock(
  38. block: unknown,
  39. ): block is { type: 'image'; source: { type: 'base64'; data: string } } {
  40. if (typeof block !== 'object' || block === null) return false
  41. const b = block as Record<string, unknown>
  42. if (b.type !== 'image') return false
  43. if (typeof b.source !== 'object' || b.source === null) return false
  44. const source = b.source as Record<string, unknown>
  45. return source.type === 'base64' && typeof source.data === 'string'
  46. }
  47. /**
  48. * Validates that all images in messages are within the API size limit.
  49. * This is a safety net at the API boundary to catch any oversized images
  50. * that may have slipped through upstream processing.
  51. *
  52. * Note: The API's 5MB limit applies to the base64-encoded string length,
  53. * not the decoded raw bytes.
  54. *
  55. * Works with both UserMessage/AssistantMessage types (which have { type, message })
  56. * and raw MessageParam types (which have { role, content }).
  57. *
  58. * @param messages - Array of messages to validate
  59. * @throws ImageSizeError if any image exceeds the API limit
  60. */
  61. export function validateImagesForAPI(messages: unknown[]): void {
  62. const oversizedImages: OversizedImage[] = []
  63. let imageIndex = 0
  64. for (const msg of messages) {
  65. if (typeof msg !== 'object' || msg === null) continue
  66. const m = msg as Record<string, unknown>
  67. // Handle wrapped message format { type: 'user', message: { role, content } }
  68. // Only check user messages
  69. if (m.type !== 'user') continue
  70. const innerMessage = m.message as Record<string, unknown> | undefined
  71. if (!innerMessage) continue
  72. const content = innerMessage.content
  73. if (typeof content === 'string' || !Array.isArray(content)) continue
  74. for (const block of content) {
  75. if (isBase64ImageBlock(block)) {
  76. imageIndex++
  77. // Check the base64-encoded string length directly (not decoded bytes)
  78. // The API limit applies to the base64 payload size
  79. const base64Size = block.source.data.length
  80. if (base64Size > API_IMAGE_MAX_BASE64_SIZE) {
  81. logEvent('tengu_image_api_validation_failed', {
  82. base64_size_bytes: base64Size,
  83. max_bytes: API_IMAGE_MAX_BASE64_SIZE,
  84. })
  85. oversizedImages.push({ index: imageIndex, size: base64Size })
  86. }
  87. }
  88. }
  89. }
  90. if (oversizedImages.length > 0) {
  91. throw new ImageSizeError(oversizedImages, API_IMAGE_MAX_BASE64_SIZE)
  92. }
  93. }