import { GoogleGenerativeAI } from '@google/generative-ai'; import { env } from '$env/dynamic/private'; import { RECIPE_EXTRACTION_SYSTEM_PROMPT, RECIPE_EXTRACTION_USER_PROMPT, GEMINI_RESPONSE_SCHEMA, extractionResponseSchema, type ExtractionResponse } from './recipe-extraction-prompt'; export type GeminiErrorCode = | 'AI_NOT_CONFIGURED' | 'AI_RATE_LIMITED' | 'AI_TIMEOUT' | 'AI_FAILED'; export class GeminiError extends Error { constructor( public readonly code: GeminiErrorCode, message: string ) { super(message); this.name = 'GeminiError'; } } function getStatus(err: unknown): number | undefined { if (err && typeof err === 'object' && 'status' in err) { const s = (err as { status?: unknown }).status; if (typeof s === 'number') return s; } return undefined; } function getCfg(): { apiKey: string; model: string; timeoutMs: number } { const apiKey = env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY ?? ''; const model = env.GEMINI_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; const rawTimeout = env.GEMINI_TIMEOUT_MS ?? process.env.GEMINI_TIMEOUT_MS ?? '20000'; const timeoutMs = Number(rawTimeout) || 20000; return { apiKey, model, timeoutMs }; } async function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new GeminiError('AI_TIMEOUT', `Gemini timeout after ${ms} ms`)), ms ); promise.then( (v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); } ); }); } async function callGemini( imageBuffer: Buffer, mimeType: string, appendUserNote?: string ): Promise { const { apiKey, model: modelId, timeoutMs } = getCfg(); if (!apiKey) { throw new GeminiError('AI_NOT_CONFIGURED', 'GEMINI_API_KEY is not set'); } const client = new GoogleGenerativeAI(apiKey); const model = client.getGenerativeModel({ model: modelId, systemInstruction: RECIPE_EXTRACTION_SYSTEM_PROMPT, generationConfig: { temperature: 0.1, responseMimeType: 'application/json', // eslint-disable-next-line @typescript-eslint/no-explicit-any responseSchema: GEMINI_RESPONSE_SCHEMA as any } }); const parts: Array< { inlineData: { data: string; mimeType: string } } | { text: string } > = [ { inlineData: { data: imageBuffer.toString('base64'), mimeType } }, { text: RECIPE_EXTRACTION_USER_PROMPT } ]; if (appendUserNote) parts.push({ text: appendUserNote }); const result = await withTimeout( model.generateContent({ contents: [{ role: 'user', parts }] }), timeoutMs ); const text = result.response.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { throw new GeminiError('AI_FAILED', 'Gemini returned non-JSON output'); } const validated = extractionResponseSchema.safeParse(parsed); if (!validated.success) { throw new GeminiError( 'AI_FAILED', `Schema validation failed: ${validated.error.message}` ); } return validated.data; } // Public entry: one retry on recoverable failures (5xx or schema-invalid), // no retry on 429, AI_TIMEOUT, or config errors. export async function extractRecipeFromImage( imageBuffer: Buffer, mimeType: string ): Promise { let firstMsg: string | null = null; try { return await callGemini(imageBuffer, mimeType); } catch (e) { if (e instanceof GeminiError && e.code === 'AI_NOT_CONFIGURED') throw e; if (e instanceof GeminiError && e.code === 'AI_TIMEOUT') throw e; const status = getStatus(e); if (status === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit'); const recoverable = (e instanceof GeminiError && e.code === 'AI_FAILED') || (status !== undefined && status >= 500); if (!recoverable) { throw e instanceof GeminiError ? e : new GeminiError('AI_FAILED', String(e)); } firstMsg = e instanceof Error ? e.message : String(e); console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`); await new Promise((r) => setTimeout(r, 500)); try { return await callGemini( imageBuffer, mimeType, 'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.' ); } catch (retryErr) { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); if (retryErr instanceof GeminiError) { if (retryErr.code === 'AI_FAILED') { throw new GeminiError( 'AI_FAILED', `retry failed: ${retryMsg} (first: ${firstMsg})` ); } throw retryErr; } const retryStatus = getStatus(retryErr); if (retryStatus === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry'); throw new GeminiError( 'AI_FAILED', `retry failed: ${retryMsg} (first: ${firstMsg})` ); } } }