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:
Garry Tan
2026-05-21 11:19:27 -07:00
parent 772253ef44
commit 960bd68bfc
4 changed files with 618 additions and 0 deletions
+286
View File
@@ -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.`,
};
}
+73
View File
@@ -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;
}
+163
View File
@@ -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);
});
});
+96
View File
@@ -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');
});
});