diff --git a/src/lib/server/ai/gemini-client.ts b/src/lib/server/ai/gemini-client.ts new file mode 100644 index 0000000..2f45c4d --- /dev/null +++ b/src/lib/server/ai/gemini-client.ts @@ -0,0 +1,150 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { env } from '$env/dynamic/private'; +import { + RECIPE_EXTRACTION_SYSTEM_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 } }]; + 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 { + 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)); + } + + 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) { + if (retryErr instanceof GeminiError) 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', String(retryErr)); + } + } +} diff --git a/tests/unit/gemini-client.test.ts b/tests/unit/gemini-client.test.ts new file mode 100644 index 0000000..93795a6 --- /dev/null +++ b/tests/unit/gemini-client.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGenerateContent = vi.fn(); + +vi.mock('@google/generative-ai', async () => { + const actual = await vi.importActual( + '@google/generative-ai' + ); + return { + ...actual, + GoogleGenerativeAI: vi.fn().mockImplementation(() => ({ + getGenerativeModel: () => ({ generateContent: mockGenerateContent }) + })) + }; +}); + +// $env/dynamic/private is mocked via SvelteKit's own mock; here we lean on +// process.env (the client falls back to it). +import { + extractRecipeFromImage, + GeminiError +} from '../../src/lib/server/ai/gemini-client'; + +beforeEach(() => { + mockGenerateContent.mockReset(); + process.env.GEMINI_API_KEY = 'test-key'; + process.env.GEMINI_MODEL = 'gemini-2.5-flash'; + process.env.GEMINI_TIMEOUT_MS = '5000'; +}); + +const validResponse = { + title: 'Apfelkuchen', + servings_default: 8, + servings_unit: 'Stück', + prep_time_min: 20, + cook_time_min: 45, + total_time_min: null, + ingredients: [{ quantity: 500, unit: 'g', name: 'Mehl', note: null }], + steps: [{ text: 'Ofen auf 180 °C vorheizen.' }] +}; + +describe('extractRecipeFromImage', () => { + it('happy path: returns parsed recipe data', async () => { + mockGenerateContent.mockResolvedValueOnce({ + response: { text: () => JSON.stringify(validResponse) } + }); + const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); + expect(result.title).toBe('Apfelkuchen'); + expect(result.ingredients).toHaveLength(1); + }); + + it('retries once on schema-invalid JSON, then succeeds', async () => { + mockGenerateContent + .mockResolvedValueOnce({ response: { text: () => '{"title": "no arrays"}' } }) + .mockResolvedValueOnce({ + response: { text: () => JSON.stringify(validResponse) } + }); + const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); + expect(result.title).toBe('Apfelkuchen'); + expect(mockGenerateContent).toHaveBeenCalledTimes(2); + }); + + it('throws AI_FAILED after schema-invalid + retry also invalid', async () => { + mockGenerateContent + .mockResolvedValueOnce({ response: { text: () => '{}' } }) + .mockResolvedValueOnce({ + response: { text: () => '{"title": "still bad"}' } + }); + await expect( + extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg') + ).rejects.toMatchObject({ code: 'AI_FAILED' }); + }); + + it('throws AI_RATE_LIMITED without retry on 429', async () => { + const err = new Error('429 Too Many Requests') as Error & { status?: number }; + err.status = 429; + mockGenerateContent.mockRejectedValueOnce(err); + await expect( + extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg') + ).rejects.toMatchObject({ code: 'AI_RATE_LIMITED' }); + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + }); + + it('retries once on 5xx, then succeeds', async () => { + const err = new Error('500 Server Error') as Error & { status?: number }; + err.status = 500; + mockGenerateContent + .mockRejectedValueOnce(err) + .mockResolvedValueOnce({ + response: { text: () => JSON.stringify(validResponse) } + }); + const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); + expect(result.title).toBe('Apfelkuchen'); + expect(mockGenerateContent).toHaveBeenCalledTimes(2); + }); + + it('throws AI_FAILED after 5xx + retry also fails', async () => { + const err = new Error('500') as Error & { status?: number }; + err.status = 500; + mockGenerateContent.mockRejectedValue(err); + await expect( + extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg') + ).rejects.toMatchObject({ code: 'AI_FAILED' }); + expect(mockGenerateContent).toHaveBeenCalledTimes(2); + }); + + it('throws AI_TIMEOUT when generateContent never resolves', async () => { + process.env.GEMINI_TIMEOUT_MS = '50'; + mockGenerateContent.mockImplementation( + () => new Promise(() => {}) // never resolves + ); + await expect( + extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg') + ).rejects.toMatchObject({ code: 'AI_TIMEOUT' }); + }); + + it('throws AI_NOT_CONFIGURED when GEMINI_API_KEY is empty', async () => { + process.env.GEMINI_API_KEY = ''; + await expect( + extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg') + ).rejects.toMatchObject({ code: 'AI_NOT_CONFIGURED' }); + }); + + it('GeminiError has a code property', () => { + const e = new GeminiError('AI_FAILED', 'x'); + expect(e.code).toBe('AI_FAILED'); + }); +});