mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* 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>
191 lines
8.0 KiB
TypeScript
191 lines
8.0 KiB
TypeScript
/**
|
|
* v0.41.6.0 D1 — embedding credential preflight.
|
|
*
|
|
* Pure-function tests; uses the gateway's configureGateway / resetGateway
|
|
* test seam to drive different recipe / env shapes without touching
|
|
* process.env.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
|
|
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
|
import {
|
|
validateEmbeddingCreds,
|
|
formatEmbeddingCredsError,
|
|
EmbeddingCredentialError,
|
|
} from '../src/core/embed-preflight.ts';
|
|
import type { AIGatewayConfig } from '../src/core/ai/types.ts';
|
|
|
|
// This file calls configureGateway() to drive credential-validation
|
|
// scenarios. configureGateway mutates module-level gateway state (_config).
|
|
// beforeEach resets BEFORE each test, but the LAST test leaves its config
|
|
// behind — and bun runs every file in a shard inside ONE process, so that
|
|
// residue (e.g. OPENAI_API_KEY: 'sk-test') bleeds into the next file's
|
|
// isAvailable('embedding') check. That's what made facts-backstop-gating
|
|
// fail intermittently (bin-pack-dependent) on CI shard 10.
|
|
//
|
|
// Don't end on a bare resetGateway() either: the NEXT file's beforeAll
|
|
// (often engine.initSchema, which sizes vector columns from ambient gateway
|
|
// state) runs before the legacy-embedding-preload's per-test restore, so a
|
|
// null gateway here would seed 1280-d schemas under 1536-d fixtures.
|
|
// Restore the preload's legacy pin instead.
|
|
afterAll(() => {
|
|
resetGateway();
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { ...process.env },
|
|
});
|
|
});
|
|
|
|
function baseConfig(overrides: Partial<AIGatewayConfig> = {}): AIGatewayConfig {
|
|
return {
|
|
embedding_model: 'openai:text-embedding-3-small',
|
|
embedding_dimensions: 1536,
|
|
chat_model: 'anthropic:claude-sonnet-4-6',
|
|
expansion_model: 'anthropic:claude-haiku-4-5',
|
|
env: {},
|
|
base_urls: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('formatEmbeddingCredsError — user_provided_dims_unset (#1292/D6)', () => {
|
|
test('names the dimension fix, not a model fix', () => {
|
|
const msg = formatEmbeddingCredsError({
|
|
ok: false,
|
|
reason: 'user_provided_dims_unset',
|
|
model: 'litellm:bge-large',
|
|
provider: 'litellm',
|
|
recipeId: 'litellm',
|
|
});
|
|
expect(msg).toMatch(/dimension/i);
|
|
// Points at the ACCEPTED remediation, not the hard-rejected `config set`
|
|
// (config.ts refuses to write embedding_dimensions — a schema-sizing field).
|
|
expect(msg).toMatch(/gbrain init --embedding-dimensions/);
|
|
expect(msg).not.toMatch(/config set embedding_dimensions/);
|
|
});
|
|
});
|
|
|
|
describe('validateEmbeddingCreds', () => {
|
|
beforeEach(() => { resetGateway(); });
|
|
|
|
test('passes when OPENAI_API_KEY is present and openai model is configured', () => {
|
|
configureGateway(baseConfig({ env: { OPENAI_API_KEY: 'sk-test' } }));
|
|
expect(() => validateEmbeddingCreds()).not.toThrow();
|
|
});
|
|
|
|
test('throws EmbeddingCredentialError with reason=missing_env when OPENAI_API_KEY is unset', () => {
|
|
configureGateway(baseConfig({ env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
expect(e.diagnosis.ok).toBe(false);
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('missing_env');
|
|
if (e.diagnosis.reason === 'missing_env') {
|
|
expect(e.diagnosis.missingEnvVars).toEqual(['OPENAI_API_KEY']);
|
|
expect(e.diagnosis.provider).toBe('openai');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('throws missing_env for voyage when VOYAGE_API_KEY is unset', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'voyage:voyage-3-large', env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok && e.diagnosis.reason === 'missing_env') {
|
|
expect(e.diagnosis.missingEnvVars).toEqual(['VOYAGE_API_KEY']);
|
|
expect(e.diagnosis.provider).toBe('voyage');
|
|
} else { expect('expected missing_env').toBe(JSON.stringify(e.diagnosis)); }
|
|
});
|
|
|
|
test('throws missing_env for google when GOOGLE_GENERATIVE_AI_API_KEY is unset', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'google:text-embedding-004', env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok && e.diagnosis.reason === 'missing_env') {
|
|
expect(e.diagnosis.missingEnvVars).toEqual(['GOOGLE_GENERATIVE_AI_API_KEY']);
|
|
} else { expect('expected missing_env').toBe(JSON.stringify(e.diagnosis)); }
|
|
});
|
|
|
|
test('throws no_touchpoint when configured embedding_model points at anthropic', () => {
|
|
configureGateway(baseConfig({
|
|
embedding_model: 'anthropic:claude-3-5-sonnet',
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
|
|
}));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('no_touchpoint');
|
|
}
|
|
});
|
|
|
|
test('throws unknown_provider when embedding_model uses unknown provider', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'fakeprovider:embed-1', env: {} }));
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('unknown_provider');
|
|
}
|
|
});
|
|
|
|
test('throws no_gateway_config when gateway was not configured', () => {
|
|
// resetGateway() in beforeEach already cleared _config.
|
|
let caught: unknown;
|
|
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
|
|
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
|
|
const e = caught as EmbeddingCredentialError;
|
|
if (!e.diagnosis.ok) {
|
|
expect(e.diagnosis.reason).toBe('no_gateway_config');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('formatEmbeddingCredsError', () => {
|
|
beforeEach(() => { resetGateway(); });
|
|
|
|
test('missing_env produces paste-ready hint naming the env var + --no-embed option', () => {
|
|
configureGateway(baseConfig({ env: {} }));
|
|
let e: EmbeddingCredentialError;
|
|
try { validateEmbeddingCreds(); throw new Error('expected throw'); }
|
|
catch (err) { e = err as EmbeddingCredentialError; }
|
|
expect(e!.userMessage).toContain('OPENAI_API_KEY');
|
|
expect(e!.userMessage).toContain('--no-embed');
|
|
expect(e!.userMessage).toContain('export OPENAI_API_KEY');
|
|
});
|
|
|
|
test('openai-missing message suggests switching to voyage (not openai)', () => {
|
|
configureGateway(baseConfig({ env: {} }));
|
|
let e: EmbeddingCredentialError;
|
|
try { validateEmbeddingCreds(); throw new Error('expected throw'); }
|
|
catch (err) { e = err as EmbeddingCredentialError; }
|
|
// Don't tell user to switch to the provider they already have.
|
|
expect(e!.userMessage).toContain('voyage');
|
|
expect(e!.userMessage).not.toMatch(/Switch providers:.*openai:/);
|
|
});
|
|
|
|
test('voyage-missing message suggests switching to openai', () => {
|
|
configureGateway(baseConfig({ embedding_model: 'voyage:voyage-3-large', env: {} }));
|
|
let e: EmbeddingCredentialError;
|
|
try { validateEmbeddingCreds(); throw new Error('expected throw'); }
|
|
catch (err) { e = err as EmbeddingCredentialError; }
|
|
expect(e!.userMessage).toContain('VOYAGE_API_KEY');
|
|
expect(e!.userMessage).toContain('openai:text-embedding-3-small');
|
|
});
|
|
|
|
test('no_model_configured returns empty-string for ok diagnosis', () => {
|
|
configureGateway(baseConfig({ env: { OPENAI_API_KEY: 'sk-test' } }));
|
|
expect(formatEmbeddingCredsError({
|
|
ok: true, model: 'openai:text-embedding-3-small', provider: 'openai', recipeId: 'openai',
|
|
})).toBe('');
|
|
});
|
|
});
|