deckhearth/lib/card-embed.js

115 lines
3.2 KiB
JavaScript
Raw Normal View History

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(',')}]`;
}