feat(ai): Gemini-Client mit Timeout, 1x-Retry und Fehler-Codes

This commit is contained in:
hsiegeln
2026-04-21 10:40:58 +02:00
parent d479fd61d8
commit 904edcb3ff
2 changed files with 278 additions and 0 deletions

View 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));
}
}
}