Refactor: Extract OpenRouter client

This commit is contained in:
2026-03-20 22:57:42 -07:00
parent b076bd66e6
commit a04fda0b35

135
src/services/openrouter.js Normal file
View File

@@ -0,0 +1,135 @@
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
function safeJsonStringify(value) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function extractApiErrorDetails(payload) {
if (!payload) return '';
const err = payload.error || payload;
const parts = [];
if (typeof err === 'string') parts.push(err);
if (err?.message && typeof err.message === 'string') parts.push(err.message);
if (err?.code && typeof err.code === 'string') parts.push(`code: ${err.code}`);
if (err?.type && typeof err.type === 'string') parts.push(`type: ${err.type}`);
if (err?.metadata) {
if (err.metadata.provider_name) parts.push(`provider: ${err.metadata.provider_name}`);
if (typeof err.metadata.raw === 'string') parts.push(err.metadata.raw);
if (err.metadata.reason) parts.push(err.metadata.reason);
}
if (err?.details) {
if (typeof err.details === 'string') parts.push(err.details);
else parts.push(safeJsonStringify(err.details));
}
const deduped = [];
const seen = new Set();
for (const p of parts.map((x) => (x || '').trim())) {
if (!p || seen.has(p)) continue;
seen.add(p);
deduped.push(p);
}
return deduped.join(' | ');
}
async function parseErrorResponse(res) {
const raw = await res.text();
let json = null;
if (raw) {
try {
json = JSON.parse(raw);
} catch {}
}
let detail = extractApiErrorDetails(json);
if (!detail && raw) detail = raw.trim();
if (!detail) detail = 'Unknown API error';
const statusPart = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
return `${statusPart}${detail}`;
}
function normalizeImageUrl(value) {
if (typeof value !== 'string') return '';
if (
value.startsWith('data:') ||
value.startsWith('http://') ||
value.startsWith('https://')
) return value;
return `data:image/png;base64,${value}`;
}
function extractImage(choice) {
let src = null;
const images = choice?.message?.images;
if (Array.isArray(images) && images.length) {
const img = images[0];
src = img?.image_url?.url || img?.url || img;
}
if (!src && Array.isArray(choice?.message?.content)) {
for (const part of choice.message.content) {
if (part?.type === 'image_url' && part?.image_url?.url) {
src = part.image_url.url;
break;
}
}
}
return normalizeImageUrl(src);
}
export async function generateFrame({ model, messages, imageSize, aspectRatio, apiKey }) {
if (!apiKey) throw new Error('Missing API key.');
const isGemini = model.startsWith('google/');
const body = {
model,
messages,
modalities: ['image'],
image_config: {
aspect_ratio: aspectRatio,
image_size: imageSize
}
};
if (!isGemini && imageSize === '0.5K') body.image_config.image_size = '1K';
const res = await fetch(API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://vibegif.lol',
'X-Title': 'vibegif.lol'
},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(await parseErrorResponse(res));
const data = await res.json();
const choice = data?.choices?.[0];
if (!choice) throw new Error('No response from model');
const imageUrl = extractImage(choice);
if (!imageUrl) throw new Error('No image in response. Model may have refused or returned text only.');
return {
base64: imageUrl,
assistantMsg: {
role: 'assistant',
content: [{ type: 'image_url', image_url: { url: imageUrl } }]
}
};
}