100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
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<string> {
|
|
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<GenerateResult> {
|
|
const key = await resolveKey();
|
|
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'Kein OpenRouter-Key hinterlegt');
|
|
|
|
const body: Record<string, unknown> = {
|
|
model: opts.model,
|
|
prompt: opts.prompt,
|
|
n: 1,
|
|
output_format: opts.outputFormat || 'png',
|
|
};
|
|
if (opts.inputUrl) body.input_references = [{ type: 'image_url', image_url: { 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<any[]> {
|
|
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 ?? [];
|
|
}
|