mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
Merge remote-tracking branch 'origin/master' into garrytan/cathedral-2
# Conflicts: # CHANGELOG.md # TODOS.md # VERSION # package.json
This commit is contained in:
@@ -38,6 +38,22 @@ Every scoreboard row carries a `seam` label: the `openclaw` row exercises the sh
|
||||
- CLI exit codes for the new command route through the shared write-fence + aliveness-grace exit seam, so PGLite's WASM exit-code stomping and Bun's exit-time stdout discard can't corrupt the CI contract.
|
||||
|
||||
To take advantage of v0.44.0.0: run `gbrain eval brainbench` — no setup, no keys, no brain required. If it ever reports something broken after an upgrade, `bun evals/brainbench/generator/gen.ts` rebuilds the corpus byte-identically and `gbrain eval brainbench --update-baseline` re-derives the baseline from an actual run; both are safe to re-run any time.
|
||||
## [0.42.58.0] - 2026-07-06
|
||||
|
||||
**gbrain now runs cleanly on the stack you already have — a local Ollama box, a self-hosted LiteLLM proxy, llama.cpp's llama-server, or gbrain running as a Claude Code MCP subprocess — instead of silently degrading or hard-failing when you're not on a raw OpenAI/Anthropic key.** A provider-agnostic plumbing pass across the AI gateway: environment handling, base-URL normalization, and embedding-dimension validation all stop tripping on the non-frontier-vendor setups that used to fail without a clear signal.
|
||||
|
||||
### Fixed
|
||||
- **Running gbrain as a Claude Code MCP subprocess no longer breaks every AI call.** Some hosts inject an empty `ANTHROPIC_API_KEY` into the subprocess environment; that empty value used to override a real key set in your `~/.gbrain/config.json`, so every gateway call failed with a missing-key error. Empty environment values no longer clobber a configured key. (#1249)
|
||||
- **A custom Anthropic or OpenAI base URL without a `/v1` suffix no longer 404s.** When the base URL comes from the environment as a bare host, gbrain normalizes it before the call instead of posting to a path the provider doesn't serve. Unset base URLs are untouched, so the default hosted endpoints are unaffected. (#1250)
|
||||
- **Vector search stops silently going dark on a LiteLLM or llama-server embedding model.** A model-availability check was rejecting user-provided embedding recipes even when a model was configured, quietly disabling vector search so results looked empty. The check now validates what actually matters — that a dimension is set — and reports a clear, actionable message when it isn't. (#1292, #2295)
|
||||
- **Local embedding models with non-standard dimensions are accepted.** Ollama, llama-server, and LiteLLM models (e.g. modern 1024- or 4096-dimension embedders) no longer get hard-rejected by the dimension validator; you declare the dimension and gbrain trusts it. Hosted fixed-dimension providers stay strictly validated. Modern Ollama embed models are recognized. (#2271)
|
||||
|
||||
### Changed
|
||||
- **LiteLLM setup guidance now names the `/v1` path convention** so OpenAI-shaped proxies that only serve the `/v1` route don't fail authentication with no hint. (#2209)
|
||||
|
||||
### To take advantage of v0.42.58.0
|
||||
`gbrain upgrade`. If you run on Ollama, a LiteLLM proxy, llama-server, or as a Claude Code MCP subprocess, the fixes apply automatically — no migration, no config change. If you use a user-provided embedding recipe (LiteLLM / llama-server) and see a "no default embedding dimension" message, set it with `gbrain init --embedding-dimensions <N>`.
|
||||
|
||||
## [0.42.57.0] - 2026-07-02
|
||||
|
||||
**PGLite incident fix: a busy `gbrain dream` (or `embed`) could have its data-directory lock stolen and get its brain corrupted beyond in-place repair. The lock will no longer be taken from a process that is alive, and an already-corrupted store now tells you exactly how to recover.**
|
||||
|
||||
@@ -16,6 +16,47 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
|
||||
- [ ] **Periodic re-baselining (the ratchet doesn't auto-tighten).** Improvements aren't banked into master's baseline until a PR updates it, so a regression back to a stale baseline level passes. Documented as an accepted residual in `docs/eval/BRAINBENCH.md`; the fix is an operator habit or a scheduled job that re-runs `--update-baseline` after metric-improving merges. Priority: P3.
|
||||
|
||||
- [ ] **Hermetic-ize the 7 env-sensitive LLM-availability tests.** `test/think-gateway-adapter.test.ts`, `test/conversation-parser/llm-base.test.ts`/`llm-fallback.test.ts`, `test/doctor-ze-checks.test.ts` assert behavior "when ANTHROPIC_API_KEY is unset" by reading the live process env — they fail on any dev shell that exports provider keys (verified failing on clean master in such a shell; green in keyless CI). Stub/save-restore the env per test so local runs match CI. Priority: P2.
|
||||
## provider-agnostic follow-ups (filed v0.42.58.0)
|
||||
|
||||
Deferred from the provider-agnostic plumbing wave (#1249/#1250/#1292/#2271/#2209).
|
||||
Plan + review trail at `~/.claude/plans/system-instruction-you-are-working-keen-newell.md`.
|
||||
The eng-review + Codex outside-voice narrowed the wave to these deferrals:
|
||||
|
||||
- [ ] **P2 — Capability-aware query expansion on OpenAI-compat providers (#2372).**
|
||||
Expansion only runs for recipes that declare an `expansion` touchpoint, and only the
|
||||
native providers (anthropic/openai/google) do. To make expansion work on
|
||||
litellm/openrouter/groq/together/deepseek you must ADD expansion touchpoints to those
|
||||
chat-capable recipes AND add a `generateObject`→`generateText` capability fallback for
|
||||
backends without strict structured outputs. Feature-shaped; overlaps the general
|
||||
OpenAI-compat proxy story (`docs/designs/COMMUNITY_IDEAS.md`). Community PR #2373 is a
|
||||
starting point. Where: `src/core/ai/gateway.ts:expand`, recipe files, `types.ts` (ExpansionTouchpoint).
|
||||
- [ ] **P2 — LiteLLM as a chat/expansion backend.** `litellm-proxy` declares ONLY an
|
||||
embedding touchpoint, so `think`/chat on LiteLLM is dead. Add chat (and expansion) so a
|
||||
LiteLLM proxy is a full LLM backend, not embedding-only. The general OpenAI-compat proxy story.
|
||||
- [ ] **P3 — Per-model embedding dims metadata on `EmbeddingTouchpoint`.** `default_dims`
|
||||
is recipe-wide, so a recipe (ollama) can't carry different native dims per model. This
|
||||
wave added the modern ollama model NAMES + a `trust_custom_dims` passthrough (user supplies
|
||||
`--embedding-dimensions`); per-model dims would let gbrain pick the right default. Then
|
||||
ollama could fail-closed at preflight like litellm/llama-server instead of at first embed.
|
||||
- [ ] **P3 — Google native baseURL normalization (#1250 follow-up).** `resolveNativeBaseUrl`
|
||||
covers anthropic + openai; Google was deferred because Gemini's native suffix is unproven
|
||||
(its OpenAI-compat route is `/v1beta/openai`). Verify the correct `@ai-sdk/google` suffix,
|
||||
then add `google` to the helper. Where: `src/core/ai/gateway.ts:resolveNativeBaseUrl`.
|
||||
- [ ] **P3 — Fold Voyage/Google/LiteLLM/OpenRouter API keys into `buildGatewayConfig`.**
|
||||
It folds only OPENAI/ANTHROPIC/ZEROENTROPY file-plane keys today, so `config.json`-set keys
|
||||
for other providers only work if also in `process.env`. Extend the mapping. Where:
|
||||
`src/core/ai/build-gateway-config.ts`.
|
||||
- [ ] **P3 — OpenRouter per-model custom-dim handling.** OpenRouter declares recipe-wide
|
||||
`dims_options` and mixes fixed-dim + arbitrary models, so it's excluded from `trust_custom_dims`.
|
||||
A per-model story would let OpenRouter accept custom dims for models that support them.
|
||||
- [ ] **P1 — Gateway subagent-loop tool-result persistence + Date normalization (#2273/#2256).**
|
||||
Confirmed crash-block: non-Anthropic subagent jobs dead-letter after any interruption
|
||||
(tool-result user turns aren't persisted; raw Date values fail the AI SDK's strict JSON
|
||||
check). Larger self-contained change with 6 competing community PRs
|
||||
(#2274/#2257/#1934/#2065/#2112/#2336) — pick one canonical impl, preserve authorship.
|
||||
This is the immediate fast-follow to the provider-agnostic wave. Where:
|
||||
`src/core/ai/gateway.ts:toolLoop`/`toModelMessages`, `src/core/minions/handlers/subagent.ts`.
|
||||
|
||||
## Life Chronicle follow-ups (filed v0.42.56.0, #2390)
|
||||
|
||||
Deferred from the Life Chronicle wave (CEO Scope-Expansion + eng review CLEARED,
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ export interface BrainEngine {
|
||||
|
||||
**Slug-based API, not ID-based.** Every method takes slugs, not numeric IDs. The engine resolves slugs to IDs internally. This keeps the interface portable... slugs are strings, IDs are database-specific.
|
||||
|
||||
**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that. This is intentional: embedding is an external API call (OpenAI), not a storage concern. All engines share the same embedding service.
|
||||
**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that (a thin delegation to the provider-agnostic AI gateway in `src/core/ai/gateway.ts`). This is intentional: embedding is an external API call (OpenAI, Voyage, a local Ollama — whichever provider you configured), not a storage concern. All engines share the same embedding service.
|
||||
|
||||
**Chunking is NOT in the engine.** Same logic. `src/core/chunkers/` handles chunking. The engine stores and retrieves chunks. All engines share the same chunkers.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,7 +34,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
|
||||
| `zhipu` | `ZHIPUAI_API_KEY` | 1024 | varies | no | no |
|
||||
| `ollama` | (none — runs locally) | 768 | 0 | yes | no |
|
||||
| `llama-server` | (none — runs locally) | user-set | 0 | yes | no |
|
||||
| `litellm` | `LITELLM_API_KEY` (optional) | user-set | varies | yes (proxy) | no |
|
||||
| `litellm` | `LITELLM_API_KEY` (optional) | user-set | varies | yes (proxy) | yes (backend permitting) |
|
||||
| `together` | `TOGETHER_API_KEY` | 768 | varies | no | no |
|
||||
| `anthropic` | (no embedding model — chat only) | — | — | — | — |
|
||||
| `deepseek` | (no embedding model — chat only) | — | — | — | — |
|
||||
@@ -77,6 +77,8 @@ The doctor distinguishes two repair paths:
|
||||
|
||||
Default. Set `OPENAI_API_KEY`. Models: `text-embedding-3-large` (3072 max, 1536 default), `text-embedding-3-small` (1536). Matryoshka via the `dimensions` field — gbrain pins it from `embedding_dimensions` config so existing 1536-dim brains stay aligned across SDK upgrades.
|
||||
|
||||
Optional `OPENAI_BASE_URL` — point the native OpenAI provider at an OpenAI-compatible gateway. A bare host is normalized to carry the `/v1` suffix automatically (so `https://gw.example.com` and `https://gw.example.com/v1` both work); when unset, the SDK's default endpoint is untouched. `ANTHROPIC_BASE_URL` gets the same normalization for Anthropic chat/expansion calls.
|
||||
|
||||
### Voyage AI
|
||||
|
||||
Best-in-class quality on the Voyage 4 family (Jan 2026 release). Set `VOYAGE_API_KEY`. Models: `voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-4-nano`, `voyage-3.5`, `voyage-code-3` (code-tuned), `voyage-finance-2`, `voyage-law-2`, `voyage-multimodal-3` (text + image).
|
||||
@@ -141,13 +143,15 @@ Set `ZHIPUAI_API_KEY`. Models: `embedding-3` (current; Matryoshka 256-2048 dims)
|
||||
|
||||
No env required — Ollama runs unauthenticated locally. Optional `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`) and `OLLAMA_API_KEY` (for auth-enabled deployments).
|
||||
|
||||
Recipe ships with `nomic-embed-text` (768d, recommended), `mxbai-embed-large` (1024d), `all-minilm` (384d). `gbrain providers test --model ollama:nomic-embed-text` smoke-tests the local install.
|
||||
Recipe ships with `nomic-embed-text` (768d, recommended), `mxbai-embed-large` (1024d), `all-minilm` (384d), plus the larger modern embedders `qwen3-embed-8b` (4096d) and `snowflake-arctic-embed-l-v2` (1024d). `gbrain providers test --model ollama:nomic-embed-text` smoke-tests the local install.
|
||||
|
||||
The recipe default is `nomic-embed-text`'s 768 dims. If you run one of the larger models, declare its native dimension with `--embedding-dimensions <N>` at init — gbrain trusts the value you declare for local recipes instead of rejecting a non-768 width.
|
||||
|
||||
### llama-server (local, llama.cpp)
|
||||
|
||||
`llama.cpp`'s `llama-server --embeddings` endpoint. No env required. Optional `LLAMA_SERVER_BASE_URL` (default `http://localhost:8080/v1`) and `LLAMA_SERVER_API_KEY`.
|
||||
|
||||
User-driven models: launch llama-server with `--model <gguf-path> --embeddings`, then run `gbrain init --embedding-model llama-server:<your-id> --embedding-dimensions <N>`. The recipe refuses the implicit shorthand `--model llama-server` because there's no canonical first model.
|
||||
User-driven models: launch llama-server with `--model <gguf-path> --embeddings`, then run `gbrain init --embedding-model llama-server:<your-id> --embedding-dimensions <N>`. gbrain trusts the dimension you declare (you know the GGUF you launched); the recipe refuses the implicit shorthand `--model llama-server` because there's no canonical first model.
|
||||
|
||||
### LiteLLM proxy (universal escape hatch)
|
||||
|
||||
@@ -155,6 +159,8 @@ Run [LiteLLM](https://docs.litellm.ai/docs/proxy/quick_start) in front of any pr
|
||||
|
||||
This is the catch-all for "my provider isn't in the list above." Set up LiteLLM, then `gbrain init --embedding-model litellm:<your-model-id> --embedding-dimensions <N>`.
|
||||
|
||||
**Include the `/v1` suffix in `LITELLM_BASE_URL` if your proxy serves the OpenAI route there** (e.g. `http://localhost:4000/v1`). Many LiteLLM deployments expose the OpenAI-compatible API only under `/v1`; pointing gbrain at the bare host 404s or fails authentication with no hint. gbrain trusts the dimension you declare for the proxy-backed model — the proxy's backend, not gbrain, decides the true width — so `--embedding-dimensions <N>` is required and accepted as-is.
|
||||
|
||||
## Choosing dimensions
|
||||
|
||||
Three numbers matter:
|
||||
@@ -183,5 +189,3 @@ The supported paths:
|
||||
- **Postgres (Supabase / self-hosted):** follow the SQL recipe in `docs/embedding-migrations.md` (drop the HNSW index, ALTER COLUMN TYPE, clear stale embeddings, recreate the index conditionally, then `gbrain init --supabase --embedding-model X --embedding-dimensions N` to update the file plane and re-embed).
|
||||
|
||||
`gbrain doctor` 8c "alternative_providers" surfaces unconfigured providers whose env is already set — useful when you've configured OpenAI but also have e.g. `VOYAGE_API_KEY` exported and want to know you can switch without extra setup.
|
||||
|
||||
`gbrain doctor` 8c "alternative_providers" surfaces unconfigured providers whose env is already set — useful when you've configured OpenAI but also have e.g. `VOYAGE_API_KEY` exported and want to know you can switch without extra setup.
|
||||
|
||||
+1
-1
@@ -2039,7 +2039,7 @@ export interface BrainEngine {
|
||||
|
||||
**Slug-based API, not ID-based.** Every method takes slugs, not numeric IDs. The engine resolves slugs to IDs internally. This keeps the interface portable... slugs are strings, IDs are database-specific.
|
||||
|
||||
**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that. This is intentional: embedding is an external API call (OpenAI), not a storage concern. All engines share the same embedding service.
|
||||
**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that (a thin delegation to the provider-agnostic AI gateway in `src/core/ai/gateway.ts`). This is intentional: embedding is an external API call (OpenAI, Voyage, a local Ollama — whichever provider you configured), not a storage concern. All engines share the same embedding service.
|
||||
|
||||
**Chunking is NOT in the engine.** Same logic. `src/core/chunkers/` handles chunking. The engine stores and retrieves chunks. All engines share the same chunkers.
|
||||
|
||||
|
||||
@@ -61,6 +61,18 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
|
||||
chat_model: c.chat_model,
|
||||
chat_fallback_chain: c.chat_fallback_chain,
|
||||
base_urls: { ...envBaseUrls, ...(c.provider_base_urls ?? {}) }, // config wins over env
|
||||
env: { ...envFromConfig, ...process.env }, // process.env wins
|
||||
// #1249: process.env still wins over the config-plane fallback, BUT only for
|
||||
// keys that carry a real value. Claude Code (and some launchers) inject
|
||||
// ANTHROPIC_API_KEY='' to neuter subprocess LLM calls; an unconditional
|
||||
// `...process.env` lets that empty string clobber a valid config.json key, so
|
||||
// every gateway op then throws NO_ANTHROPIC_API_KEY. Drop empty-string /
|
||||
// undefined entries before the merge. Only '' and undefined are dropped —
|
||||
// '0' and 'false' are legitimate values and survive.
|
||||
env: {
|
||||
...envFromConfig,
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, v]) => v !== undefined && v !== ''),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+63
-14
@@ -389,11 +389,47 @@ export function applyOpenAICompatConfig(
|
||||
return { baseURL };
|
||||
}
|
||||
|
||||
/**
|
||||
* #1250: native providers (anthropic/openai) are instantiated as
|
||||
* `create<Provider>({ apiKey })` with NO explicit baseURL, so the AI SDK reads
|
||||
* `<PROVIDER>_BASE_URL` verbatim. When a host injects a BARE base URL (Claude
|
||||
* Code sets `ANTHROPIC_BASE_URL=https://api.anthropic.com` with no `/v1`), the
|
||||
* SDK POSTs `<base>/messages` → 404 and every chat/expansion/embedding call
|
||||
* breaks. This normalizes a configured native base URL to carry the `/v1`
|
||||
* suffix and returns it so callers can pass it explicitly.
|
||||
*
|
||||
* Returns undefined when the env provides no base URL, so the SDK's own default
|
||||
* (which already includes `/v1`) is preserved untouched — the happy path is not
|
||||
* altered. Google is intentionally NOT handled here: its native suffix is
|
||||
* unproven (Gemini's OpenAI-compat route is `/v1beta/openai`), so it's deferred
|
||||
* to a follow-up rather than risk a regression on a wrong assumption.
|
||||
*
|
||||
* @internal exported for tests.
|
||||
*/
|
||||
export function resolveNativeBaseUrl(
|
||||
provider: 'anthropic' | 'openai',
|
||||
cfg: AIGatewayConfig,
|
||||
): string | undefined {
|
||||
const envKey = provider === 'anthropic' ? 'ANTHROPIC_BASE_URL' : 'OPENAI_BASE_URL';
|
||||
const raw = cfg.env[envKey];
|
||||
if (!raw || !raw.trim()) return undefined;
|
||||
const trimmed = raw.trim().replace(/\/+$/, '');
|
||||
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
||||
}
|
||||
|
||||
/** Configure the gateway. Called by cli.ts#connectEngine. Clears cached models. */
|
||||
export function configureGateway(config: AIGatewayConfig): void {
|
||||
_config = {
|
||||
embedding_model: config.embedding_model ?? DEFAULT_EMBEDDING_MODEL,
|
||||
embedding_dimensions: config.embedding_dimensions ?? DEFAULT_EMBEDDING_DIMENSIONS,
|
||||
// #1292/D6: do NOT fabricate a default here. Every gateway-internal reader
|
||||
// already applies `?? DEFAULT_EMBEDDING_DIMENSIONS` (getEmbeddingDimensions,
|
||||
// embedQuery, the dim self-check) or `?? 0` (the multimodal path, which is
|
||||
// designed to SKIP validation when the dim is unknown rather than fabricate
|
||||
// one). Backfilling 1280 here erased the "user never set a dimension" signal,
|
||||
// which (a) defeated that intended skip and (b) let a user-provided recipe
|
||||
// with no explicit dim look fully-configured to diagnoseEmbedding and then
|
||||
// fail with a cryptic wrong-width error at embed time. Keep it honest.
|
||||
embedding_dimensions: config.embedding_dimensions,
|
||||
embedding_multimodal_model: config.embedding_multimodal_model,
|
||||
expansion_model: config.expansion_model ?? DEFAULT_EXPANSION_MODEL,
|
||||
chat_model: config.chat_model ?? DEFAULT_CHAT_MODEL,
|
||||
@@ -656,7 +692,7 @@ export type EmbeddingDiagnosis =
|
||||
| { ok: false; reason: 'no_model_configured' }
|
||||
| { ok: false; reason: 'unknown_provider'; model: string; provider: string; message: string }
|
||||
| { ok: false; reason: 'no_touchpoint'; model: string; provider: string; recipeId: string }
|
||||
| { ok: false; reason: 'user_provided_model_unset'; model: string; provider: string; recipeId: string }
|
||||
| { ok: false; reason: 'user_provided_dims_unset'; model: string; provider: string; recipeId: string }
|
||||
| { ok: false; reason: 'missing_env'; model: string; provider: string; recipeId: string; missingEnvVars: string[] };
|
||||
|
||||
export function diagnoseEmbedding(modelOverride?: string): EmbeddingDiagnosis {
|
||||
@@ -704,16 +740,24 @@ export function diagnoseEmbedding(modelOverride?: string): EmbeddingDiagnosis {
|
||||
};
|
||||
}
|
||||
|
||||
// Openai-compat recipes with empty models list require a user-provided model.
|
||||
// #1292/D6: a user-provided / proxy embedding recipe that ships no default
|
||||
// dimension (LiteLLM declares default_dims:0) needs an explicit
|
||||
// embedding_dimensions — otherwise embed() silently falls back to a wrong
|
||||
// vector width at write time. Fail closed here with a clear, actionable reason.
|
||||
//
|
||||
// This REPLACES the prior `user_provided_model_unset` guard, which was a
|
||||
// structurally-unreachable "no model picked" check: parseModelId throws on a
|
||||
// bare provider (model-resolver.ts), so by the time we reach here
|
||||
// parsed.modelId is ALWAYS non-empty. That guard only ever false-positived for
|
||||
// a fully-specified litellm:<model> with dims set, silently disabling vector
|
||||
// search. The genuine "picked a user-provided provider but no model" UX is
|
||||
// handled at the config/init layer, where a bare provider string still exists.
|
||||
const isUserProvided = (tp as any).user_provided_models === true;
|
||||
if (
|
||||
Array.isArray(tp.models) &&
|
||||
tp.models.length === 0 &&
|
||||
(recipe.id === 'litellm' || isUserProvided)
|
||||
) {
|
||||
const recipeDefaultDims = tp.default_dims ?? 0;
|
||||
if ((isUserProvided || recipeDefaultDims === 0) && !_config!.embedding_dimensions) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'user_provided_model_unset',
|
||||
reason: 'user_provided_dims_unset',
|
||||
model: modelStr,
|
||||
provider: parsed.providerId,
|
||||
recipeId: recipe.id,
|
||||
@@ -1200,7 +1244,8 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
|
||||
`OpenAI embedding requires OPENAI_API_KEY.`,
|
||||
recipe.setup_hint,
|
||||
);
|
||||
const client = createOpenAI({ apiKey });
|
||||
const baseURL = resolveNativeBaseUrl('openai', cfg);
|
||||
const client = createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||
// AI SDK v6: use .textEmbeddingModel() for embeddings
|
||||
return (client as any).textEmbeddingModel
|
||||
? (client as any).textEmbeddingModel(modelId)
|
||||
@@ -2123,7 +2168,8 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon
|
||||
case 'native-openai': {
|
||||
const apiKey = cfg.env.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new AIConfigError(`OpenAI expansion requires OPENAI_API_KEY.`, recipe.setup_hint);
|
||||
return createOpenAI({ apiKey }).languageModel(modelId);
|
||||
const baseURL = resolveNativeBaseUrl('openai', cfg);
|
||||
return createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
|
||||
}
|
||||
case 'native-google': {
|
||||
const apiKey = cfg.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
@@ -2133,7 +2179,8 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon
|
||||
case 'native-anthropic': {
|
||||
const apiKey = cfg.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new AIConfigError(`Anthropic expansion requires ANTHROPIC_API_KEY.`, recipe.setup_hint);
|
||||
return createAnthropic({ apiKey }).languageModel(modelId);
|
||||
const baseURL = resolveNativeBaseUrl('anthropic', cfg);
|
||||
return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
|
||||
}
|
||||
case 'openai-compatible': {
|
||||
// D12=A: unified auth via Recipe.resolveAuth (or default).
|
||||
@@ -2495,7 +2542,8 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig):
|
||||
case 'native-openai': {
|
||||
const apiKey = cfg.env.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new AIConfigError(`OpenAI chat requires OPENAI_API_KEY.`, recipe.setup_hint);
|
||||
return createOpenAI({ apiKey }).languageModel(modelId);
|
||||
const baseURL = resolveNativeBaseUrl('openai', cfg);
|
||||
return createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
|
||||
}
|
||||
case 'native-google': {
|
||||
const apiKey = cfg.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
@@ -2505,7 +2553,8 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig):
|
||||
case 'native-anthropic': {
|
||||
const apiKey = cfg.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new AIConfigError(`Anthropic chat requires ANTHROPIC_API_KEY.`, recipe.setup_hint);
|
||||
return createAnthropic({ apiKey }).languageModel(modelId);
|
||||
const baseURL = resolveNativeBaseUrl('anthropic', cfg);
|
||||
return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
|
||||
}
|
||||
case 'openai-compatible': {
|
||||
// D12=A: unified auth via Recipe.resolveAuth (or default).
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Recipe } from '../types.ts';
|
||||
* gbrain at it via `LITELLM_BASE_URL`. The proxy normalizes to
|
||||
* OpenAI-compatible API.
|
||||
*
|
||||
* See docs/guides/litellm-proxy.md for the setup recipe.
|
||||
* See docs/integrations/embedding-providers.md for the setup recipe.
|
||||
*/
|
||||
export const litellmProxy: Recipe = {
|
||||
id: 'litellm',
|
||||
@@ -25,6 +25,7 @@ export const litellmProxy: Recipe = {
|
||||
models: [],
|
||||
user_provided_models: true, // v0.32 D8=A wire-through for the litellm hardcode
|
||||
default_dims: 0, // user must declare --embedding-dimensions explicitly
|
||||
trust_custom_dims: true, // #2271: proxy-backed model dim is user-declared
|
||||
cost_per_1m_tokens_usd: undefined,
|
||||
price_last_verified: '2026-04-20',
|
||||
// LiteLLM's batch capacity is determined by the backend it proxies;
|
||||
@@ -41,5 +42,5 @@ export const litellmProxy: Recipe = {
|
||||
supports_multimodal: true,
|
||||
},
|
||||
},
|
||||
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL + pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
|
||||
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export const llamaServer: Recipe = {
|
||||
models: [], // user-driven; whatever model the server was launched with
|
||||
user_provided_models: true,
|
||||
default_dims: 0, // forces explicit --embedding-dimensions
|
||||
trust_custom_dims: true, // #2271: user knows the launched model's native dim
|
||||
cost_per_1m_tokens_usd: 0,
|
||||
price_last_verified: '2026-05-10',
|
||||
// llama-server's batch capacity is set by `--ctx-size` at launch
|
||||
|
||||
@@ -13,8 +13,20 @@ export const ollama: Recipe = {
|
||||
},
|
||||
touchpoints: {
|
||||
embedding: {
|
||||
models: ['nomic-embed-text', 'mxbai-embed-large', 'all-minilm'],
|
||||
// #2271: modern local embed models added so assertTouchpoint accepts them.
|
||||
// Each carries its own native dim (qwen3-embed-8b=4096, arctic-l-v2=1024);
|
||||
// the recipe-wide default_dims below is only the nomic fallback, so users
|
||||
// of the larger models pass --embedding-dimensions (allowed via
|
||||
// trust_custom_dims). Per-model dims metadata is a tracked follow-up.
|
||||
models: [
|
||||
'nomic-embed-text',
|
||||
'mxbai-embed-large',
|
||||
'all-minilm',
|
||||
'qwen3-embed-8b',
|
||||
'snowflake-arctic-embed-l-v2',
|
||||
],
|
||||
default_dims: 768, // nomic-embed-text native dim
|
||||
trust_custom_dims: true, // #2271: local models carry varied native dims
|
||||
cost_per_1m_tokens_usd: 0,
|
||||
price_last_verified: '2026-04-20',
|
||||
// Ollama's batch capacity depends on the locally loaded model + the
|
||||
|
||||
@@ -87,6 +87,17 @@ export interface EmbeddingTouchpoint {
|
||||
* for shorthand `--model <provider>` and prints a setup hint.
|
||||
*/
|
||||
user_provided_models?: true;
|
||||
/**
|
||||
* #2271: trust a user-supplied `--embedding-dimensions` for this recipe even
|
||||
* when it's not in the known-Matryoshka allowlist. Set ONLY on local /
|
||||
* bring-your-own-backend recipes where the user knows their model's native dim
|
||||
* and we can't enumerate every model (ollama, llama-server, litellm). The
|
||||
* provider's `/embeddings` response-dim validation catches a genuine mismatch
|
||||
* pre-storage. Must NOT be set on fixed-dim hosted providers (openai/voyage/
|
||||
* zeroentropy stay fail-closed) or on recipes that declare recipe-wide
|
||||
* `dims_options` (e.g. openrouter, whose Tier-1 options legitimately govern).
|
||||
*/
|
||||
trust_custom_dims?: true;
|
||||
/**
|
||||
* v0.32 (#779 reworked): explicit opt-out of the missing-max_batch_tokens
|
||||
* startup warning. Set to `true` for recipes whose batch capacity is
|
||||
|
||||
@@ -91,11 +91,12 @@ export function formatEmbeddingCredsError(d: EmbeddingDiagnosis): string {
|
||||
' Or run with --no-embed to import-only and embed later.',
|
||||
].join('\n');
|
||||
|
||||
case 'user_provided_model_unset':
|
||||
case 'user_provided_dims_unset':
|
||||
return [
|
||||
`Provider "${d.provider}" requires a specific model name to be configured.`,
|
||||
`Provider "${d.provider}" ships no default embedding dimension; set one explicitly.`,
|
||||
'',
|
||||
` Set one: gbrain config set embedding_model ${d.provider}:<model-name>`,
|
||||
` Re-init with the dimension: gbrain init --embedding-dimensions <N>`,
|
||||
' (embedding_dimensions is a schema-sizing field — `config set` rejects it on purpose)',
|
||||
' Or run with --no-embed to import-only and embed later.',
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -451,6 +451,18 @@ function isCustomDimValidForProvider(
|
||||
};
|
||||
}
|
||||
|
||||
// Passthrough tier (#2271): local / bring-your-own-backend recipes (ollama,
|
||||
// llama-server, litellm) flag trust_custom_dims because the user knows their
|
||||
// model's native dim and we can't enumerate every locally-pulled model. Trust
|
||||
// the requested dim; the provider's /embeddings response-dim validation catches
|
||||
// a genuine mismatch pre-storage. Runs AFTER Tier 1 (recipe dims_options) and
|
||||
// Tier 2 (provider Matryoshka allowlists) so a recipe that DOES declare fixed
|
||||
// options (e.g. openrouter) is still governed by those, and fixed-dim hosted
|
||||
// providers (openai/voyage/zeroentropy) never reach here as valid.
|
||||
if (recipe.touchpoints.embedding?.trust_custom_dims === true) {
|
||||
return { valid: true, error: '' };
|
||||
}
|
||||
|
||||
// Tier 3: provider not known to support custom dims at all.
|
||||
return {
|
||||
valid: false,
|
||||
|
||||
@@ -115,8 +115,9 @@ function formatInitEmbedWarning(d: Exclude<EmbeddingDiagnosis, { ok: true }>): s
|
||||
case 'no_touchpoint':
|
||||
lines.push(` Provider "${d.provider}" has no embedding touchpoint.`);
|
||||
break;
|
||||
case 'user_provided_model_unset':
|
||||
lines.push(` Provider "${d.provider}" needs an explicit model id (provider:model).`);
|
||||
case 'user_provided_dims_unset':
|
||||
lines.push(` Provider "${d.provider}" ships no default embedding dimension — set one explicitly.`);
|
||||
lines.push(` re-run: gbrain init --embedding-dimensions <N> (e.g. 1024 for bge-large)`);
|
||||
break;
|
||||
case 'no_model_configured':
|
||||
lines.push(' No embedding model is configured.');
|
||||
|
||||
@@ -85,3 +85,35 @@ describe('buildGatewayConfig env-baseURL passthrough', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGatewayConfig env empty-string clobber guard (#1249)', () => {
|
||||
test('an empty-string process.env value does NOT clobber a valid config-plane key', async () => {
|
||||
// Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls.
|
||||
await withEnv({ ANTHROPIC_API_KEY: '' }, async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
anthropic_api_key: 'sk-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.ANTHROPIC_API_KEY).toBe('sk-config-plane');
|
||||
});
|
||||
});
|
||||
|
||||
test('a real process.env value still wins over the config-plane fallback', async () => {
|
||||
await withEnv({ ANTHROPIC_API_KEY: 'sk-env-plane' }, async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
anthropic_api_key: 'sk-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.ANTHROPIC_API_KEY).toBe('sk-env-plane');
|
||||
});
|
||||
});
|
||||
|
||||
test("legitimate falsy-but-present values ('0' / 'false') are preserved, not dropped", async () => {
|
||||
await withEnv(
|
||||
{ GBRAIN_TEST_ZERO_VAL: '0', GBRAIN_TEST_FALSE_VAL: 'false' },
|
||||
async () => {
|
||||
const cfg = buildGatewayConfig(baseConfig);
|
||||
expect(cfg.env.GBRAIN_TEST_ZERO_VAL).toBe('0');
|
||||
expect(cfg.env.GBRAIN_TEST_FALSE_VAL).toBe('false');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* diagnoseEmbedding dims-presence guard (#1292 / eng-review D6).
|
||||
*
|
||||
* The old `user_provided_model_unset` guard fired for ANY litellm config with an
|
||||
* empty recipe model-allowlist — including a fully-specified `litellm:bge-large`
|
||||
* with dims set — so vector search was silently disabled ("No results."). It was
|
||||
* also structurally unreachable as a "no model" check (parseModelId throws on a
|
||||
* bare provider). It's replaced with a real dimensions-presence check: a
|
||||
* user-provided / zero-default recipe with no explicit embedding_dimensions fails
|
||||
* CLOSED here with a clear reason, instead of returning ok and failing with a
|
||||
* cryptic wrong-width error at embed time.
|
||||
*
|
||||
* No embed transport is installed (resetGateway clears it) so diagnoseEmbedding
|
||||
* runs its real config-only logic rather than the test fast-path.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
configureGateway,
|
||||
diagnoseEmbedding,
|
||||
getEmbeddingDimensions,
|
||||
resetGateway,
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
import { DEFAULT_EMBEDDING_DIMENSIONS } from '../../src/core/ai/defaults.ts';
|
||||
|
||||
afterEach(() => resetGateway());
|
||||
|
||||
// Shard hygiene: a file must not END with a reset/non-legacy gateway. The
|
||||
// legacy-embedding-preload restores 1536 per-TEST, but the NEXT file's
|
||||
// beforeAll (often engine.initSchema, which sizes vector columns from the
|
||||
// ambient gateway) runs before any beforeEach — so leaving the gateway null
|
||||
// here would seed 1280-d schemas under that file's 1536-d fixtures.
|
||||
afterAll(() => {
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536,
|
||||
env: { ...process.env },
|
||||
});
|
||||
});
|
||||
|
||||
describe('diagnoseEmbedding dims-presence guard (#1292/D6)', () => {
|
||||
test('litellm:<model> WITH embedding_dimensions → available (the #1292 false-positive is gone)', () => {
|
||||
configureGateway({
|
||||
embedding_model: 'litellm:bge-large',
|
||||
embedding_dimensions: 1024,
|
||||
env: {},
|
||||
});
|
||||
const d = diagnoseEmbedding();
|
||||
expect(d.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('litellm:<model> WITHOUT embedding_dimensions → fails closed with user_provided_dims_unset', () => {
|
||||
configureGateway({
|
||||
embedding_model: 'litellm:bge-large',
|
||||
// no embedding_dimensions on purpose
|
||||
env: {},
|
||||
});
|
||||
const d = diagnoseEmbedding();
|
||||
expect(d.ok).toBe(false);
|
||||
if (!d.ok) expect(d.reason).toBe('user_provided_dims_unset');
|
||||
});
|
||||
|
||||
test('a fixed-dim provider with its own default_dims needs no explicit dimension', () => {
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-small',
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
});
|
||||
const d = diagnoseEmbedding();
|
||||
expect(d.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('backfill invariant: configureGateway keeps embedding_dimensions honest, but getEmbeddingDimensions() still defaults', () => {
|
||||
// The dims-presence guard depends on _config.embedding_dimensions being
|
||||
// undefined when unset (configureGateway no longer fabricates a default).
|
||||
// Downstream readers must still see the default via their own `??`.
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-small',
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
});
|
||||
expect(getEmbeddingDimensions()).toBe(DEFAULT_EMBEDDING_DIMENSIONS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* resolveNativeBaseUrl unit tests (#1250).
|
||||
*
|
||||
* Native providers (anthropic/openai) are instantiated as create<Provider>({ apiKey })
|
||||
* with no explicit baseURL, so the AI SDK reads <PROVIDER>_BASE_URL verbatim. A bare
|
||||
* host (Claude Code injects ANTHROPIC_BASE_URL=https://api.anthropic.com with no /v1)
|
||||
* makes the SDK POST <base>/messages → 404. resolveNativeBaseUrl normalizes a configured
|
||||
* base URL to carry /v1, and returns undefined when unset so the SDK default is preserved.
|
||||
*
|
||||
* Pure function over cfg.env — no process.env mutation, so no withEnv() needed.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveNativeBaseUrl } from '../../src/core/ai/gateway.ts';
|
||||
import type { AIGatewayConfig } from '../../src/core/ai/types.ts';
|
||||
|
||||
function cfgWith(env: Record<string, string | undefined>): AIGatewayConfig {
|
||||
return { env } as unknown as AIGatewayConfig;
|
||||
}
|
||||
|
||||
describe('resolveNativeBaseUrl (#1250)', () => {
|
||||
test('anthropic: bare host gets /v1 appended', () => {
|
||||
expect(
|
||||
resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://api.anthropic.com' })),
|
||||
).toBe('https://api.anthropic.com/v1');
|
||||
});
|
||||
|
||||
test('anthropic: already-/v1 host is unchanged', () => {
|
||||
expect(
|
||||
resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://api.anthropic.com/v1' })),
|
||||
).toBe('https://api.anthropic.com/v1');
|
||||
});
|
||||
|
||||
test('anthropic: trailing slashes are normalized', () => {
|
||||
expect(
|
||||
resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://proxy.example/' })),
|
||||
).toBe('https://proxy.example/v1');
|
||||
expect(
|
||||
resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://proxy.example/v1/' })),
|
||||
).toBe('https://proxy.example/v1');
|
||||
});
|
||||
|
||||
test('anthropic: unset / empty → undefined so the SDK default is preserved [REGRESSION]', () => {
|
||||
expect(resolveNativeBaseUrl('anthropic', cfgWith({}))).toBeUndefined();
|
||||
expect(resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: '' }))).toBeUndefined();
|
||||
expect(resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: ' ' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('openai: reads OPENAI_BASE_URL with the same normalization', () => {
|
||||
expect(
|
||||
resolveNativeBaseUrl('openai', cfgWith({ OPENAI_BASE_URL: 'https://api.openai.com' })),
|
||||
).toBe('https://api.openai.com/v1');
|
||||
expect(
|
||||
resolveNativeBaseUrl('openai', cfgWith({ OPENAI_BASE_URL: 'https://api.openai.com/v1' })),
|
||||
).toBe('https://api.openai.com/v1');
|
||||
expect(resolveNativeBaseUrl('openai', cfgWith({}))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('each provider only reads its own env var', () => {
|
||||
expect(resolveNativeBaseUrl('openai', cfgWith({ ANTHROPIC_BASE_URL: 'https://x' }))).toBeUndefined();
|
||||
expect(resolveNativeBaseUrl('anthropic', cfgWith({ OPENAI_BASE_URL: 'https://x' }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,21 @@ import type { AIGatewayConfig } from '../src/core/ai/types.ts';
|
||||
// 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. Clean up after
|
||||
// the whole file so the gateway is pristine for whatever runs next.
|
||||
afterAll(() => { resetGateway(); });
|
||||
// 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 {
|
||||
@@ -36,6 +48,23 @@ function baseConfig(overrides: Partial<AIGatewayConfig> = {}): AIGatewayConfig {
|
||||
};
|
||||
}
|
||||
|
||||
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(); });
|
||||
|
||||
|
||||
@@ -257,6 +257,55 @@ describe('resolveSchemaEmbeddingDim', () => {
|
||||
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);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { configureGateway } from '../src/core/ai/gateway.ts';
|
||||
import {
|
||||
detectRegressions,
|
||||
computeDriftScore,
|
||||
@@ -26,6 +27,17 @@ import type { TrajectoryPoint } from '../src/core/engine.ts';
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
// This file hardcodes 1536-d vectors (vecForMetric). initSchema sizes the
|
||||
// facts halfvec column from the AMBIENT gateway state, and a beforeAll runs
|
||||
// before the legacy-embedding-preload's per-test restore — so a preceding
|
||||
// file in the shard that left the gateway reset or non-1536 would seed a
|
||||
// 1280-d schema and every insert here would fail with a width mismatch.
|
||||
// Pin the schema shape explicitly (the pattern bunfig's preload documents).
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536,
|
||||
env: { ...process.env },
|
||||
});
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* `ANTHROPIC_API_KEY`); this test is the structural regression guard that
|
||||
* runs in every parallel-test shard.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { afterAll, describe, test, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
configureGateway,
|
||||
isAvailable,
|
||||
@@ -33,6 +33,25 @@ beforeEach(() => {
|
||||
__setChatTransportForTests(null);
|
||||
});
|
||||
|
||||
// Shard hygiene: this file's tests each configureGateway WITHOUT
|
||||
// embedding_dimensions, and the file used to END in that state. The
|
||||
// legacy-embedding-preload only re-pins 1536 when the gateway is RESET
|
||||
// (it checks by calling getEmbeddingDimensions, which doesn't throw on a
|
||||
// configured-but-dimensionless gateway), and the NEXT file's beforeAll
|
||||
// (often engine.initSchema, which sizes vector columns from ambient
|
||||
// gateway state) runs before any beforeEach — so this file poisoned every
|
||||
// later fresh-schema file in its shard down to 1280-d columns under
|
||||
// 1536-d fixtures (bit engine-find-trajectory in CI shard 5). Restore the
|
||||
// legacy pin on exit.
|
||||
afterAll(() => {
|
||||
__setChatTransportForTests(null);
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536,
|
||||
env: { ...process.env },
|
||||
});
|
||||
});
|
||||
|
||||
describe('facts extract — silent-no-op regression (v0.31.6 bug class)', () => {
|
||||
test('with valid model and ANTHROPIC_API_KEY present, isAvailable("chat") is true', () => {
|
||||
configureGateway({
|
||||
|
||||
Reference in New Issue
Block a user