diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 03bdc37b4..423d51961 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -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); @@ -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); }