freellmapi / server /src /services /media.ts
Nryn215's picture
Deploy: Enable and enforce pure ELO-based Intelligence routing strategy
ed57015
Raw
History Blame Contribute Delete
15.8 kB
// Generative-media routing (image generation + audio/TTS).
//
// Self-contained, exactly like embeddings: media models live in their OWN
// `media_models` table so they can NEVER enter the chat router's candidate pool
// (a chat request can't misroute to an image model) and never pollute the chat
// token budget. Each platform has a small adapter here; routing fails over
// across the providers serving the same modality. The rows are maintained in the
// published catalog and arrive via catalog-sync (premium on the live tier within
// ~12h, free at the monthly promote) — never seeded by migrations.
import { getDb } from '../db/index.js';
import { decrypt } from '../lib/crypto.js';
import { proxyFetch } from '../lib/proxy.js';
/** Platforms with a media adapter below. catalog-sync gates media rows on this
* (decoupled from the chat provider registry — e.g. SiliconFlow is media-only). */
export const MEDIA_PLATFORMS = new Set(['nvidia', 'pollinations', 'cloudflare', 'siliconflow', 'google']);
/** Platforms whose free media path needs no API key (anonymous). */
const KEYLESS_CAPABLE = new Set(['pollinations']);
export type MediaModality = 'image' | 'audio';
export interface MediaModelRow {
id: number;
platform: string;
model_id: string;
display_name: string;
modality: MediaModality;
priority: number;
enabled: number;
quota_label: string;
}
export class MediaError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export interface ImageResult {
platform: string;
modelId: string;
images: Array<{ b64_json?: string; url?: string }>;
}
export interface SpeechResult {
platform: string;
modelId: string;
audio: Buffer;
contentType: string;
}
export interface ImageParams { prompt: string; n?: number; size?: string }
export interface SpeechParams { input: string; voice?: string; format?: string }
// Media generations are slower than chat — a cold FLUX/SDXL run can take 30-60s.
const FETCH_TIMEOUT_MS = 60_000;
export function listMediaModels(modality: MediaModality): MediaModelRow[] {
return getDb()
.prepare('SELECT * FROM media_models WHERE modality = ? AND enabled = 1 ORDER BY priority, id')
.all(modality) as MediaModelRow[];
}
/** All media models (both modalities, including disabled) for the dashboard. */
export function listAllMediaModels(): MediaModelRow[] {
return getDb()
.prepare('SELECT * FROM media_models ORDER BY modality, priority, id')
.all() as MediaModelRow[];
}
function getPlatformKey(platform: string): string | null {
const row = getDb()
.prepare("SELECT encrypted_key, iv, auth_tag FROM api_keys WHERE platform = ? AND enabled = 1 AND status IN ('healthy', 'unknown', 'error') ORDER BY id LIMIT 1")
.get(platform) as { encrypted_key: string; iv: string; auth_tag: string } | undefined;
if (!row) return null;
try {
return decrypt(row.encrypted_key, row.iv, row.auth_tag);
} catch {
return null;
}
}
async function mediaFetch(url: string, platform: string, init: RequestInit): Promise<Response> {
const r = await proxyFetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, platform);
if (!r.ok) {
const body = await r.text().catch(() => '');
throw new MediaError(`${platform} ${r.status}: ${body.slice(0, 200)}`, r.status);
}
return r;
}
function parseSize(size?: string): [number, number] {
if (size && /^\d+x\d+$/.test(size)) {
const [w, h] = size.split('x').map(Number);
return [w, h];
}
return [1024, 1024];
}
function parseCfKey(key: string | null): { accountId: string; token: string } {
if (!key) throw new MediaError('cloudflare key required (account_id:token)', 401);
const sep = key.indexOf(':');
if (sep === -1) throw new MediaError('cloudflare key is not in account_id:token form', 500);
return { accountId: key.slice(0, sep), token: key.slice(sep + 1) };
}
function contentTypeFor(fmt: string): string {
switch (fmt) {
case 'wav': return 'audio/wav';
case 'opus': return 'audio/ogg';
case 'aac': return 'audio/aac';
case 'flac': return 'audio/flac';
case 'pcm': return 'audio/L16';
case 'mp3':
default: return 'audio/mpeg';
}
}
function parseRate(mime?: string): number | undefined {
const m = mime?.match(/rate=(\d+)/);
return m ? Number(m[1]) : undefined;
}
/** Wrap raw 16-bit mono PCM (what Gemini TTS returns) in a WAV header so any
* client can play it without knowing the sample rate out of band. */
function wrapPcmAsWav(pcm: Buffer, sampleRate: number): Buffer {
const numChannels = 1;
const bitsPerSample = 16;
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
const blockAlign = (numChannels * bitsPerSample) / 8;
const header = Buffer.alloc(44);
header.write('RIFF', 0);
header.writeUInt32LE(36 + pcm.length, 4);
header.write('WAVE', 8);
header.write('fmt ', 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20); // PCM
header.writeUInt16LE(numChannels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write('data', 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
async function callImageProvider(
row: MediaModelRow,
key: string | null,
p: ImageParams,
): Promise<Array<{ b64_json?: string; url?: string }>> {
const [w, h] = parseSize(p.size);
switch (row.platform) {
case 'nvidia': {
// NVIDIA NIM image models live at ai.api.nvidia.com/v1/genai/{model};
// response is { artifacts: [{ base64 }] }.
const r = await mediaFetch(`https://ai.api.nvidia.com/v1/genai/${row.model_id}`, 'nvidia', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ prompt: p.prompt, mode: 'base', steps: 4, width: w, height: h }),
});
const j = (await r.json()) as { artifacts?: { base64?: string }[] };
return (j.artifacts ?? []).map(a => ({ b64_json: a.base64 }));
}
case 'pollinations': {
// Keyless GET image endpoint returns raw image bytes.
const url = `https://image.pollinations.ai/prompt/${encodeURIComponent(p.prompt)}?width=${w}&height=${h}&nologo=true&model=${encodeURIComponent(row.model_id)}`;
const r = await mediaFetch(url, 'pollinations', { method: 'GET' });
const buf = Buffer.from(await r.arrayBuffer());
return [{ b64_json: buf.toString('base64') }];
}
case 'cloudflare': {
const { accountId, token } = parseCfKey(key);
const r = await mediaFetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${row.model_id}`, 'cloudflare', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ prompt: p.prompt, width: w, height: h }),
});
// FLUX returns JSON { result: { image: <b64> } }; SDXL returns raw PNG bytes.
const ct = r.headers.get('content-type') ?? '';
if (ct.includes('application/json')) {
const j = (await r.json()) as { result?: { image?: string } };
const b64 = j.result?.image;
if (!b64) throw new MediaError('cloudflare returned no image', 502);
return [{ b64_json: b64 }];
}
const buf = Buffer.from(await r.arrayBuffer());
return [{ b64_json: buf.toString('base64') }];
}
case 'siliconflow': {
const r = await mediaFetch('https://api.siliconflow.com/v1/images/generations', 'siliconflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ model: row.model_id, prompt: p.prompt, image_size: `${w}x${h}` }),
});
const j = (await r.json()) as { images?: { url?: string }[]; data?: { url?: string }[] };
return (j.images ?? j.data ?? []).map(i => ({ url: i.url }));
}
default:
throw new MediaError(`no image adapter for platform '${row.platform}'`, 500);
}
}
async function callSpeechProvider(
row: MediaModelRow,
key: string | null,
p: SpeechParams,
): Promise<{ audio: Buffer; contentType: string }> {
switch (row.platform) {
case 'cloudflare': {
const { accountId, token } = parseCfKey(key);
const r = await mediaFetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${row.model_id}`, 'cloudflare', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ prompt: p.input, lang: p.voice ?? 'en' }),
});
const j = (await r.json()) as { result?: { audio?: string } };
const b64 = j.result?.audio;
if (!b64) throw new MediaError('cloudflare returned no audio', 502);
return { audio: Buffer.from(b64, 'base64'), contentType: 'audio/mpeg' };
}
case 'siliconflow': {
const fmt = p.format ?? 'mp3';
const r = await mediaFetch('https://api.siliconflow.com/v1/audio/speech', 'siliconflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ model: row.model_id, input: p.input, voice: p.voice ?? `${row.model_id}:alex`, response_format: fmt }),
});
return { audio: Buffer.from(await r.arrayBuffer()), contentType: contentTypeFor(fmt) };
}
case 'pollinations': {
// OpenAI-shaped chat-completions with the audio modality returns b64 audio.
// The anonymous tier needs no key; only send one when it's a real sk_ token.
const realKey = key && key.startsWith('sk_') ? key : null;
const r = await mediaFetch('https://gen.pollinations.ai/v1/chat/completions', 'pollinations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(realKey ? { Authorization: `Bearer ${realKey}` } : {}) },
body: JSON.stringify({
model: row.model_id,
modalities: ['text', 'audio'],
audio: { voice: p.voice ?? 'alloy', format: p.format ?? 'mp3' },
messages: [{ role: 'user', content: p.input }],
}),
});
const j = (await r.json()) as { choices?: { message?: { audio?: { data?: string } } }[] };
const b64 = j.choices?.[0]?.message?.audio?.data;
if (!b64) throw new MediaError('pollinations returned no audio', 502);
return { audio: Buffer.from(b64, 'base64'), contentType: contentTypeFor(p.format ?? 'mp3') };
}
case 'google': {
// Gemini TTS via generateContent (AUDIO modality) returns base64 PCM
// (L16, mono, ~24kHz); wrap it in a WAV header so clients can play it.
const r = await mediaFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${row.model_id}:generateContent?key=${encodeURIComponent(key ?? '')}`,
'google',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: p.input }] }],
generationConfig: {
responseModalities: ['AUDIO'],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: p.voice ?? 'Kore' } } },
},
}),
},
);
const j = (await r.json()) as {
candidates?: { content?: { parts?: { inlineData?: { data?: string; mimeType?: string } }[] } }[];
};
const part = j.candidates?.[0]?.content?.parts?.find(pt => pt.inlineData?.data);
const b64 = part?.inlineData?.data;
if (!b64) throw new MediaError('gemini returned no audio', 502);
const rate = parseRate(part?.inlineData?.mimeType) ?? 24000;
return { audio: wrapPcmAsWav(Buffer.from(b64, 'base64'), rate), contentType: 'audio/wav' };
}
default:
throw new MediaError(`no speech adapter for platform '${row.platform}'`, 500);
}
}
/** Map the request's `model` to a candidate chain within one modality:
* 'auto'/empty → every enabled provider for the modality (failover order),
* a provider model id → just that row. */
function resolveMediaChain(model: string | undefined, modality: MediaModality): MediaModelRow[] {
const rows = listMediaModels(modality);
if (rows.length === 0) {
throw new MediaError(`No enabled ${modality} providers configured.`, 503);
}
if (!model || model === 'auto') return rows;
const matches = rows.filter(r => r.model_id === model);
if (matches.length === 0) {
throw new MediaError(`Unknown ${modality} model '${model}'. Use 'auto' or a provider model id.`, 400);
}
return matches;
}
function logMedia(row: MediaModelRow, status: 'success' | 'error', latencyMs: number, error: string | null): void {
try {
getDb()
.prepare(`INSERT INTO requests (platform, model_id, key_id, status, input_tokens, output_tokens, latency_ms, error, request_type)
VALUES (?, ?, NULL, ?, 0, 0, ?, ?, ?)`)
.run(row.platform, row.model_id, status, latencyMs, error, row.modality);
} catch (e) {
console.error('Failed to log media request:', e);
}
}
function chainError(modality: MediaModality, lastError: MediaError | null): MediaError {
return new MediaError(
`All ${modality} providers failed${lastError ? ` (last: ${lastError.message.slice(0, 160)})` : ' (no usable keys)'}.`,
lastError && lastError.status === 429 ? 429 : 502,
);
}
/** Generate image(s), failing over across providers serving the modality. */
export async function runImageGeneration(model: string | undefined, params: ImageParams): Promise<ImageResult> {
const chain = resolveMediaChain(model, 'image');
let lastError: MediaError | null = null;
for (const row of chain) {
const keyless = KEYLESS_CAPABLE.has(row.platform);
const key = keyless ? null : getPlatformKey(row.platform);
if (!keyless && !key) continue; // no usable key for this provider — try the next
const started = Date.now();
try {
const images = await callImageProvider(row, key, params);
if (!images.length || images.every(i => !i.b64_json && !i.url)) {
throw new MediaError('upstream returned no image', 502);
}
logMedia(row, 'success', Date.now() - started, null);
return { platform: row.platform, modelId: row.model_id, images };
} catch (err: any) {
const e = err instanceof MediaError ? err : new MediaError(String(err?.message ?? err), 502);
logMedia(row, 'error', Date.now() - started, e.message.slice(0, 300));
lastError = e;
}
}
throw chainError('image', lastError);
}
/** Synthesize speech, failing over across providers serving the modality. */
export async function runSpeech(model: string | undefined, params: SpeechParams): Promise<SpeechResult> {
const chain = resolveMediaChain(model, 'audio');
let lastError: MediaError | null = null;
for (const row of chain) {
const keyless = KEYLESS_CAPABLE.has(row.platform);
const key = keyless ? null : getPlatformKey(row.platform);
if (!keyless && !key) continue;
const started = Date.now();
try {
const out = await callSpeechProvider(row, key, params);
if (!out.audio.length) throw new MediaError('upstream returned no audio', 502);
logMedia(row, 'success', Date.now() - started, null);
return { platform: row.platform, modelId: row.model_id, audio: out.audio, contentType: out.contentType };
} catch (err: any) {
const e = err instanceof MediaError ? err : new MediaError(String(err?.message ?? err), 502);
logMedia(row, 'error', Date.now() - started, e.message.slice(0, 300));
lastError = e;
}
}
throw chainError('audio', lastError);
}