From 28af0bb54885b406ee210007c54c0f28fe8f3f7a Mon Sep 17 00:00:00 2001 From: Federico Cachero Date: Thu, 7 May 2026 16:14:44 -0300 Subject: [PATCH] fix(adapter/voyage): translate request/response between OpenAI-compat SDK and Voyage's actual contract The @ai-sdk/openai-compatible package treats Voyage as if it were OpenAI-shaped, but Voyage's /v1/embeddings endpoint diverges in three places that combine into a hard-blocking incompatibility: OUTBOUND request: - 'encoding_format=float' (SDK default) is rejected; Voyage only accepts 'base64' - 'dimensions' parameter (OpenAI name) is rejected; Voyage uses 'output_dimension' INBOUND response: - With encoding_format=base64, 'embedding' is returned as a base64 string, but the SDK's Zod schema (openaiTextEmbeddingResponseSchema) expects an 'array of number'. The schema fails with 'Invalid JSON response' even though the JSON is well-formed. - 'usage' lacks 'prompt_tokens'; the schema requires it when usage is present. Without this patch, ALL embedding requests to Voyage fail. Reproducible by running 'gbrain put < text' with embedding_model=voyage:voyage-* and any current voyage model (voyage-3-large, voyage-3, voyage-4-large). Solution: pass a custom 'fetch' to createOpenAICompatible only when recipe.id === 'voyage'. The fetch wrapper: 1. Forces encoding_format='base64' on outbound (Voyage's only accepted value) 2. Translates dimensions -> output_dimension on outbound 3. Drops Content-Length so the runtime recomputes from the mutated body 4. Decodes base64 embeddings to Float32 arrays on inbound (so the Zod schema sees what it expects) 5. Synthesizes prompt_tokens from total_tokens when missing This is a minimal, targeted fix. It only activates for Voyage and falls through cleanly for all other providers. No public API changes. --- src/core/ai/gateway.ts | 97 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) 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); }