feat(ai): Gemini-Client mit Timeout, 1x-Retry und Fehler-Codes
This commit is contained in:
150
src/lib/server/ai/gemini-client.ts
Normal file
150
src/lib/server/ai/gemini-client.ts
Normal file
@@ -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<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
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<ExtractionResponse> {
|
||||
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<ExtractionResponse> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
128
tests/unit/gemini-client.test.ts
Normal file
128
tests/unit/gemini-client.test.ts
Normal file
@@ -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<typeof import('@google/generative-ai')>(
|
||||
'@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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user