Files
gbrain/test/embedding-dim-check.test.ts
T
a25209bbb2 v0.42.58.0 fix(ai): provider-agnostic gateway — env clobber, base-URL /v1, embedding dims (#1249 #1250 #1292 #2271 #2209) (#2627)
* fix(ai): drop empty-string env values before merge so they can't clobber config keys (#1249)

Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls; an
unconditional process.env spread let that empty string override a valid
config.json key, breaking every gateway op with NO_ANTHROPIC_API_KEY. Filter
'' / undefined before the merge; '0' and 'false' are preserved.

* fix(ai): normalize native provider base URLs + replace embedding guard with a dims-presence check (#1250, #1292)

#1250: createAnthropic/createOpenAI were called with no baseURL, so an
env-injected bare host (e.g. ANTHROPIC_BASE_URL without /v1) 404'd. Add a
shared resolveNativeBaseUrl and pass a normalized baseURL at all anthropic +
openai native sites (google deferred until its suffix is verified).

#1292/D6: the user_provided_model_unset guard was structurally unreachable as
a no-model check (parseModelId throws on a bare provider) and only ever
false-positived for litellm:<model>, silently disabling vector search. Replace
it with a real dims-presence check for user-provided/zero-default recipes and
delete the dead branch in both consumers. Also stop configureGateway from
fabricating a default embedding_dimensions, so 'no dims set' stays honest.

* fix(ai): trust user-declared embedding dims for local recipes + litellm /v1 hint (#2271, #2209)

#2271: a new trust_custom_dims flag adds a passthrough tier so ollama /
llama-server / litellm accept a user-supplied --embedding-dimensions instead of
being hard-rejected. Fail-closed for fixed-dim providers (openai/voyage/
zeroentropy) and excludes openrouter (declares dims_options). Register modern
ollama embed model names.

#2209: litellm setup_hint now states the /v1 path convention and the docs
pointer is corrected to docs/integrations/embedding-providers.md.

* docs+test(ai): KEY_FILES current-state for provider-agnostic gateway + embed-preflight dims-unset test (#1249, #1250, #1292)

* fix(ai): point user_provided_dims_unset remediation at 'gbrain init' (config set rejects it) + coverage

Pre-landing adversarial review (P1): the new dims-unset guard told users to run
'gbrain config set embedding_dimensions <N>', which config.ts hard-rejects (it's a
schema-sizing field). Both consumer messages now point at 'gbrain init
--embedding-dimensions'. Adds: pgvector-cap-still-fires regression for the
trust_custom_dims passthrough, and a configureGateway backfill-invariant test.

* chore: bump version and changelog (v0.42.57.0)

Provider-agnostic plumbing wave: #1249 empty-env clobber, #1250 native baseURL
normalization, #1292 embedding dims-presence guard, #2271 trust_custom_dims
passthrough, #2209 litellm /v1 hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: sync embedding-providers guide for provider-agnostic gateway wave (v0.42.57.0)

Post-ship doc drift fix for the v0.42.57.0 AI-gateway wave:
- LiteLLM section now names the /v1 base-URL convention (#2209).
- Ollama section lists the newly-registered modern embedders qwen3-embed-8b
  + snowflake-arctic-embed-l-v2, and notes dims-trust for local recipes (#2271).
- llama-server section notes gbrain trusts the user-declared dimension (#2271).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: post-ship doc sweep for v0.42.57.0 provider-agnostic gateway wave

- KEY_FILES.md types.ts entry: document EmbeddingTouchpoint.trust_custom_dims
  (#2271 passthrough tier, runs after dims_options + Matryoshka allowlists)
- ENGINES.md: embedding design-choice note now names the provider-agnostic
  gateway delegation instead of the stale OpenAI-only parenthetical
- embedding-providers.md: drop an exact-duplicate doctor-8c paragraph
- llms-full.txt regenerated (ENGINES.md is inlined in the bundle)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: apply codex doc-review findings for v0.42.57.0 (base-URL env note, litellm multimodal)

- embedding-providers.md OpenAI section: document OPENAI_BASE_URL /
  ANTHROPIC_BASE_URL bare-host /v1 normalization (#1250 user-facing surface)
- TL;DR table: litellm multimodal is backend-permitting (recipe declares
  supports_multimodal: true, routed via the openai-compat multimodal path),
  not "no"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin engine-find-trajectory schema to 1536 + stop gateway-state leaks across shard files

CI shard 5 failed 7 findTrajectory tests with 'expected 1280 dimensions, not
1536': engine-find-trajectory hardcodes 1536-d vectors but sizes its schema
from AMBIENT gateway state in beforeAll — which runs before the
legacy-embedding-preload's per-test 1536 restore. A preceding file that ends
with a dimensionless configureGateway (facts-extract-silent-no-op) or a bare
resetGateway poisons the next fresh initSchema down to 1280-d columns. The
new test files in this PR reshuffled shard bin-packing and exposed the trap.

- engine-find-trajectory: pin OpenAI/1536 explicitly before initSchema (the
  pattern bunfig's preload documents) — deterministic regardless of neighbors
- facts-extract-silent-no-op, diagnose-embedding-dims, embed-preflight:
  restore the legacy 1536 pin in afterAll instead of ending reset/dimensionless

Reproduced: synthetic dimensionless-gateway file + old victim = the exact 7
CI failures; with the pin = 0. Verified in-process pair runs both orders.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:05:23 +09:00

405 lines
16 KiB
TypeScript

/**
* v0.28.5 (A4) — Existing-brain dimension-mismatch detection unit tests.
*
* Pairs with `gbrain init` and `gbrain doctor`'s loud-failure paths. Validates
* that:
* 1. readContentChunksEmbeddingDim correctly reports null on a fresh brain.
* 2. After initSchema, it returns the actual templated dim (1536 default).
* 3. embeddingMismatchMessage produces a recipe that explicitly drops the
* HNSW index, alters the column, wipes embeddings, and conditionally
* reindexes — codex's #8 finding from plan review.
*/
import { test, expect, describe, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import {
readContentChunksEmbeddingDim,
embeddingMismatchMessage,
resolveSchemaEmbeddingDim,
resolveSchemaMultimodalDim,
PGVECTOR_COLUMN_MAX_DIMS,
} from '../src/core/embedding-dim-check.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
// Canonical pattern: single engine per file, init once, disconnect once.
// The two tests below diverge in whether they want a migrated brain or a
// pre-initSchema brain — handled by inline reset / second-engine instead of
// resetting in beforeEach (keeps the migrated state cached for the LATEST case).
let engine: PGLiteEngine;
beforeAll(async () => {
// Hermeticity guard (cross-file gateway-state leak class — see CLAUDE.md
// "Test-isolation lint and helpers"). initSchema builds the
// content_chunks vector column at the gateway's configured dim. The
// bunfig preload pins OpenAI/1536, but its beforeEach only re-applies
// legacy when the gateway was RESET (throws) — it does NOT correct a
// sibling that configured a different LIVE dim (e.g. ZE/1280) and never
// reset. Under weight-based shard bin-packing, such a sibling can run
// first, so pin 1536 explicitly here BEFORE initSchema (this is exactly
// the "call configureGateway() in your own beforeAll" escape hatch the
// preload documents). Reset in afterAll so we don't leak 1536 onward.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
resetGateway();
});
describe('readContentChunksEmbeddingDim', () => {
test('returns dims from a migrated brain (1536d via legacy-embedding preload)', async () => {
// v0.37 fix wave: the canonical gateway default is now 1280 (ZE).
// However, `bunfig.toml` preloads `test/helpers/legacy-embedding-preload.ts`
// which configures the gateway to OpenAI/1536 BEFORE any test runs.
// This preserves the 20+ test files with hardcoded 1536-d
// Float32Array fixtures. So initSchema() under tests produces a
// 1536-d column.
//
// New v0.37 tests that need to assert the ZE/1280 default can call
// configureGateway() explicitly in their own beforeAll, which
// overrides the preload.
const result = await readContentChunksEmbeddingDim(engine);
expect(result.exists).toBe(true);
expect(result.dims).toBe(1536);
}, 30000);
test('returns { exists: false, dims: null } on a fresh brain (no initSchema)', async () => {
// One-off engine for the fresh-brain case. Never call initSchema so
// content_chunks doesn't exist yet. Cleaned up at end of test.
const fresh = new PGLiteEngine();
await fresh.connect({});
try {
const result = await readContentChunksEmbeddingDim(fresh);
expect(result.exists).toBe(false);
expect(result.dims).toBeNull();
} finally {
await fresh.disconnect();
}
}, 30000);
});
describe('embeddingMismatchMessage', () => {
test('Postgres branch inlines all four recipe steps for HNSW-eligible dims', () => {
const msg = embeddingMismatchMessage({
currentDims: 1536,
requestedDims: 768,
requestedModel: 'nomic-embed-text',
source: 'init',
engineKind: 'postgres',
});
expect(msg).toContain('vector(1536)');
expect(msg).toContain('vector(768)');
expect(msg).toContain('DROP INDEX IF EXISTS idx_chunks_embedding');
expect(msg).toContain('ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(768)');
expect(msg).toContain('UPDATE content_chunks SET embedding = NULL');
expect(msg).toContain('CREATE INDEX IF NOT EXISTS idx_chunks_embedding');
expect(msg).toContain('docs/embedding-migrations.md');
});
test('Postgres recipe NULLs embeddings BEFORE the column alter (pgvector refuses cross-dim casts)', () => {
// pgvector aborts `ALTER COLUMN TYPE vector(N)` with "expected N
// dimensions, not M" while rows still hold old-width vectors — which is
// every brain running this recipe. The UPDATE must precede the ALTER
// (NULLs cast fine). Order pinned so the printed recipe can't drift from
// the corrected docs/embedding-migrations.md again.
const msg = embeddingMismatchMessage({
currentDims: 1536,
requestedDims: 768,
requestedModel: 'nomic-embed-text',
source: 'init',
engineKind: 'postgres',
});
const nullIdx = msg.indexOf('UPDATE content_chunks SET embedding = NULL');
const alterIdx = msg.indexOf('ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(768)');
expect(nullIdx).toBeGreaterThan(-1);
expect(alterIdx).toBeGreaterThan(-1);
expect(nullIdx).toBeLessThan(alterIdx);
});
test('Postgres branch skips HNSW recreate when requested dims exceed pgvector cap', () => {
// Codex finding #8: 2048d (Voyage 4 Large) cannot be HNSW-indexed in pgvector.
// The recipe must NOT instruct a CREATE INDEX HNSW for that dim.
const msg = embeddingMismatchMessage({
currentDims: 1536,
requestedDims: 2048,
requestedModel: 'voyage-4-large',
source: 'init',
engineKind: 'postgres',
});
expect(msg).toContain('vector(2048)');
expect(msg).toContain('Skip reindex');
expect(msg).toContain("exceeds pgvector's HNSW cap");
// The HNSW CREATE INDEX line must NOT appear in the 2048d recipe.
expect(msg).not.toContain('CREATE INDEX IF NOT EXISTS idx_chunks_embedding\n ON content_chunks USING hnsw');
});
test('source: doctor uses a different header than source: init', () => {
const initMsg = embeddingMismatchMessage({ currentDims: 1536, requestedDims: 768, source: 'init', engineKind: 'postgres' });
const doctorMsg = embeddingMismatchMessage({ currentDims: 1536, requestedDims: 768, source: 'doctor', engineKind: 'postgres' });
expect(initMsg).toContain('Refusing to silently re-template');
expect(doctorMsg).toContain('Embedding dimension mismatch detected');
});
// v0.37 fix wave Lane D.1: PGLite branch uses wipe-and-reinit recipe
// because PGLite can't ALTER vector column types.
test('PGLite branch uses wipe-and-reinit, not ALTER COLUMN', () => {
const msg = embeddingMismatchMessage({
currentDims: 1536,
requestedDims: 1280,
requestedModel: 'zeroentropyai:zembed-1',
source: 'init',
engineKind: 'pglite',
databasePath: '/tmp/test-brain.pglite',
});
expect(msg).toContain('vector(1536)');
expect(msg).toContain('vector(1280)');
expect(msg).toContain('mv /tmp/test-brain.pglite /tmp/test-brain.pglite.bak');
expect(msg).toContain('gbrain init --pglite --embedding-model zeroentropyai:zembed-1 --embedding-dimensions 1280');
expect(msg).toContain('PGLite cannot ALTER vector column types');
// Must NOT contain the Postgres-only SQL recipe.
expect(msg).not.toContain('ALTER TABLE content_chunks ALTER COLUMN');
expect(msg).not.toContain('DROP INDEX IF EXISTS idx_chunks_embedding');
});
test('PGLite branch falls back to default database path when omitted', () => {
const msg = embeddingMismatchMessage({
currentDims: 1536,
requestedDims: 1280,
source: 'init',
engineKind: 'pglite',
});
// Default falls back to gbrainPath('brain.pglite').
expect(msg).toMatch(/mv .+brain\.pglite .+brain\.pglite\.bak/);
});
test('PGLite branch must NOT recommend `gbrain config set embedding_model` (no-op after Lane C.2)', () => {
const msg = embeddingMismatchMessage({
currentDims: 1536,
requestedDims: 1280,
requestedModel: 'zeroentropyai:zembed-1',
source: 'doctor',
engineKind: 'pglite',
});
// The pre-v0.37 recipe pointed at `gbrain config set embedding_model X`
// which is a no-op after C.2. Recipe must point at init instead.
expect(msg).not.toContain('gbrain config set embedding_model');
expect(msg).not.toContain('gbrain config set embedding_dimensions');
});
});
// ============================================================================
// 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);
});
// #2271 — trust_custom_dims passthrough for local / BYO-backend recipes.
test('ollama accepts a custom dim for a modern model via trust_custom_dims', () => {
const got = resolveSchemaEmbeddingDim({
embedding_model: 'ollama:qwen3-embed-8b',
embedding_dimensions: 4096,
});
expect(got.ok).toBe(true);
if (got.ok) expect(got.dim).toBe(4096);
});
test('litellm accepts a user-declared custom dim via trust_custom_dims', () => {
const got = resolveSchemaEmbeddingDim({
embedding_model: 'litellm:bge-large',
embedding_dimensions: 1024,
});
expect(got.ok).toBe(true);
if (got.ok) expect(got.dim).toBe(1024);
});
test('llama-server accepts a user-declared custom dim via trust_custom_dims', () => {
const got = resolveSchemaEmbeddingDim({
embedding_model: 'llama-server:nomic-embed-text-v1.5',
embedding_dimensions: 2560,
});
expect(got.ok).toBe(true);
if (got.ok) expect(got.dim).toBe(2560);
});
test('[REGRESSION] openrouter (declares dims_options, NOT flagged) still fail-closed on an unlisted dim', () => {
const got = resolveSchemaEmbeddingDim({
embedding_model: 'openrouter:openai/text-embedding-3-small',
embedding_dimensions: 4096,
});
expect(got.ok).toBe(false);
if (!got.ok) expect(got.error).toMatch(/rejects custom dimensions 4096|does not support custom dimensions/);
});
test('[REGRESSION] trust_custom_dims does NOT bypass the pgvector column cap', () => {
// The passthrough tier trusts the user's dim, but the pgvector cap check runs
// BEFORE it — pin that ordering so a future refactor can't let an oversized
// dim through for a trusted local recipe.
const got = resolveSchemaEmbeddingDim({
embedding_model: 'ollama:qwen3-embed-8b',
embedding_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1,
});
expect(got.ok).toBe(false);
if (!got.ok) expect(got.error).toMatch(/exceed pgvector|column cap/i);
});
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);
});
});