Merge remote-tracking branch 'origin/master' into garrytan/cathedral-1

Brings in v0.42.58.0 (provider-agnostic AI gateway — env clobber, base-URL
/v1 normalization, embedding-dim validation; #1249 #1250 #1292 #2271 #2209).

Conflicts resolved (version trio + TODOS only; AI-gateway code auto-merged):
- VERSION / package.json: kept 0.43.0.0 (Cathedral 1 — higher semver wins).
- CHANGELOG.md / TODOS.md: kept both sides — ours (0.43.0.0) on top, master's
  0.42.58.0 entry / provider-agnostic follow-ups below.

Verified: 3-line version audit agrees on 0.43.0.0; bun.lock unchanged;
bun run typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-09 18:55:50 -07:00
co-authored by Claude Opus 4.8
22 changed files with 484 additions and 37 deletions
+16
View File
@@ -49,6 +49,22 @@ complete operation catalog — both speak the verbs). Run `gbrain protocol confo
to self-certify, and `gbrain protocol stats` to watch adoption. Memories your agent
saves are readable by every agent connected to the brain by default; pass
`visibility: "private"` for local-only facts.
## [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.**
+41
View File
@@ -23,6 +23,47 @@ and the scope record at `~/.gstack/projects/garrytan-gbrain/ceo-plans/2026-06-12
`src/core/verbs/entity-card.ts` open-threads assembly + a new schema table
(additive — the card field already exists, so this is a quality upgrade, not
a contract change).
## 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
View File
@@ -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
+9 -5
View File
@@ -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
View File
@@ -2041,7 +2041,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.
+13 -1
View File
@@ -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
View File
@@ -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).
+3 -2
View File
@@ -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>.',
};
+1
View File
@@ -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 -1
View File
@@ -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
+11
View File
@@ -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
+4 -3
View File
@@ -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');
+12
View File
@@ -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,
+3 -2
View File
@@ -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.');
+32
View File
@@ -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');
},
);
});
});
+82
View File
@@ -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);
});
});
+63
View File
@@ -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();
});
});
+32 -3
View File
@@ -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(); });
+49
View File
@@ -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);
+12
View File
@@ -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();
+20 -1
View File
@@ -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({