The image module is not tied to OpenRouter any more

`IMAGE_API_BASE_URL` decides where the image requests go. The default is still
OpenRouter — one key, many models, and the model list in the admin area fills
itself in — but the request body was already written in the OpenAI image shape,
so pointing it at a LiteLLM gateway, a company proxy or a server of your own
works. `GET /images/models` is an OpenRouter route; a base that answers 404 is
treated as "cannot list models", not as an error, and the model id is typed
instead. `IMAGE_API_KEY` is the new name; `OPENROUTER_API_KEY` is still read so
existing instances keep working.

Also: the seeded example recipes now show four different task combinations
rather than four sizes — clean up, poster, cut out to transparent PNG, sticker
with contour, square for social. They are examples to read and replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-15 15:42:12 +00:00
1 parent 0798eb7438
commit eea374d328
6 files changed
+70 -13

No files matched your search

+35 -6
View File
@@ -1,16 +1,36 @@
import { one } from './db';
import { decrypt } from './crypto';
const BASE = 'https://openrouter.ai/api/v1';
/**
* Where the image requests go.
*
* OpenRouter by default, because it is one key for many models. Point
* `IMAGE_API_BASE_URL` somewhere else and Klarbild talks to that instead — any
* endpoint that answers `POST {base}/images` in the OpenAI image shape will do,
* which covers a gateway like LiteLLM, a company proxy, or a local server. The
* request body is written to that shape deliberately; the only OpenRouter
* extras are the `X-Title` header and the `usage.cost` field, and both are
* optional on the way back.
*
* Whatever you point it at, the images go to exactly one place and you chose it.
*/
const BASE = (process.env.IMAGE_API_BASE_URL || 'https://openrouter.ai/api/v1').replace(/\/+$/, '');
/** Key: the DB value (set in the admin area) takes precedence over the environment variable. */
/** True when this instance talks to OpenRouter rather than to something else. */
export const usingOpenRouter = BASE.includes('openrouter.ai');
/**
* Key: the value from the admin area wins, then `IMAGE_API_KEY`, then
* `OPENROUTER_API_KEY` — the last one so that instances set up before the base
* URL became configurable keep working untouched.
*/
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 { /* falls back to ENV */ }
}
return process.env.OPENROUTER_API_KEY || '';
return process.env.IMAGE_API_KEY || process.env.OPENROUTER_API_KEY || '';
}
export class OpenRouterError extends Error {
@@ -49,7 +69,7 @@ export interface GenerateResult {
/** The dedicated image endpoint POST /api/v1/images (not chat/completions). */
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
const key = await resolveKey();
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No OpenRouter key stored');
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'No API key stored for the image service');
const body: Record<string, unknown> = {
model: opts.model,
@@ -69,6 +89,7 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
// OpenRouter shows this on the activity page. Other endpoints ignore it.
'X-Title': 'Klarbild',
},
body: JSON.stringify(body),
@@ -76,7 +97,7 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
if (!res.ok) {
throw new OpenRouterError(res.status, friendlyFor(res.status),
`OpenRouter ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300));
`Image API ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300));
}
const json: any = await res.json();
@@ -90,12 +111,20 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
};
}
/** The available image models (for the model list in the admin area). */
/**
* The available image models, for the picker in the admin area.
*
* `GET {base}/images/models` is an OpenRouter route. An endpoint that does not
* have it answers 404, and that is not an error worth showing: it only means
* the list cannot be filled in automatically, and the model id has to be typed.
* Anything else — a wrong key, a dead host — is still reported.
*/
export async function listImageModels(): Promise<any[]> {
const key = await resolveKey();
const res = await fetch(`${BASE}/images/models`, {
headers: { Authorization: `Bearer ${key}` },
});
if (res.status === 404 && !usingOpenRouter) return [];
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
return (await res.json())?.data ?? [];
}
+8 -4
View File
@@ -81,15 +81,19 @@ async function seedRecipes(): Promise<void> {
const by = admin?.id ?? null;
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
query(
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm, output_ext)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300,
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null]);
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null,
(extra.output_ext as string) ?? null]);
// Examples, meant to be read and then replaced. Each one shows a different
// combination of the four tasks rather than a different size.
await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
await R('TV frame (16:9)', ['clean', 'format'], 'tv169', 'landscape');
await R('Cut out, transparent PNG', ['clean', 'cutout'], 'keep', 'landscape', { output_ext: 'png' });
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 });
await R('Square 30 × 30 for social', ['clean', 'format'], '30x30', 'landscape');
}
/* --------------------------------------------------------- print presets */