mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80b8b6757 |
+11
-2
@@ -808,12 +808,20 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
|
||||
// 'default'. Wrapped in try/catch so a doctor / single-source brain that
|
||||
// never set up sources still returns 'default' silently.
|
||||
let sourceId: string | undefined;
|
||||
// #2561: when the source resolved via a NON-explicit tier (path-match /
|
||||
// brain default / sole-non-default / seed default), unqualified search-shaped
|
||||
// reads span every `config.federated = true` source. Computed here (the
|
||||
// trusted local boundary) and consumed by federatedSearchScope in
|
||||
// operations.ts, which additionally gates on ctx.remote === false.
|
||||
let localFederated: string[] | undefined;
|
||||
try {
|
||||
const { resolveSourceId } = await import('./core/source-resolver.ts');
|
||||
const { resolveSourceWithTier, localFederatedSourceIds } = await import('./core/source-resolver.ts');
|
||||
// params.source is set when a CLI flag was parsed for the op (rare; most
|
||||
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
|
||||
const explicit = (params.source as string | undefined) ?? null;
|
||||
sourceId = await resolveSourceId(engine, explicit);
|
||||
const resolved = await resolveSourceWithTier(engine, explicit);
|
||||
sourceId = resolved.source_id;
|
||||
localFederated = await localFederatedSourceIds(engine, resolved.source_id, resolved.tier);
|
||||
} catch {
|
||||
// Source resolution failed (e.g. sources table doesn't exist on a fresh
|
||||
// pre-init brain). Leave sourceId unset; engine read methods fall through
|
||||
@@ -834,6 +842,7 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
|
||||
// table). Matches dispatch.ts's auto-fill so the contract holds across
|
||||
// every transport.
|
||||
sourceId: sourceId ?? 'default',
|
||||
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -90,30 +90,6 @@ 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<string, number> = {
|
||||
'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
|
||||
@@ -250,23 +226,6 @@ 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
|
||||
|
||||
@@ -263,18 +263,6 @@ 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,
|
||||
@@ -1304,103 +1292,6 @@ 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));
|
||||
@@ -1469,8 +1360,6 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
|
||||
? zeroEntropyCompatFetch
|
||||
: recipe.id === 'nvidia'
|
||||
? nvidiaCompatFetch
|
||||
: recipe.id === 'perplexity'
|
||||
? perplexityCompatFetch
|
||||
: openAICompatAsymmetricFetch);
|
||||
const client = createOpenAICompatible({
|
||||
name: recipe.id,
|
||||
|
||||
@@ -26,7 +26,6 @@ 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,
|
||||
@@ -49,7 +48,6 @@ const ALL: Recipe[] = [
|
||||
moonshot,
|
||||
mistral,
|
||||
nvidia,
|
||||
perplexity,
|
||||
];
|
||||
|
||||
/** Map from `provider:id` key to recipe. */
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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).',
|
||||
};
|
||||
@@ -32,10 +32,6 @@ import {
|
||||
nvidiaEmbeddingDim,
|
||||
nvidiaEmbeddingDimOptions,
|
||||
supportsNvidiaEmbeddingDimension,
|
||||
isPerplexityEmbeddingModel,
|
||||
isValidPerplexityDim,
|
||||
maxPerplexityEmbeddingDim,
|
||||
PERPLEXITY_MIN_DIMS,
|
||||
} from './ai/dims.ts';
|
||||
|
||||
/**
|
||||
@@ -466,15 +462,6 @@ 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);
|
||||
|
||||
@@ -40,9 +40,6 @@ export const EMBEDDING_PRICING: Record<string, EmbeddingPricing> = {
|
||||
// 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 =
|
||||
|
||||
+61
-2
@@ -424,6 +424,23 @@ export interface OperationContext {
|
||||
* satisfied even on single-source brains.
|
||||
*/
|
||||
sourceId: string;
|
||||
/**
|
||||
* #2561 — federated read scope for UNQUALIFIED local CLI reads.
|
||||
*
|
||||
* Set ONLY by the local CLI's context builder (src/cli.ts makeContext), and
|
||||
* only when the source resolved via a non-explicit tier (local_path /
|
||||
* brain_default / sole_non_default / seed_default — NOT --source, NOT
|
||||
* GBRAIN_SOURCE, NOT a .gbrain-source dotfile). Contains the resolved
|
||||
* source first, then every other `config.federated = true` source, so an
|
||||
* unqualified `gbrain search "X"` spans federated sources as
|
||||
* docs/guides/multi-source-brains.md promises.
|
||||
*
|
||||
* Consumed exclusively by `federatedSearchScope` and ONLY when
|
||||
* `ctx.remote === false` — a remote caller's scope stays governed by
|
||||
* `ctx.auth.allowedSources` / scalar `ctx.sourceId` (source-isolation
|
||||
* invariant, fail-closed).
|
||||
*/
|
||||
localFederatedSourceIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,6 +556,45 @@ export function resolveRequestedScope(
|
||||
return sourceScopeOpts(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2561 — source scope for the search-shaped read ops (`search`, `query`).
|
||||
*
|
||||
* Delegates to `resolveRequestedScope` (the single trust+grant resolver), then
|
||||
* widens an UNQUALIFIED trusted-local scalar scope to the CLI-computed
|
||||
* federated set (`ctx.localFederatedSourceIds`, resolved source first). This is
|
||||
* what makes `sources add --federated` mean something for local search: a
|
||||
* federated source participates in unqualified `gbrain search "X"` results.
|
||||
*
|
||||
* The expansion NEVER applies when:
|
||||
* - the caller is not strictly trusted-local (`ctx.remote !== false`) —
|
||||
* remote scope stays grant-governed (fail-closed source isolation);
|
||||
* - a per-call `source_id` was passed (explicit wins, including `__all__`);
|
||||
* - the resolver already produced a federated array (OAuth grant);
|
||||
* - the CLI resolved the source from an explicit signal (--source / env /
|
||||
* dotfile) — makeContext leaves `localFederatedSourceIds` unset then.
|
||||
*
|
||||
* Deliberately NOT inside `sourceScopeOpts`: code-intel ops collapse a
|
||||
* multi-element scope to an error (`resolveCodeIntelScope`), and non-search
|
||||
* reads (get_page, get_links, …) keep their long-standing scalar behavior.
|
||||
*/
|
||||
export function federatedSearchScope(
|
||||
ctx: OperationContext,
|
||||
sourceIdParam?: string,
|
||||
): { sourceId?: string; sourceIds?: string[] } {
|
||||
const scope = resolveRequestedScope(ctx, sourceIdParam);
|
||||
if (
|
||||
ctx.remote === false &&
|
||||
sourceIdParam === undefined &&
|
||||
scope.sourceId !== undefined &&
|
||||
scope.sourceIds === undefined &&
|
||||
ctx.localFederatedSourceIds !== undefined &&
|
||||
ctx.localFederatedSourceIds.length > 1
|
||||
) {
|
||||
return { sourceIds: ctx.localFederatedSourceIds };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code-intel adapter for `resolveRequestedScope`. Graph traversal
|
||||
* (code_callers/code_callees/code_blast/code_flow) is single-source by design —
|
||||
@@ -1448,7 +1504,8 @@ const search: Operation = {
|
||||
const queryText = p.query as string;
|
||||
const limit = (p.limit as number) || 20;
|
||||
const offset = (p.offset as number) || 0;
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
// #2561: unqualified trusted-local search spans federated sources.
|
||||
const scope = federatedSearchScope(ctx);
|
||||
|
||||
// T4/D5 — per-call mode honored ONLY for trusted/local callers so a remote
|
||||
// OAuth client can't escalate to the costly tokenmax bundle. Local + unknown
|
||||
@@ -1610,7 +1667,9 @@ const query: Operation = {
|
||||
// is spread into BOTH the image-similarity searchVector path and the text
|
||||
// hybridSearch path below, so both honor the same grant.
|
||||
const sourceIdParam = typeof p.source_id === 'string' ? p.source_id : undefined;
|
||||
const querySourceScope = resolveRequestedScope(ctx, sourceIdParam);
|
||||
// #2561: unqualified trusted-local query spans federated sources (per-call
|
||||
// source_id / remote grants still resolve through resolveRequestedScope).
|
||||
const querySourceScope = federatedSearchScope(ctx, sourceIdParam);
|
||||
|
||||
// v0.27.1: image-similarity branch. Bypasses hybridSearch (which is
|
||||
// text-only); embeds the image via embedMultimodal and runs a direct
|
||||
|
||||
@@ -353,6 +353,45 @@ export async function resolveSourceWithTier(
|
||||
return { source_id: 'default', tier: 'seed_default' };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2561 — compute the federated read scope for an UNQUALIFIED local CLI call.
|
||||
*
|
||||
* `sources add --federated` promises that a `config.federated = true` source
|
||||
* "participates in unqualified `gbrain search` results"
|
||||
* (docs/guides/multi-source-brains.md). This helper turns that promise into a
|
||||
* scope: given the resolved source and WHICH tier resolved it, return
|
||||
* `[resolvedSource, ...other federated source ids]` — or `undefined` when the
|
||||
* expansion must not apply:
|
||||
*
|
||||
* - explicit tiers (`flag` / `env` / `dotfile`): the user named a source;
|
||||
* scalar scope stands (that IS the qualified case);
|
||||
* - no other federated source exists: keep the scalar fast path unchanged.
|
||||
*
|
||||
* Archived sources are excluded (same rationale as pickSoleNonDefaultSource);
|
||||
* the archived column is v34+, so fall back to the un-archived query on older
|
||||
* brains. Callers put the result on `OperationContext.localFederatedSourceIds`
|
||||
* — consumed only by `federatedSearchScope` and only when `remote === false`.
|
||||
*/
|
||||
export async function localFederatedSourceIds(
|
||||
engine: BrainEngine,
|
||||
sourceId: string,
|
||||
tier: SourceTier,
|
||||
): Promise<string[] | undefined> {
|
||||
if (tier === 'flag' || tier === 'env' || tier === 'dotfile') return undefined;
|
||||
let rows: Array<{ id: string }>;
|
||||
try {
|
||||
rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE config->>'federated' = 'true' AND archived = false ORDER BY id`,
|
||||
);
|
||||
} catch {
|
||||
rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE config->>'federated' = 'true' ORDER BY id`,
|
||||
);
|
||||
}
|
||||
const ids = [sourceId, ...rows.map((r) => r.id).filter((id) => id !== sourceId)];
|
||||
return ids.length > 1 ? ids : undefined;
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __testing = {
|
||||
readDotfileWalk,
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* #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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* #2561 — sources.config.federated participates in UNQUALIFIED local CLI
|
||||
* search/query.
|
||||
*
|
||||
* Pre-fix: the local CLI always emitted a scalar `{sourceId}` scope (required
|
||||
* field, auto-filled 'default'), so a source registered with
|
||||
* `gbrain sources add --federated` was invisible to an unqualified
|
||||
* `gbrain search "X"` — contradicting docs/guides/multi-source-brains.md
|
||||
* ("Source participates in unqualified `gbrain search` results").
|
||||
*
|
||||
* Fix: the CLI context builder computes `ctx.localFederatedSourceIds`
|
||||
* (resolved source + every other federated source) whenever the source
|
||||
* resolved via a NON-explicit tier; `federatedSearchScope` widens the scalar
|
||||
* scope to that set for the `search` / `query` ops — trusted-local only
|
||||
* (`ctx.remote === false`), never for remote callers, never when a per-call
|
||||
* `source_id` or an explicit --source/env/dotfile was given.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { localFederatedSourceIds } from '../src/core/source-resolver.ts';
|
||||
import {
|
||||
federatedSearchScope,
|
||||
operations,
|
||||
type OperationContext,
|
||||
} from '../src/core/operations.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
const search = operations.find((o) => o.name === 'search')!;
|
||||
|
||||
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
return {
|
||||
engine: engine as any,
|
||||
config: {} as any,
|
||||
logger: console as any,
|
||||
dryRun: false,
|
||||
remote: false,
|
||||
sourceId: 'default',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
// Seeded 'default' source is federated=true. Add:
|
||||
// wiki — federated (must join unqualified search)
|
||||
// private — NOT federated (must stay invisible unless explicitly named)
|
||||
// oldnews — federated but archived (must stay excluded)
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config) VALUES ('wiki', 'wiki', '/tmp/wiki', '{"federated": true}'::jsonb)`,
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config) VALUES ('private', 'private', '/tmp/private', '{}'::jsonb)`,
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, archived) VALUES ('oldnews', 'oldnews', '/tmp/oldnews', '{"federated": true}'::jsonb, true)`,
|
||||
);
|
||||
const pages: Array<[slug: string, sourceId: string, where: string]> = [
|
||||
['notes/home', 'default', 'default'],
|
||||
['wiki/topic', 'wiki', 'wiki'],
|
||||
['private/topic', 'private', 'private'],
|
||||
['old/topic', 'oldnews', 'oldnews'],
|
||||
];
|
||||
for (const [slug, sourceId, where] of pages) {
|
||||
await engine.putPage(slug, {
|
||||
type: 'note', title: `Topic in ${where}`, compiled_truth: `the zebra telescope in ${where}`, frontmatter: {},
|
||||
}, { sourceId });
|
||||
await engine.upsertChunks(slug, [
|
||||
{ chunk_index: 0, chunk_text: `the zebra telescope in ${where}`, chunk_source: 'compiled_truth' },
|
||||
], { sourceId });
|
||||
}
|
||||
// Keyword-only search path: no embedding provider needed in tests.
|
||||
await engine.setConfig('search.mcp_keyword_only', 'true');
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
describe('localFederatedSourceIds — CLI-side scope computation', () => {
|
||||
test('non-explicit tier: resolved source first, then other federated, archived excluded', async () => {
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'seed_default')).toEqual(['default', 'wiki']);
|
||||
});
|
||||
|
||||
test('non-federated resolved source still joins its own scope', async () => {
|
||||
expect(await localFederatedSourceIds(engine, 'private', 'brain_default')).toEqual(['private', 'default', 'wiki']);
|
||||
});
|
||||
|
||||
test('explicit tiers (--source / env / dotfile) never expand', async () => {
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'flag')).toBeUndefined();
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'env')).toBeUndefined();
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'dotfile')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('single federated source (the resolved one) keeps the scalar fast path', async () => {
|
||||
const solo = { executeRaw: async () => [{ id: 'default' }] } as any;
|
||||
expect(await localFederatedSourceIds(solo, 'default', 'seed_default')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('federatedSearchScope — trust + explicitness matrix', () => {
|
||||
test('trusted local + unqualified widens to the federated set', () => {
|
||||
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['default', 'wiki'] });
|
||||
});
|
||||
|
||||
test('remote caller NEVER widens (fail-closed), even if the field is set', () => {
|
||||
const ctx = ctxOf({ remote: true, localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx)).toEqual({ sourceId: 'default' });
|
||||
});
|
||||
|
||||
test('per-call source_id wins over the federated set', () => {
|
||||
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx, 'wiki')).toEqual({ sourceId: 'wiki' });
|
||||
});
|
||||
|
||||
test('per-call __all__ keeps the whole-brain semantics for trusted local', () => {
|
||||
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx, '__all__')).toEqual({});
|
||||
});
|
||||
|
||||
test('a federated OAuth grant wins over the local set', () => {
|
||||
const ctx = ctxOf({
|
||||
localFederatedSourceIds: ['default', 'wiki'],
|
||||
auth: { allowedSources: ['a', 'b'] } as OperationContext['auth'],
|
||||
});
|
||||
expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['a', 'b'] });
|
||||
});
|
||||
|
||||
test('no local federated set → unchanged scalar scope', () => {
|
||||
expect(federatedSearchScope(ctxOf())).toEqual({ sourceId: 'default' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('search op — unqualified local search spans federated sources', () => {
|
||||
test('federated source results appear; non-federated + archived stay invisible', async () => {
|
||||
const ctx = ctxOf({
|
||||
localFederatedSourceIds: await localFederatedSourceIds(engine, 'default', 'seed_default'),
|
||||
});
|
||||
const results = (await search.handler(ctx, { query: 'zebra telescope' })) as Array<{ slug: string }>;
|
||||
const slugs = results.map((r) => r.slug);
|
||||
expect(slugs).toContain('notes/home');
|
||||
expect(slugs).toContain('wiki/topic'); // pre-#2561 this was missing
|
||||
expect(slugs).not.toContain('private/topic');
|
||||
expect(slugs).not.toContain('old/topic');
|
||||
});
|
||||
|
||||
test('explicit source resolution (no federated set on ctx) stays single-source', async () => {
|
||||
const results = (await search.handler(ctxOf(), { query: 'zebra telescope' })) as Array<{ slug: string }>;
|
||||
const slugs = results.map((r) => r.slug);
|
||||
expect(slugs).toEqual(['notes/home']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user