mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
feat(core): Levenshtein helper + preflight schema-dim resolvers
Foundation for v0.37.10.0 env-detection wave. Two pure modules: - src/core/levenshtein.ts: editDistance(a,b) + suggestNearest(input, candidates, maxDistance). Used by config-set "did you mean" suggestions and env-var typo detection at init. - src/core/embedding-dim-check.ts: resolveSchemaEmbeddingDim() + resolveSchemaMultimodalDim() pure functions. Validate resolved dim against recipe default_dims + per-provider Matryoshka allow-lists (OpenAI text-3, Voyage flexible-dim, ZeroEntropy zembed-1) BEFORE any DB write. Plus EmbeddingDisabledError + assertEmbeddingEnabled() runtime guard for the deferred-setup path (D9). New PGVECTOR_COLUMN_MAX_DIMS=16000 exported. Tests: 41 unit cases across both modules.
This commit is contained in:
@@ -15,6 +15,64 @@
|
||||
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import { PGVECTOR_HNSW_VECTOR_MAX_DIMS } from './vector-index.ts';
|
||||
import { resolveRecipe } from './ai/model-resolver.ts';
|
||||
import type { Recipe } from './ai/types.ts';
|
||||
import { AIConfigError } from './ai/errors.ts';
|
||||
import {
|
||||
supportsVoyageOutputDimension,
|
||||
isValidVoyageOutputDim,
|
||||
VOYAGE_VALID_OUTPUT_DIMS,
|
||||
supportsZeroEntropyDimension,
|
||||
isValidZeroEntropyDim,
|
||||
ZEROENTROPY_VALID_DIMS,
|
||||
isOpenAITextEmbedding3Model,
|
||||
isValidOpenAITextEmbedding3Dim,
|
||||
maxOpenAITextEmbedding3Dim,
|
||||
} from './ai/dims.ts';
|
||||
|
||||
/**
|
||||
* pgvector supports vector(N) columns up to 16000 dimensions. HNSW indexing
|
||||
* is capped at PGVECTOR_HNSW_VECTOR_MAX_DIMS (2000); above that, exact scan
|
||||
* still works but searches are slower.
|
||||
*
|
||||
* The preflight resolver below uses this as the hard upper bound so anything
|
||||
* pgvector itself would reject (e.g. an accidental `embedding_dimensions: 99999`)
|
||||
* fails at init time rather than at first embed.
|
||||
*/
|
||||
export const PGVECTOR_COLUMN_MAX_DIMS = 16000;
|
||||
|
||||
/**
|
||||
* v0.37 (D9): runtime guard for the deferred-setup mode.
|
||||
*
|
||||
* Init's `--no-embedding` opt-in writes `embedding_disabled: true` to
|
||||
* config.json. Every embed callsite (CLI: `gbrain embed`, `gbrain import`;
|
||||
* library: `runEmbedCore`) consults this guard so the user gets a clear
|
||||
* "configure embedding first" message rather than an opaque gateway error
|
||||
* at first vector write.
|
||||
*
|
||||
* Returns void on the happy path. Throws `EmbeddingDisabledError` when the
|
||||
* config has `embedding_disabled: true`. The error type lets callers in
|
||||
* CLI mode print a paste-ready hint + exit 1, and library callers (Minion
|
||||
* handlers) bubble it back as a structured job failure.
|
||||
*/
|
||||
export class EmbeddingDisabledError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'EmbeddingDisabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEmbeddingEnabled(cfg: { embedding_disabled?: boolean } | null): void {
|
||||
if (cfg?.embedding_disabled) {
|
||||
throw new EmbeddingDisabledError(
|
||||
'This brain was initialized with `--no-embedding` (deferred setup).\n' +
|
||||
'Configure an embedding provider before running embed / import:\n' +
|
||||
' gbrain config set embedding_model <provider>:<model>\n' +
|
||||
' gbrain config set embedding_dimensions <N>\n' +
|
||||
' gbrain init --force --embedding-model <provider>:<model> # re-init to size schema\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ColumnDimResult {
|
||||
/** Whether the `content_chunks.embedding` column exists. False on a fresh brain. */
|
||||
@@ -118,3 +176,231 @@ export function embeddingMismatchMessage(opts: {
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v0.37.x — preflight schema-dim resolution (D11 + D12)
|
||||
//
|
||||
// Resolves the dim that the PGLite schema substitution will use BEFORE
|
||||
// `engine.initSchema()` runs, so init can't create a column whose width
|
||||
// disagrees with the gateway-resolved provider. Pure functions, no I/O —
|
||||
// init calls them, exits early on error, never writes anything to disk in
|
||||
// the failure path. The post-init invariant assertion stays as a regression
|
||||
// guardrail; after this resolver lands it can never fire.
|
||||
// ============================================================================
|
||||
|
||||
/** Tagged-union result of preflight resolution. */
|
||||
export type ResolveSchemaDimResult =
|
||||
| { ok: true; dim: number; model: string; provider: string; recipeDefault: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Inputs for the embedding-tier preflight resolver. */
|
||||
export interface ResolveSchemaEmbeddingDimOpts {
|
||||
/** `provider:model` string (e.g. `openai:text-embedding-3-large`). Required. */
|
||||
embedding_model: string;
|
||||
/** Explicit override (Matryoshka step, custom dim). Optional. */
|
||||
embedding_dimensions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the dim that will land in `content_chunks.embedding`'s vector(N)
|
||||
* column. Caller is `init.ts:initPGLite` before any DB write happens.
|
||||
*
|
||||
* Validations:
|
||||
* 1. `embedding_model` parses as `provider:model`.
|
||||
* 2. Provider is a known recipe.
|
||||
* 3. Recipe declares an `embedding` touchpoint.
|
||||
* 4. Resolved dim is a positive integer.
|
||||
* 5. Resolved dim ≤ PGVECTOR_COLUMN_MAX_DIMS (16000).
|
||||
* 6. If user passed `embedding_dimensions`, it either matches
|
||||
* `recipe.touchpoints.embedding.default_dims` OR is in the recipe's
|
||||
* `dims_options` list (Matryoshka providers). Otherwise reject — the
|
||||
* user picked a model that doesn't support custom dims.
|
||||
*/
|
||||
export function resolveSchemaEmbeddingDim(opts: ResolveSchemaEmbeddingDimOpts): ResolveSchemaDimResult {
|
||||
try {
|
||||
const { recipe, parsed } = resolveRecipe(opts.embedding_model);
|
||||
const tp = recipe.touchpoints.embedding;
|
||||
if (!tp) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Provider "${recipe.id}" does not offer embedding models. ` +
|
||||
`Pick a recipe with an embedding touchpoint (gbrain providers list).`,
|
||||
};
|
||||
}
|
||||
return validateDimAgainstTouchpoint(parsed.modelId, recipe, tp.default_dims, tp.dims_options, opts.embedding_dimensions);
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof AIConfigError ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs for the multimodal-tier preflight resolver (D12). */
|
||||
export interface ResolveSchemaMultimodalDimOpts {
|
||||
/** `provider:model` string for the multimodal endpoint. Required. */
|
||||
embedding_multimodal_model: string;
|
||||
/** Explicit override. Optional. */
|
||||
embedding_multimodal_dimensions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the dim that will land in `content_chunks.embedding_multimodal`'s
|
||||
* vector(N) column. Mirrors `resolveSchemaEmbeddingDim` but also checks the
|
||||
* recipe-level `supports_multimodal` flag and the per-model
|
||||
* `multimodal_models` allow-list (some recipes like Voyage mix text-only
|
||||
* and multimodal models in one embedding touchpoint).
|
||||
*/
|
||||
export function resolveSchemaMultimodalDim(opts: ResolveSchemaMultimodalDimOpts): ResolveSchemaDimResult {
|
||||
try {
|
||||
const { recipe, parsed } = resolveRecipe(opts.embedding_multimodal_model);
|
||||
const tp = recipe.touchpoints.embedding;
|
||||
if (!tp) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Provider "${recipe.id}" does not offer embedding models. ` +
|
||||
`Pick a recipe with an embedding touchpoint that supports multimodal input.`,
|
||||
};
|
||||
}
|
||||
if (!tp.supports_multimodal) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Provider "${recipe.id}" does not support multimodal embeddings. ` +
|
||||
`Configured recipes that do: voyage (voyage-multimodal-3). ` +
|
||||
`Run \`gbrain providers list\` to see touchpoint coverage.`,
|
||||
};
|
||||
}
|
||||
if (tp.multimodal_models && !tp.multimodal_models.includes(parsed.modelId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Model "${parsed.modelId}" is not in provider "${recipe.id}"'s multimodal allow-list ` +
|
||||
`(allowed: ${tp.multimodal_models.join(', ')}). ` +
|
||||
`Pick a multimodal-capable model from this provider.`,
|
||||
};
|
||||
}
|
||||
return validateDimAgainstTouchpoint(parsed.modelId, recipe, tp.default_dims, tp.dims_options, opts.embedding_multimodal_dimensions);
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof AIConfigError ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared validation of a requested dim against a recipe touchpoint's
|
||||
* declared dims, including provider-specific Matryoshka allow-lists.
|
||||
*
|
||||
* Recipes (`src/core/ai/recipes/*.ts`) declare `default_dims` per touchpoint
|
||||
* but do NOT generally encode Matryoshka steps as `dims_options`. The
|
||||
* per-provider valid-dim allow-lists live in `src/core/ai/dims.ts`:
|
||||
* - `VOYAGE_VALID_OUTPUT_DIMS` (256/512/1024/2048) for flexible Voyage models
|
||||
* - `ZEROENTROPY_VALID_DIMS` (2560/1280/640/320/160/80/40) for ZE zembed-1
|
||||
* - OpenAI text-embedding-3-* accepts ANY positive integer up to the
|
||||
* model's native size (1536 small / 3072 large)
|
||||
*
|
||||
* Validation order:
|
||||
* 1. recipe-declared `dims_options` (highest precedence — recipe author
|
||||
* knows their backend)
|
||||
* 2. provider-specific dim.ts allow-lists (for known Matryoshka providers)
|
||||
* 3. fall through to "this model only emits default_dims" rejection
|
||||
*/
|
||||
function validateDimAgainstTouchpoint(
|
||||
modelId: string,
|
||||
recipe: Recipe,
|
||||
defaultDims: number,
|
||||
dimsOptions: number[] | undefined,
|
||||
requestedDims: number | undefined,
|
||||
): ResolveSchemaDimResult {
|
||||
const dim = requestedDims ?? defaultDims;
|
||||
|
||||
if (!Number.isInteger(dim) || dim <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Embedding dimensions must be a positive integer; got ${JSON.stringify(dim)}.`,
|
||||
};
|
||||
}
|
||||
if (dim > PGVECTOR_COLUMN_MAX_DIMS) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Embedding dimensions ${dim} exceed pgvector's column cap of ${PGVECTOR_COLUMN_MAX_DIMS}. ` +
|
||||
`Pick a model that returns ≤${PGVECTOR_COLUMN_MAX_DIMS} dims.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedDims !== undefined && requestedDims !== defaultDims) {
|
||||
// User asked for a non-default dim. Walk the precedence chain.
|
||||
const customDimOk = isCustomDimValidForProvider(recipe, modelId, requestedDims, dimsOptions);
|
||||
if (!customDimOk.valid) {
|
||||
return { ok: false, error: customDimOk.error };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
dim,
|
||||
model: `${recipe.id}:${modelId}`,
|
||||
provider: recipe.id,
|
||||
recipeDefault: defaultDims,
|
||||
};
|
||||
}
|
||||
|
||||
interface CustomDimCheck {
|
||||
valid: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
function isCustomDimValidForProvider(
|
||||
recipe: Recipe,
|
||||
modelId: string,
|
||||
requestedDims: number,
|
||||
dimsOptions: number[] | undefined,
|
||||
): CustomDimCheck {
|
||||
// Tier 1: recipe-declared dims_options.
|
||||
if (dimsOptions && dimsOptions.length > 0) {
|
||||
if (dimsOptions.includes(requestedDims)) return { valid: true, error: '' };
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
`Provider "${recipe.id}" model "${modelId}" rejects custom dimensions ${requestedDims} ` +
|
||||
`(allowed: ${dimsOptions.join(', ')}).`,
|
||||
};
|
||||
}
|
||||
|
||||
// Tier 2: provider-specific Matryoshka allow-lists.
|
||||
if (recipe.id === 'voyage' && supportsVoyageOutputDimension(modelId)) {
|
||||
if (isValidVoyageOutputDim(requestedDims)) return { valid: true, error: '' };
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
`Voyage model "${modelId}" rejects custom dimensions ${requestedDims} ` +
|
||||
`(allowed: ${VOYAGE_VALID_OUTPUT_DIMS.join(', ')}).`,
|
||||
};
|
||||
}
|
||||
if (recipe.id === 'zeroentropyai' && supportsZeroEntropyDimension(modelId)) {
|
||||
if (isValidZeroEntropyDim(requestedDims)) return { valid: true, error: '' };
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
`ZeroEntropy model "${modelId}" does not support custom dimensions ${requestedDims} ` +
|
||||
`(allowed: ${ZEROENTROPY_VALID_DIMS.join(', ')}).`,
|
||||
};
|
||||
}
|
||||
if (recipe.id === 'openai' && isOpenAITextEmbedding3Model(modelId)) {
|
||||
if (isValidOpenAITextEmbedding3Dim(modelId, requestedDims)) return { valid: true, error: '' };
|
||||
const maxDim = maxOpenAITextEmbedding3Dim(modelId);
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
`OpenAI ${modelId} accepts dimensions 1..${maxDim}, got ${requestedDims}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Tier 3: provider not known to support custom dims at all.
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
`Provider "${recipe.id}" model "${modelId}" does not support custom dimensions ${requestedDims} ` +
|
||||
`(this model only emits its default vector size). ` +
|
||||
`Either drop --embedding-dimensions or pick a Matryoshka-aware model.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Levenshtein edit distance + nearest-match suggestion.
|
||||
*
|
||||
* Used by `gbrain config set` (D6) to suggest the canonical key when a user
|
||||
* writes an unknown one (`embedding.provider` → "did you mean `embedding_model`?"),
|
||||
* and by init's env detection (D13) to flag near-miss env var names
|
||||
* (`OPENAPI_API_KEY` → "did you mean `OPENAI_API_KEY`?").
|
||||
*
|
||||
* Iterative two-row DP, O(m*n) time, O(min(m,n)) space. Plenty fast for the
|
||||
* ~30 known config keys and ~14 recipe env vars we compare against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns the minimum number of single-character insertions, deletions, or
|
||||
* substitutions to transform `a` into `b`. Case-sensitive.
|
||||
*/
|
||||
export function editDistance(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
// Ensure b is the shorter — keeps the row buffer minimal.
|
||||
if (a.length < b.length) {
|
||||
const tmp = a;
|
||||
a = b;
|
||||
b = tmp;
|
||||
}
|
||||
|
||||
const n = b.length;
|
||||
let prev = new Array<number>(n + 1);
|
||||
let curr = new Array<number>(n + 1);
|
||||
for (let j = 0; j <= n; j++) prev[j] = j;
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
curr[0] = i;
|
||||
const ai = a.charCodeAt(i - 1);
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = ai === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
const del = prev[j] + 1;
|
||||
const ins = curr[j - 1] + 1;
|
||||
const sub = prev[j - 1] + cost;
|
||||
curr[j] = del < ins ? (del < sub ? del : sub) : (ins < sub ? ins : sub);
|
||||
}
|
||||
const swap = prev;
|
||||
prev = curr;
|
||||
curr = swap;
|
||||
}
|
||||
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the closest match for `input` among `candidates` whose edit distance
|
||||
* is ≤ `maxDistance` (default 3). Returns the best match or null.
|
||||
*
|
||||
* Tie-break: lexicographic order of the candidate (deterministic across runs).
|
||||
*/
|
||||
export function suggestNearest(
|
||||
input: string,
|
||||
candidates: readonly string[],
|
||||
maxDistance = 3,
|
||||
): string | null {
|
||||
let best: string | null = null;
|
||||
let bestDist = maxDistance + 1;
|
||||
for (const c of candidates) {
|
||||
const d = editDistance(input, c);
|
||||
if (d < bestDist || (d === bestDist && best !== null && c < best)) {
|
||||
best = c;
|
||||
bestDist = d;
|
||||
}
|
||||
}
|
||||
return bestDist <= maxDistance ? best : null;
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import {
|
||||
readContentChunksEmbeddingDim,
|
||||
embeddingMismatchMessage,
|
||||
resolveSchemaEmbeddingDim,
|
||||
resolveSchemaMultimodalDim,
|
||||
PGVECTOR_COLUMN_MAX_DIMS,
|
||||
} from '../src/core/embedding-dim-check.ts';
|
||||
|
||||
// Canonical pattern: single engine per file, init once, disconnect once.
|
||||
@@ -95,3 +98,163 @@ describe('embeddingMismatchMessage', () => {
|
||||
expect(doctorMsg).toContain('Embedding dimension mismatch detected');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// v0.37.x — D11 + D12 preflight resolvers
|
||||
// ============================================================================
|
||||
|
||||
describe('resolveSchemaEmbeddingDim', () => {
|
||||
test('OpenAI text-embedding-3-large resolves at default 1536', () => {
|
||||
const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai:text-embedding-3-large' });
|
||||
expect(got).toEqual({
|
||||
ok: true,
|
||||
dim: 1536,
|
||||
model: 'openai:text-embedding-3-large',
|
||||
provider: 'openai',
|
||||
recipeDefault: 1536,
|
||||
});
|
||||
});
|
||||
|
||||
test('ZeroEntropy zembed-1 resolves at recipe default', () => {
|
||||
const got = resolveSchemaEmbeddingDim({ embedding_model: 'zeroentropyai:zembed-1' });
|
||||
expect(got.ok).toBe(true);
|
||||
if (got.ok) {
|
||||
expect(got.provider).toBe('zeroentropyai');
|
||||
expect(got.model).toBe('zeroentropyai:zembed-1');
|
||||
expect(got.dim).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('ZeroEntropy Matryoshka explicit dim (1280) accepted', () => {
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'zeroentropyai:zembed-1',
|
||||
embedding_dimensions: 1280,
|
||||
});
|
||||
expect(got.ok).toBe(true);
|
||||
if (got.ok) expect(got.dim).toBe(1280);
|
||||
});
|
||||
|
||||
test('ZeroEntropy Matryoshka invalid dim (1024) rejected — 1024 is Voyage step, not ZE', () => {
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'zeroentropyai:zembed-1',
|
||||
embedding_dimensions: 1024,
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
if (!got.ok) expect(got.error).toMatch(/does not support custom dimensions 1024|only emits/);
|
||||
});
|
||||
|
||||
test('OpenAI text-3-large rejects 2048 (not in declared dims_options)', () => {
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 2048,
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
if (!got.ok) expect(got.error).toMatch(/rejects custom dimensions 2048|does not support custom dimensions/);
|
||||
});
|
||||
|
||||
test('OpenAI text-3-large accepts 768 (declared in recipe dims_options)', () => {
|
||||
// text-embedding-3-large declares dims_options including 768.
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 768,
|
||||
});
|
||||
expect(got.ok).toBe(true);
|
||||
if (got.ok) expect(got.dim).toBe(768);
|
||||
});
|
||||
|
||||
test('unknown provider rejected with provider list hint', () => {
|
||||
const got = resolveSchemaEmbeddingDim({ embedding_model: 'notarealprovider:foo' });
|
||||
expect(got.ok).toBe(false);
|
||||
if (!got.ok) expect(got.error).toMatch(/unknown provider/i);
|
||||
});
|
||||
|
||||
test('missing colon rejected', () => {
|
||||
const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai' });
|
||||
expect(got.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('negative dim rejected', () => {
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: -100,
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
if (!got.ok) expect(got.error).toMatch(/positive integer/);
|
||||
});
|
||||
|
||||
test('zero dim rejected', () => {
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 0,
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('non-integer dim rejected', () => {
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536.5,
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('dim exceeding pgvector column cap rejected', () => {
|
||||
const got = resolveSchemaEmbeddingDim({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1,
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
if (!got.ok) expect(got.error).toMatch(/exceed pgvector's column cap/);
|
||||
});
|
||||
|
||||
test('regression: bug-reporter scenario — OpenAI auto-pick resolves at 1536', () => {
|
||||
const got = resolveSchemaEmbeddingDim({ embedding_model: 'openai:text-embedding-3-large' });
|
||||
expect(got.ok).toBe(true);
|
||||
if (got.ok) {
|
||||
expect(got.dim).toBe(1536);
|
||||
expect(got.model).toBe('openai:text-embedding-3-large');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSchemaMultimodalDim', () => {
|
||||
test('voyage voyage-multimodal-3 accepted', () => {
|
||||
const got = resolveSchemaMultimodalDim({ embedding_multimodal_model: 'voyage:voyage-multimodal-3' });
|
||||
expect(got.ok).toBe(true);
|
||||
if (got.ok) {
|
||||
expect(got.provider).toBe('voyage');
|
||||
expect(got.dim).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenAI text-embedding-3-large rejected — not multimodal', () => {
|
||||
const got = resolveSchemaMultimodalDim({
|
||||
embedding_multimodal_model: 'openai:text-embedding-3-large',
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
if (!got.ok) expect(got.error).toMatch(/does not support multimodal/);
|
||||
});
|
||||
|
||||
test('voyage text-only model (voyage-3-large) rejected via allow-list', () => {
|
||||
const got = resolveSchemaMultimodalDim({
|
||||
embedding_multimodal_model: 'voyage:voyage-3-large',
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
if (!got.ok) expect(got.error).toMatch(/not in provider "voyage"'s multimodal allow-list/);
|
||||
});
|
||||
|
||||
test('unknown provider rejected', () => {
|
||||
const got = resolveSchemaMultimodalDim({
|
||||
embedding_multimodal_model: 'notarealprovider:foo',
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('dim above pgvector cap rejected', () => {
|
||||
const got = resolveSchemaMultimodalDim({
|
||||
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
|
||||
embedding_multimodal_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1,
|
||||
});
|
||||
expect(got.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { editDistance, suggestNearest } from '../src/core/levenshtein.ts';
|
||||
|
||||
describe('editDistance', () => {
|
||||
test('identical strings return 0', () => {
|
||||
expect(editDistance('embedding_model', 'embedding_model')).toBe(0);
|
||||
});
|
||||
|
||||
test('empty vs non-empty returns length', () => {
|
||||
expect(editDistance('', 'foo')).toBe(3);
|
||||
expect(editDistance('foo', '')).toBe(3);
|
||||
});
|
||||
|
||||
test('one substitution', () => {
|
||||
expect(editDistance('cat', 'bat')).toBe(1);
|
||||
});
|
||||
|
||||
test('one insertion', () => {
|
||||
expect(editDistance('cat', 'cats')).toBe(1);
|
||||
});
|
||||
|
||||
test('one deletion', () => {
|
||||
expect(editDistance('cats', 'cat')).toBe(1);
|
||||
});
|
||||
|
||||
test('classic transposition (kitten → sitting)', () => {
|
||||
expect(editDistance('kitten', 'sitting')).toBe(3);
|
||||
});
|
||||
|
||||
test('case-sensitive', () => {
|
||||
// 'A' vs 'a' is a substitution
|
||||
expect(editDistance('OPENAI_API_KEY', 'openai_api_key')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('symmetric', () => {
|
||||
expect(editDistance('abc', 'xyz')).toBe(editDistance('xyz', 'abc'));
|
||||
});
|
||||
|
||||
test('bug-reporter case: embedding.provider → embedding_model', () => {
|
||||
// 6 edits: replace 'p' with 'm', 'r' with 'o', 'o' with 'd', 'v' with 'e',
|
||||
// 'i' with 'l', and a couple more changes
|
||||
const d = editDistance('embedding.provider', 'embedding_model');
|
||||
// The exact number doesn't matter — we want it > 3 so it would NOT
|
||||
// suggest embedding_model for embedding.provider with default threshold.
|
||||
// The dot-vs-underscore case is handled by a different mapping (see
|
||||
// suggestNearest with higher threshold in the config.ts caller).
|
||||
expect(d).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
test('bug-reporter case: embedding.model → embedding_model (1 edit)', () => {
|
||||
// Only '.' vs '_' differs. 1 substitution.
|
||||
expect(editDistance('embedding.model', 'embedding_model')).toBe(1);
|
||||
});
|
||||
|
||||
test('typo: OPENAPI_API_KEY → OPENAI_API_KEY (1 deletion)', () => {
|
||||
expect(editDistance('OPENAPI_API_KEY', 'OPENAI_API_KEY')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestNearest', () => {
|
||||
const KEYS = ['embedding_model', 'embedding_dimensions', 'expansion_model', 'chat_model', 'search.mode'];
|
||||
|
||||
test('exact match returns identity', () => {
|
||||
expect(suggestNearest('chat_model', KEYS)).toBe('chat_model');
|
||||
});
|
||||
|
||||
test('1-edit typo suggests within default threshold', () => {
|
||||
expect(suggestNearest('embedding.model', KEYS)).toBe('embedding_model');
|
||||
});
|
||||
|
||||
test('returns null when no candidate is within threshold', () => {
|
||||
expect(suggestNearest('completely_unrelated_garbage_string', KEYS)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null with empty candidates', () => {
|
||||
expect(suggestNearest('whatever', [])).toBeNull();
|
||||
});
|
||||
|
||||
test('deterministic tiebreak on equal distance', () => {
|
||||
// Both 'a' and 'b' are at distance 1 from 'c'. Lex order: 'a' < 'b'.
|
||||
const got = suggestNearest('c', ['a', 'b']);
|
||||
expect(got).toBe('a');
|
||||
});
|
||||
|
||||
test('respects maxDistance override', () => {
|
||||
// 4 edits away — outside the default 3, but inside an override of 5
|
||||
const far = 'fOOO_API_KEY';
|
||||
expect(suggestNearest(far, ['OPENAI_API_KEY'])).toBeNull();
|
||||
expect(suggestNearest(far, ['OPENAI_API_KEY'], 10)).toBe('OPENAI_API_KEY');
|
||||
});
|
||||
|
||||
test('bug-reporter env-var typo case: OPENAPI_API_KEY → OPENAI_API_KEY', () => {
|
||||
expect(suggestNearest('OPENAPI_API_KEY', ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOYAGE_API_KEY']))
|
||||
.toBe('OPENAI_API_KEY');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user