diff --git a/src/core/ai/dims.ts b/src/core/ai/dims.ts index b88a4e175..75bf1a0ac 100644 --- a/src/core/ai/dims.ts +++ b/src/core/ai/dims.ts @@ -90,6 +90,30 @@ export function isValidOpenAITextEmbedding3Dim(modelId: string, dims: number): b return Number.isInteger(dims) && dims >= 1 && dims <= max; } +// Perplexity hosted embeddings (#1046): Matryoshka-style flexible dims, +// any integer from 128 up to the model's native size. `dimensions` is the +// native wire field (no translation needed); output encoding divergence +// (base64 int8) is handled by perplexityCompatFetch in gateway.ts. +const PERPLEXITY_EMBEDDING_MAX_DIMS: Record = { + 'pplx-embed-v1-0.6b': 1024, + 'pplx-embed-v1-4b': 2560, +}; +export const PERPLEXITY_MIN_DIMS = 128; + +export function isPerplexityEmbeddingModel(modelId: string): boolean { + return modelId in PERPLEXITY_EMBEDDING_MAX_DIMS; +} + +export function maxPerplexityEmbeddingDim(modelId: string): number | undefined { + return PERPLEXITY_EMBEDDING_MAX_DIMS[modelId]; +} + +export function isValidPerplexityDim(modelId: string, dims: number): boolean { + const max = PERPLEXITY_EMBEDDING_MAX_DIMS[modelId]; + if (max === undefined) return false; + return Number.isInteger(dims) && dims >= PERPLEXITY_MIN_DIMS && dims <= max; +} + // NVIDIA NIM hosted embedding models use asymmetric input_type values. Most // emit fixed natural dimensions, but llama-nemotron-embed-1b-v2 accepts // Matryoshka-style dimension overrides (e.g. matching an existing 1280d @@ -226,6 +250,23 @@ export function dimsProviderOptions( }, }; } + // Perplexity pplx-embed-v1-* — flexible dims via the native + // `dimensions` field. Fail-loud when the configured dim is outside + // the model's range (same rationale as the Voyage/ZE guards: the + // upstream HTTP 400 misroutes as a transient network error). + // Symmetric retrieval — inputType is never emitted. + if (isPerplexityEmbeddingModel(modelId)) { + if (!isValidPerplexityDim(modelId, dims)) { + const max = maxPerplexityEmbeddingDim(modelId)!; + throw new AIConfigError( + `Perplexity model "${modelId}" supports embedding_dimensions in ` + + `${PERPLEXITY_MIN_DIMS}..${max}, got ${dims}.`, + `Set \`embedding_dimensions\` to a value between ${PERPLEXITY_MIN_DIMS} and ${max} ` + + `in your gbrain config.`, + ); + } + return { openaiCompatible: { dimensions: dims } }; + } // NVIDIA NIM hosted embeddings are OpenAI-compatible but require // asymmetric input_type. Use passage for indexing/document-side vectors // and query for search-side vectors. Only llama-nemotron-embed-1b-v2 diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index bcf51aa54..7b814a54a 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -267,6 +267,18 @@ export class ZeroEntropyResponseTooLargeError extends Error { } } +/** Perplexity twin of the Voyage/ZE OOM caps (#1046). Int8 components are + * 1 byte each, so a real response (512 texts × 2560 dims) is ~1.3 MB — + * anything near this cap is unambiguously not legitimate. */ +const MAX_PERPLEXITY_RESPONSE_BYTES = 256 * 1024 * 1024; + +export class PerplexityResponseTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = 'PerplexityResponseTooLargeError'; + } +} + // ---- Unified auth resolution (D12=A) ---- // // Pre-v0.32, openai-compatible auth was duplicated across instantiateEmbedding, @@ -1298,6 +1310,103 @@ const openAICompatAsymmetricFetch = (async (input: RequestInfo | URL, init?: Req return fetch(typeof input === 'string' ? input : input.toString(), baseInit); }) as unknown as typeof fetch; +/** + * Perplexity compatibility shim (#1046). Perplexity's `/v1/embeddings` + * endpoint is OpenAI-shaped but diverges on two points that break the AI + * SDK's openai-compatible adapter: + * - `encoding_format` only accepts 'base64_int8' (default) or + * 'base64_binary'; the SDK sends 'float', which Perplexity rejects. + * Force 'base64_int8' on the wire. + * - The response `embedding` is a base64 string encoding SIGNED INT8 + * components (natively quantized output). The SDK schema expects + * `number[]` — decode Int8Array → number[] here. Cosine similarity is + * scale-invariant, so the raw int8 components rank correctly. + * `dimensions` is Perplexity's native field name — no translation needed + * (dims.ts emits it directly). Layer 1/Layer 2 OOM caps mirror the Voyage + * pattern. + * + * Exported for tests (behavioral coverage of the int8 decode); not part of + * the public gateway API. + */ +export const perplexityCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + // OUTBOUND: force the encoding Perplexity actually accepts. + if (init?.body && typeof init.body === 'string') { + try { + const parsed = JSON.parse(init.body); + if (parsed && typeof parsed === 'object' && parsed.encoding_format !== 'base64_int8') { + parsed.encoding_format = 'base64_int8'; + // Drop Content-Length so fetch recomputes from the new body. + const headers = new Headers(init.headers ?? {}); + headers.delete('content-length'); + init = { ...init, body: JSON.stringify(parsed), headers }; + } + } catch { + // Body wasn't JSON — pass through untouched. + } + } + + const resp = await fetch(input as any, init); + if (!resp.ok) return resp; + const ct = resp.headers.get('content-type') ?? ''; + if (!ct.toLowerCase().includes('application/json')) return resp; + + // Layer 1: Content-Length pre-check BEFORE the body is parsed. + const contentLengthHeader = resp.headers.get('content-length'); + if (contentLengthHeader) { + const len = parseInt(contentLengthHeader, 10); + if (Number.isFinite(len) && len > MAX_PERPLEXITY_RESPONSE_BYTES) { + throw new PerplexityResponseTooLargeError( + `Perplexity response Content-Length=${len} exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes — ` + + `likely compromised endpoint or misconfiguration`, + ); + } + } + + // INBOUND: decode base64 int8 embeddings to number[] so the SDK's Zod + // schema validates. + 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') { + // Layer 2: per-embedding cap for chunked responses that skipped + // Layer 1. base64 → bytes is the canonical 0.75 ratio. + const estDecoded = Math.ceil(item.embedding.length * 0.75); + if (estDecoded > MAX_PERPLEXITY_RESPONSE_BYTES) { + throw new PerplexityResponseTooLargeError( + `Perplexity embedding base64 exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes ` + + `(estimated ${estDecoded} bytes from ${item.embedding.length} base64 chars)`, + ); + } + // base64_int8: one signed int8 per component. + const bytes = Buffer.from(item.embedding, 'base64'); + item.embedding = Array.from(new Int8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)); + 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 (err) { + // OOM-cap throws MUST propagate; anything else falls back to the + // original response (same contract as voyageCompatFetch). + if (err instanceof PerplexityResponseTooLargeError) throw err; + return resp; + } +}) as unknown as typeof fetch; + async function resolveEmbeddingProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> { const { parsed, recipe } = resolveRecipe(modelStr); assertTouchpoint(recipe, 'embedding', parsed.modelId, getExtendedModelsForProvider(parsed.providerId)); @@ -1366,6 +1475,8 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon ? zeroEntropyCompatFetch : recipe.id === 'nvidia' ? nvidiaCompatFetch + : recipe.id === 'perplexity' + ? perplexityCompatFetch : openAICompatAsymmetricFetch); const client = createOpenAICompatible({ name: recipe.id, diff --git a/src/core/ai/recipes/index.ts b/src/core/ai/recipes/index.ts index eb751ec61..e9f99f97c 100644 --- a/src/core/ai/recipes/index.ts +++ b/src/core/ai/recipes/index.ts @@ -26,6 +26,7 @@ import { llamaServerReranker } from './llama-server-reranker.ts'; import { moonshot } from './moonshot.ts'; import { mistral } from './mistral.ts'; import { nvidia } from './nvidia.ts'; +import { perplexity } from './perplexity.ts'; const ALL: Recipe[] = [ openai, @@ -48,6 +49,7 @@ const ALL: Recipe[] = [ moonshot, mistral, nvidia, + perplexity, ]; /** Map from `provider:id` key to recipe. */ diff --git a/src/core/ai/recipes/perplexity.ts b/src/core/ai/recipes/perplexity.ts new file mode 100644 index 000000000..cf7d0c415 --- /dev/null +++ b/src/core/ai/recipes/perplexity.ts @@ -0,0 +1,54 @@ +import type { Recipe } from '../types.ts'; + +/** + * Perplexity's hosted embeddings API (#1046). OpenAI-shaped at + * `POST {base}/embeddings` but diverges on the wire: + * - `encoding_format` only accepts 'base64_int8' (default) or + * 'base64_binary' — the AI SDK's 'float' default is rejected. + * - The response `embedding` is a base64 string encoding SIGNED INT8 + * components (natively quantized output), not a float array. + * Both divergences are handled by perplexityCompatFetch in gateway.ts + * (force 'base64_int8' outbound; decode Int8Array → number[] inbound). + * Cosine similarity is scale-invariant, so the raw int8 components store + * and rank correctly as floats. + * + * Models (per docs.perplexity.ai/api-reference/embeddings-post, 2026-07): + * - pplx-embed-v1-0.6b: dims 128..1024 (default 1024) + * - pplx-embed-v1-4b: dims 128..2560 (default 2560) + * The flexible-dim range validation lives in src/core/ai/dims.ts + * (PERPLEXITY_EMBEDDING_MAX_DIMS). default_dims is pinned at 1024 so both + * models work out of the box on a plain vector(N) column; users who want + * the 4b model's full 2560 width set `embedding_dimensions: 2560` and the + * existing halfvec path (dims > 2000) covers storage + ANN. + * + * Auth is PERPLEXITY_API_KEY only — deliberately NO OPENAI_API_KEY + * fallback (a Perplexity brain must never silently bill/route through + * OpenAI). If your key lives in PPLX_API_KEY, re-export it. + */ +export const perplexity: Recipe = { + id: 'perplexity', + name: 'Perplexity', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://api.perplexity.ai/v1', + auth_env: { + required: ['PERPLEXITY_API_KEY'], + setup_url: 'https://www.perplexity.ai/settings/api', + }, + touchpoints: { + embedding: { + models: ['pplx-embed-v1-0.6b', 'pplx-embed-v1-4b'], + default_dims: 1024, + cost_per_1m_tokens_usd: 0.03, // pplx-embed-v1-4b; 0.6b is $0.004/M + price_last_verified: '2026-07-21', + // Perplexity enforces 120K combined tokens (and 512 texts) per + // request. Same pre-split posture as Voyage: assume a dense + // tokenizer (1 char ≈ 1 token) at 0.5 utilization; the gateway's + // recursive halving is the runtime safety net. + max_batch_tokens: 120_000, + chars_per_token: 1, + safety_factor: 0.5, + }, + }, + setup_hint: 'Get an API key at https://www.perplexity.ai/settings/api, then `export PERPLEXITY_API_KEY=...` (re-export PPLX_API_KEY if that is where your key lives).', +}; diff --git a/src/core/embedding-dim-check.ts b/src/core/embedding-dim-check.ts index f4f1e7ee8..bdeee0129 100644 --- a/src/core/embedding-dim-check.ts +++ b/src/core/embedding-dim-check.ts @@ -32,6 +32,10 @@ import { nvidiaEmbeddingDim, nvidiaEmbeddingDimOptions, supportsNvidiaEmbeddingDimension, + isPerplexityEmbeddingModel, + isValidPerplexityDim, + maxPerplexityEmbeddingDim, + PERPLEXITY_MIN_DIMS, } from './ai/dims.ts'; /** @@ -462,6 +466,15 @@ function isCustomDimValidForProvider( `(allowed: ${ZEROENTROPY_VALID_DIMS.join(', ')}).`, }; } + if (recipe.id === 'perplexity' && isPerplexityEmbeddingModel(modelId)) { + if (isValidPerplexityDim(modelId, requestedDims)) return { valid: true, error: '' }; + return { + valid: false, + error: + `Perplexity ${modelId} accepts dimensions ${PERPLEXITY_MIN_DIMS}..${maxPerplexityEmbeddingDim(modelId)}, ` + + `got ${requestedDims}.`, + }; + } if (recipe.id === 'openai' && isOpenAITextEmbedding3Model(modelId)) { if (isValidOpenAITextEmbedding3Dim(modelId, requestedDims)) return { valid: true, error: '' }; const maxDim = maxOpenAITextEmbedding3Dim(modelId); diff --git a/src/core/embedding-pricing.ts b/src/core/embedding-pricing.ts index e2b3450fb..c248abd65 100644 --- a/src/core/embedding-pricing.ts +++ b/src/core/embedding-pricing.ts @@ -44,6 +44,9 @@ export const EMBEDDING_PRICING: Record = { // Mistral (https://mistral.ai/pricing/api/, verified 2026-07-19) 'mistral:mistral-embed': { pricePerMTok: 0.10 }, 'mistral:mistral-embed-2312': { pricePerMTok: 0.10 }, + // Perplexity (https://docs.perplexity.ai/getting-started/pricing, verified 2026-07-21) + 'perplexity:pplx-embed-v1-0.6b': { pricePerMTok: 0.004 }, + 'perplexity:pplx-embed-v1-4b': { pricePerMTok: 0.03 }, }; export type PriceLookupResult = diff --git a/test/ai/recipe-perplexity.test.ts b/test/ai/recipe-perplexity.test.ts new file mode 100644 index 000000000..0b60ec335 --- /dev/null +++ b/test/ai/recipe-perplexity.test.ts @@ -0,0 +1,142 @@ +/** + * #1046 — Perplexity hosted embeddings (pplx-embed-v1-*). + * + * Covers the three seams the recipe touches: + * - recipe registration + auth (PERPLEXITY_API_KEY only, never OPENAI_API_KEY) + * - flexible-dim validation (128..native max) in dims.ts + the init + * preflight (resolveSchemaEmbeddingDim), incl. the >2000-dim 4b case + * - perplexityCompatFetch: forces encoding_format=base64_int8 outbound and + * decodes the base64 int8 embedding payload to number[] inbound + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + dimsProviderOptions, + isPerplexityEmbeddingModel, + isValidPerplexityDim, + maxPerplexityEmbeddingDim, +} from '../../src/core/ai/dims.ts'; +import { getRecipe, RECIPES } from '../../src/core/ai/recipes/index.ts'; +import { perplexity } from '../../src/core/ai/recipes/perplexity.ts'; +import { defaultResolveAuth, perplexityCompatFetch } from '../../src/core/ai/gateway.ts'; +import { AIConfigError } from '../../src/core/ai/errors.ts'; +import { resolveSchemaEmbeddingDim } from '../../src/core/embedding-dim-check.ts'; +import { lookupEmbeddingPrice } from '../../src/core/embedding-pricing.ts'; + +describe('recipe: perplexity', () => { + test('registered as an OpenAI-compatible embedding provider', () => { + expect(RECIPES.has('perplexity')).toBe(true); + expect(getRecipe('perplexity')).toBe(perplexity); + expect(perplexity.tier).toBe('openai-compat'); + expect(perplexity.implementation).toBe('openai-compatible'); + expect(perplexity.base_url_default).toBe('https://api.perplexity.ai/v1'); + const e = perplexity.touchpoints.embedding!; + expect(e.models).toEqual(['pplx-embed-v1-0.6b', 'pplx-embed-v1-4b']); + expect(e.default_dims).toBe(1024); + expect(e.max_batch_tokens).toBe(120_000); + }); + + test('auth is PERPLEXITY_API_KEY bearer — no OPENAI_API_KEY fallback', () => { + expect(perplexity.resolveAuth).toBeUndefined(); + expect(perplexity.auth_env?.required).toEqual(['PERPLEXITY_API_KEY']); + expect(defaultResolveAuth(perplexity, { PERPLEXITY_API_KEY: 'fake-pplx' }, 'embedding')).toEqual({ + headerName: 'Authorization', + token: 'Bearer fake-pplx', + }); + // An OPENAI_API_KEY in the env must NOT satisfy Perplexity auth. + expect(() => defaultResolveAuth(perplexity, { OPENAI_API_KEY: 'sk-test' }, 'embedding')).toThrow(AIConfigError); + }); + + test('dims: 128..native-max range per model', () => { + expect(isPerplexityEmbeddingModel('pplx-embed-v1-4b')).toBe(true); + expect(maxPerplexityEmbeddingDim('pplx-embed-v1-4b')).toBe(2560); + expect(maxPerplexityEmbeddingDim('pplx-embed-v1-0.6b')).toBe(1024); + expect(isValidPerplexityDim('pplx-embed-v1-4b', 2560)).toBe(true); + expect(isValidPerplexityDim('pplx-embed-v1-4b', 128)).toBe(true); + expect(isValidPerplexityDim('pplx-embed-v1-4b', 64)).toBe(false); + expect(isValidPerplexityDim('pplx-embed-v1-0.6b', 2560)).toBe(false); + }); + + test('dimsProviderOptions emits native `dimensions`, fails loud out of range', () => { + expect(dimsProviderOptions('openai-compatible', 'pplx-embed-v1-4b', 2560)).toEqual({ + openaiCompatible: { dimensions: 2560 }, + }); + // Symmetric provider — inputType never emitted. + expect(dimsProviderOptions('openai-compatible', 'pplx-embed-v1-4b', 1024, 'query')).toEqual({ + openaiCompatible: { dimensions: 1024 }, + }); + expect(() => dimsProviderOptions('openai-compatible', 'pplx-embed-v1-0.6b', 2560)).toThrow(AIConfigError); + }); + + test('init preflight accepts the 4b model at its native 2560 dims (halfvec territory)', () => { + const res = resolveSchemaEmbeddingDim({ + embedding_model: 'perplexity:pplx-embed-v1-4b', + embedding_dimensions: 2560, + }); + expect(res).toEqual({ + ok: true, + dim: 2560, + model: 'perplexity:pplx-embed-v1-4b', + provider: 'perplexity', + recipeDefault: 1024, + }); + const bad = resolveSchemaEmbeddingDim({ + embedding_model: 'perplexity:pplx-embed-v1-4b', + embedding_dimensions: 4096, + }); + expect(bad.ok).toBe(false); + }); + + test('embedding pricing table knows both models', () => { + expect(lookupEmbeddingPrice('perplexity:pplx-embed-v1-4b')).toMatchObject({ kind: 'known', pricePerMTok: 0.03 }); + expect(lookupEmbeddingPrice('perplexity:pplx-embed-v1-0.6b')).toMatchObject({ kind: 'known', pricePerMTok: 0.004 }); + }); +}); + +describe('perplexityCompatFetch — int8 wire shim', () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test('forces encoding_format=base64_int8 outbound and decodes int8 base64 inbound', async () => { + const int8 = new Int8Array([3, -7, 127, -128]); + const b64 = Buffer.from(int8.buffer).toString('base64'); + let sentBody: any; + globalThis.fetch = (async (_input: any, init?: RequestInit) => { + sentBody = JSON.parse(init!.body as string); + return new Response( + JSON.stringify({ + object: 'list', + model: 'pplx-embed-v1-4b', + data: [{ object: 'embedding', index: 0, embedding: b64 }], + usage: { prompt_tokens: 4, total_tokens: 4 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as any; + + const resp = await (perplexityCompatFetch as any)('https://api.perplexity.ai/v1/embeddings', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // The AI SDK sends encoding_format:'float' — Perplexity rejects it. + body: JSON.stringify({ model: 'pplx-embed-v1-4b', input: ['hi'], encoding_format: 'float', dimensions: 4 }), + }); + + expect(sentBody.encoding_format).toBe('base64_int8'); + expect(sentBody.dimensions).toBe(4); // native field, untouched + const json = await resp.json(); + expect(json.data[0].embedding).toEqual([3, -7, 127, -128]); + expect(json.usage.prompt_tokens).toBe(4); + }); + + test('non-JSON and error responses pass through untouched', async () => { + globalThis.fetch = (async () => + new Response('nope', { status: 401, headers: { 'content-type': 'text/plain' } })) as any; + const resp = await (perplexityCompatFetch as any)('https://api.perplexity.ai/v1/embeddings', { + method: 'POST', + body: JSON.stringify({ model: 'pplx-embed-v1-4b', input: ['hi'] }), + }); + expect(resp.status).toBe(401); + expect(await resp.text()).toBe('nope'); + }); +});