All files / web/src/lib/image-providers gemini.ts

0% Statements 0/147
0% Branches 0/1
0% Functions 0/1
0% Lines 0/147

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148                                                                                                                                                                                                                                                                                                       
import type { ImageOptions, ImageProvider } from './types'

const MAX_RETRIES = 3
const RETRYABLE_STATUSES = new Set([500, 502, 503])

function parseGeminiError(status: number, body: string): string {
  try {
    const json = JSON.parse(body)
    const message = json?.error?.message
    if (typeof message === 'string') {
      const clean = message.split('\n')[0].trim()
      const retryInfo = json?.error?.details?.find((d: Record<string, unknown>) => d?.retryDelay)
      const retrySuffix = retryInfo ? ` (retry after ${retryInfo.retryDelay})` : ''
      return `Gemini ${status}: ${clean}${retrySuffix}`
    }
  } catch {
    // Not JSON
  }

  const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body
  return `Gemini API error ${status}: ${truncated}`
}

async function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

type GeminiAspectRatio = '1:1' | '3:4' | '4:3' | '9:16' | '16:9'
type GeminiImageSize = '256' | '512' | '1K'

/** Pick the closest Gemini-supported aspect ratio and image size for a given pixel size. */
function mapSizeToGeminiParams(size?: ImageOptions['size']): {
  aspectRatio: GeminiAspectRatio
  imageSize: GeminiImageSize
} {
  if (!size) return { aspectRatio: '1:1', imageSize: '1K' }

  const ratio = size.width / size.height
  const aspects: Array<{ label: GeminiAspectRatio; ratio: number }> = [
    { label: '9:16', ratio: 9 / 16 },
    { label: '3:4', ratio: 3 / 4 },
    { label: '1:1', ratio: 1 },
    { label: '4:3', ratio: 4 / 3 },
    { label: '16:9', ratio: 16 / 9 },
  ]
  let closest = aspects[0]
  for (const a of aspects) {
    if (Math.abs(a.ratio - ratio) < Math.abs(closest.ratio - ratio)) closest = a
  }

  const maxDim = Math.max(size.width, size.height)
  const imageSize: GeminiImageSize = maxDim <= 256 ? '256' : maxDim <= 512 ? '512' : '1K'

  return { aspectRatio: closest.label, imageSize }
}

export const geminiProvider: ImageProvider = {
  meta: {
    id: 'gemini',
    name: 'Google Gemini',
    envKey: 'GEMINI_API_KEY',
    models: [
      { id: 'gemini-3-pro-image-preview', name: 'Nano Banana Pro' },
      { id: 'gemini-2.5-flash-image', name: 'Gemini 2.5 Flash Image' },
    ],
  },

  isAvailable(): boolean {
    return !!process.env.GEMINI_API_KEY
  },

  async generate({ model, prompt, options, referenceImage }): Promise<{ imageBuffer: Buffer }> {
    const apiKey = process.env.GEMINI_API_KEY
    if (!apiKey) {
      throw new Error('No API key configured for Gemini. Set GEMINI_API_KEY in your environment.')
    }

    const { aspectRatio, imageSize } = mapSizeToGeminiParams(options?.size)

    const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`

    // Build content parts — include reference image before text if provided
    const parts: Array<Record<string, unknown>> = []
    if (referenceImage) {
      parts.push({
        inlineData: {
          mimeType: 'image/png',
          data: referenceImage.toString('base64'),
        },
      })
    }
    parts.push({ text: prompt })

    const requestBody = JSON.stringify({
      contents: [{ parts }],
      generationConfig: {
        responseModalities: ['TEXT', 'IMAGE'],
        imageConfig: {
          aspectRatio,
          imageSize,
        },
      },
    })

    let lastError: Error | null = null

    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      if (attempt > 0) {
        // Exponential backoff: 2s, 4s, 8s + jitter
        const delay = (1 << attempt) * 1000 + Math.random() * 1000
        await sleep(delay)
      }

      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: requestBody,
      })

      if (!response.ok) {
        const body = await response.text().catch(() => '')
        lastError = new Error(parseGeminiError(response.status, body))

        if (RETRYABLE_STATUSES.has(response.status) && attempt < MAX_RETRIES) {
          continue
        }
        throw lastError
      }

      const data = await response.json()
      const candidates = data.candidates
      if (!candidates || candidates.length === 0) {
        throw new Error('Gemini returned no candidates')
      }

      for (const part of candidates[0].content.parts) {
        if (part.inlineData && part.inlineData.mimeType?.startsWith('image/')) {
          return { imageBuffer: Buffer.from(part.inlineData.data, 'base64') }
        }
      }

      throw new Error('Gemini response contained no image data')
    }

    throw lastError ?? new Error('Gemini generation failed after retries')
  },
}