import { one } from './db'; import { decrypt } from './crypto'; const BASE = 'https://openrouter.ai/api/v1'; /** Schlüssel: DB-Wert (Admin) hat Vorrang vor der Umgebungsvariable. */ export async function resolveKey(): Promise { const row = await one<{ openrouter_key_enc: string | null }>( 'SELECT openrouter_key_enc FROM settings WHERE id=1'); if (row?.openrouter_key_enc) { try { return decrypt(row.openrouter_key_enc); } catch { /* fällt auf ENV zurück */ } } return process.env.OPENROUTER_API_KEY || ''; } export class OpenRouterError extends Error { constructor(public status: number, public friendly: string, msg?: string) { super(msg || friendly); } } function friendlyFor(status: number): string { if (status === 402) return 'Guthaben erschöpft — gerade nicht möglich.'; if (status === 429) return 'Dienst überlastet — wird automatisch erneut versucht.'; if (status >= 500) return 'Dienst vorübergehend gestört.'; if (status === 401 || status === 403) return 'Zugang zum Bilddienst nicht möglich.'; return 'Bildbearbeitung fehlgeschlagen.'; } export interface GenerateOpts { model: string; prompt: string; inputUrl?: string; // vorsignierte URL des Originals aspectRatio?: string; // "16:9", "3:4" … resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln background?: 'transparent' | 'opaque'; outputFormat?: 'png' | 'jpeg' | 'webp'; seed?: number; } export interface GenerateResult { buffer: Buffer; mediaType: string; cost: number; model: string; } /** Dedizierter Bild-Endpunkt POST /api/v1/images (nicht chat/completions). */ export async function generateImage(opts: GenerateOpts): Promise { const key = await resolveKey(); if (!key) throw new OpenRouterError(401, friendlyFor(401), 'Kein OpenRouter-Key hinterlegt'); const body: Record = { model: opts.model, prompt: opts.prompt, n: 1, output_format: opts.outputFormat || 'png', }; if (opts.inputUrl) body.input_references = [{ url: opts.inputUrl }]; if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio; if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides if (opts.background) body.background = opts.background; if (typeof opts.seed === 'number') body.seed = opts.seed; const res = await fetch(`${BASE}/images`, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'X-Title': 'Klarbild', }, body: JSON.stringify(body), }); if (!res.ok) { throw new OpenRouterError(res.status, friendlyFor(res.status), `OpenRouter ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300)); } const json: any = await res.json(); const b64 = json?.data?.[0]?.b64_json; if (!b64) throw new OpenRouterError(502, 'Kein Bild erhalten.', 'Antwort ohne b64_json'); return { buffer: Buffer.from(b64, 'base64'), mediaType: json.data[0].media_type || 'image/png', cost: Number(json?.usage?.cost ?? 0), model: opts.model, }; } /** Verfügbare Bildmodelle (für die Admin-Modellliste). */ export async function listImageModels(): Promise { const key = await resolveKey(); const res = await fetch(`${BASE}/images/models`, { headers: { Authorization: `Bearer ${key}` }, }); if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status)); return (await res.json())?.data ?? []; }