Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/core/ai/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,96 @@ export function isAvailable(touchpoint: TouchpointKind): boolean {

// ---- Embedding ----

/**
* Voyage AI compatibility shim. Voyage's `/v1/embeddings` endpoint is OpenAI-shaped
* but diverges on two parameters:
* - `encoding_format` only accepts `'base64'` (the AI SDK sends `'float'` by default,
* which makes Voyage respond with HTTP 400). Force `'base64'` so the SDK round-trip
* parses correctly.
* - OpenAI's `dimensions` parameter is rejected; Voyage uses `output_dimension`.
* Translate the field name when the caller explicitly requested a dimension.
*
* The mutated body is what gets sent on the wire; the AI SDK still receives a
* base64-encoded response and decodes it as expected.
*/
const voyageCompatFetch: typeof fetch = async (input, init) => {
// OUTBOUND: rewrite request body for Voyage's actual API contract.
if (init?.body && typeof init.body === 'string') {
try {
const parsed = JSON.parse(init.body);
if (parsed && typeof parsed === 'object') {
let mutated = false;
// Voyage rejects 'float' (the SDK default). Force the value Voyage accepts.
if (parsed.encoding_format !== 'base64') {
parsed.encoding_format = 'base64';
mutated = true;
}
// Translate OpenAI's `dimensions` to Voyage's `output_dimension`.
if ('dimensions' in parsed) {
const dims = parsed.dimensions;
delete parsed.dimensions;
if (typeof dims === 'number') parsed.output_dimension = dims;
mutated = true;
}
if (mutated) {
const newBody = JSON.stringify(parsed);
// Drop Content-Length so fetch recomputes from the new body.
const headers = new Headers(init.headers ?? {});
headers.delete('content-length');
init = { ...init, body: newBody, headers };
}
}
} catch {
// Body wasn't JSON — pass through untouched.
}
}

const resp = await fetch(input, init);
if (!resp.ok) return resp;
const ct = resp.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('application/json')) return resp;

// INBOUND: rewrite response so the AI SDK's Zod schema validates.
// Voyage diverges from OpenAI in two places that break the parser:
// - `embedding` is a base64 string (SDK schema expects `number[]`)
// - `usage` lacks `prompt_tokens` (SDK schema requires it when usage present)
try {
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.data)) {
for (const item of json.data) {
if (item && typeof item.embedding === 'string') {
// Voyage returns Float32 little-endian base64.
const bytes = Buffer.from(item.embedding, 'base64');
const floats = new Float32Array(
bytes.buffer,
bytes.byteOffset,
Math.floor(bytes.byteLength / 4),
);
item.embedding = Array.from(floats);
modified = true;
}
}
}
if (json.usage && typeof json.usage === 'object' && json.usage.prompt_tokens === undefined) {
json.usage.prompt_tokens = typeof json.usage.total_tokens === 'number'
? json.usage.total_tokens
: 0;
modified = true;
}
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch {
// If parsing/transformation fails, fall back to the original response.
return resp;
}
};

async function resolveEmbeddingProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
const { parsed, recipe } = resolveRecipe(modelStr);
assertTouchpoint(recipe, 'embedding', parsed.modelId);
Expand Down Expand Up @@ -197,6 +287,13 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
name: recipe.id,
baseURL: baseUrl,
apiKey: apiKey ?? 'unauthenticated',
// Voyage AI's `/v1/embeddings` endpoint is "OpenAI-compatible" only in URL
// shape; it rejects `encoding_format=float` (only `base64` is accepted) and
// ignores OpenAI's `dimensions` parameter (Voyage uses `output_dimension`).
// The default openai-compatible client sends `encoding_format=float`, which
// makes Voyage respond with HTTP 400 "Bad Request". Strip those fields
// before forwarding when targeting Voyage.
fetch: recipe.id === 'voyage' ? voyageCompatFetch : undefined,
});
return client.textEmbeddingModel(modelId);
}
Expand Down