diff --git a/src/services/openrouter.js b/src/services/openrouter.js new file mode 100644 index 0000000..333f55b --- /dev/null +++ b/src/services/openrouter.js @@ -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 } }] + } + }; +}