const GATEWAY_EMBED_URL = 'https://ai-gateway.vercel.sh/v1/embeddings'; const DEFAULT_EMBED_MODEL = process.env.SCAN_EMBED_MODEL || 'cohere/embed-v4.0'; /** Output dimension — must match migrations/1782000000001_add-card-embeddings.js. */ export const EMBED_DIMENSION = Number(process.env.SCAN_EMBED_DIMENSION || 1024); export class EmbedApiError extends Error { constructor(message, { status } = {}) { super(message); this.name = 'EmbedApiError'; this.status = status; } } function parseEmbeddingResponse(data) { const embedding = data?.data?.[0]?.embedding || data?.embeddings?.[0]?.values || data?.embeddings?.[0] || data?.embedding; if (!Array.isArray(embedding) || embedding.length === 0) { throw new EmbedApiError('Embedding API returned no vector'); } return embedding.map((value) => Number(value)); } function isValidEmbedInput(value) { if (typeof value !== 'string' || !value.trim()) { return false; } if (value.startsWith('data:')) { return value.includes(','); } return value.startsWith('http://') || value.startsWith('https://'); } function buildEmbedInput(value) { if (typeof value === 'string') { return value; } if (value?.imageUrl) { return value.imageUrl; } if (value?.imageDataUrl) { return value.imageDataUrl; } throw new EmbedApiError('Invalid embed input'); } function normalizeEmbeddingDimensions(embedding) { if (embedding.length === EMBED_DIMENSION) { return embedding; } if (embedding.length > EMBED_DIMENSION) { // Gateway ignores matryoshka `dimensions` for multimodal — truncate to catalog width. return embedding.slice(0, EMBED_DIMENSION); } throw new EmbedApiError( `Embedding API returned ${embedding.length} dimensions; expected ${EMBED_DIMENSION}` ); } /** * Embed image content via Vercel AI Gateway (server-only). * Accepts a JPEG data URL string, HTTPS image URL string, or { imageUrl } / { imageDataUrl }. */ export async function embedCardImage(input) { const apiKey = process.env.AI_GATEWAY_API_KEY; if (!apiKey) { throw new EmbedApiError('AI_GATEWAY_API_KEY is not configured on the server'); } const payloadInput = buildEmbedInput(input); if (!isValidEmbedInput(payloadInput)) { throw new EmbedApiError('Invalid image data format'); } const response = await fetch(GATEWAY_EMBED_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: DEFAULT_EMBED_MODEL, input: payloadInput, dimensions: EMBED_DIMENSION, }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const message = errorData.error?.message || errorData.message || 'Unknown error'; throw new EmbedApiError(`Embedding API error: ${response.status} - ${message}`, { status: response.status, }); } return normalizeEmbeddingDimensions(parseEmbeddingResponse(await response.json())); } /** Format a float array for pgvector tagged-template queries. */ export function formatEmbeddingForPg(embedding) { return `[${embedding.map((value) => Number(value).toFixed(8)).join(',')}]`; }