Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Opus 5 b9eaebc383 feat(ai): default embedder moves to ollama:bge-m3 @1024 with a loud hosted fallback (#3390)
ZeroEntropy's hosted API — the shipped default embedder since v0.36.2.0 —
shuts down 2026-09-04. Per the new Default-provider policy (CLAUDE.md), a
gbrain DEFAULT must be open-weight or from the vendor with the longest
proven model-lifetime record. The new default is ollama:bge-m3 at its
native 1024 dimensions: open-weight, local, free, and the strongest
open-weight multilingual retriever among the 8 candidates evaluated.

Because the default has no hosted vendor, it ships with a declared hosted
fallback: openai:text-embedding-3-small at 1024 (Matryoshka width pinned
to bge-m3's so a later fallback→default migration rebuilds vectors only).
`gbrain init` probes Ollama ONCE (≤1.5s, fail-open, OLLAMA_BASE_URL
honored), persists the resolved choice, and falls back LOUDLY — naming
the multilingual quality cost and the paste-ready way back. The
`embedding_default_fallback` config marker makes `gbrain doctor` re-probe
on every run, so installing Ollama later surfaces the switch. Embed calls
never probe.

Fresh installs only. Existing brains keep their configured model; no
migration re-embeds anyone. The one-shot `gbrain upgrade` ZE-sunset
banner now reads the DB config plane too and prints both concrete
targets (bge-m3 default / same-width hosted).

No VERSION/CHANGELOG/package.json bump here — /ship allocates at release.

Supersedes #3481.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:31:24 -07:00
23 changed files with 788 additions and 120 deletions
+21 -1
View File
@@ -239,7 +239,27 @@ The install picker fires inside `gbrain init` AFTER `engine.initSchema()`
(non-TTY auto-selects). The upgrade banner fires once via `runPostUpgrade`
in `src/commands/upgrade.ts`, gated by `search.mode_upgrade_notice_shown`.
## Eval discipline (v0.32.3)
## Default-provider policy
**A gbrain DEFAULT embedding or reranking model must be either open-weight, or
from the vendor with the longest proven model-lifetime record. Novel/startup
providers may ship as opt-in recipes, never as the default.** Rationale: the
v0.36 zembed-1 default stranded every default-config brain when ZeroEntropy
was acquired and gave ~6 weeks notice (hosted API sunsets 2026-09-04).
Current defaults live in `src/core/ai/defaults.ts`:
`DEFAULT_EMBEDDING_MODEL = 'ollama:bge-m3'`,
`DEFAULT_EMBEDDING_DIMENSIONS = 1024` (bge-m3's native width — open-weight,
local, cannot be sunset by anyone). Because the default has no hosted vendor,
it also has a **declared hosted fallback**: `FALLBACK_EMBEDDING_MODEL =
'openai:text-embedding-3-small'` at 1024 (Matryoshka width pinned to bge-m3's
so a later fallback→default migration rebuilds vectors only — no column ALTER,
no HNSW rebuild). `gbrain init` probes Ollama ONCE (`src/core/ai/ollama-detect.ts`,
≤1.5s, fail-open), persists the resolved choice to config.json, and falls back
LOUDLY — the notice names the multilingual quality cost and the paste-ready way
back, and the `embedding_default_fallback` config marker makes `gbrain doctor`
re-probe on every run so "installed Ollama later" surfaces the switch. Embed
calls never probe. A silent downgrade onto the fallback is a bug.
Every metric printed by any `gbrain eval *` or `gbrain search stats` command
resolves through `src/core/eval/metric-glossary.ts` so industry terms
+12 -5
View File
@@ -40,16 +40,23 @@ restart the shell or add the PATH export to the shell profile.
## Step 2: API Keys
Ask the user for these. gbrain defaults to the ZeroEntropy embedding + reranker stack
(as of v0.36.2.0); OpenAI/Voyage are still supported as fallbacks via `gbrain config
set embedding_model <provider:model>`.
gbrain's default embedder is `ollama:bge-m3` at 1024 dimensions — local,
open-weight, no API key. If Ollama is installed with the model pulled
(`ollama pull bge-m3`), `gbrain init` detects it automatically. Otherwise
init falls back (loudly) to the hosted `openai:text-embedding-3-small` when
`OPENAI_API_KEY` is set; other providers via `gbrain config set
embedding_model <provider:model>`.
```bash
export ZEROENTROPY_API_KEY=ze-... # default embedding + reranker (v0.36.2.0+)
export OPENAI_API_KEY=sk-... # fallback for vector search; also used for chat models
ollama pull bge-m3 # default embedding (local; install Ollama from https://ollama.ai)
export OPENAI_API_KEY=sk-... # hosted fallback embedding; also used for chat models
export ANTHROPIC_API_KEY=sk-ant-... # optional, improves search quality via query expansion
```
> ZeroEntropy was the default embedder + reranker from v0.36.2.0 through
> v0.42.x. Its hosted API shuts down 2026-09-04. Brains still on it get a
> one-time `gbrain upgrade` banner with the exact migration commands.
Save to shell profile or `.env`. Keys are picked up by `gbrain config set` automatically
or can be stored in `~/.gbrain/config.json` (file plane). Without any embedding provider,
keyword search still works. Without Anthropic, search works but skips query expansion.
+3 -3
View File
@@ -290,8 +290,8 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
- **Voice**: Phone calls create brain pages via Twilio + OpenAI Realtime (or DIY STT+LLM+TTS). Setup recipe: [`recipes/twilio-voice-brain.md`](recipes/twilio-voice-brain.md).
- **Email + calendar**: webhook handlers that route to brain signals. [`docs/integrations/meeting-webhooks.md`](docs/integrations/meeting-webhooks.md).
- **Embedding providers**: 16 recipes covering OpenAI (default fallback), OpenRouter, Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md).
- **Rerankers**: ZeroEntropy `zerank-2` hosted (default in `tokenmax` mode) plus the v0.40.6.1 `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted ZeroEntropy weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Embedding providers**: 16 recipes covering Ollama (local — `bge-m3` at 1024d is the default: open-weight, multilingual, cannot be sunset), OpenAI (`text-embedding-3-small` is the hosted fallback when Ollama isn't available), OpenRouter, Voyage, Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md). ZeroEntropy was the default from v0.36.2.0 to v0.42.x; its hosted API sunsets 2026-09-04 — `gbrain upgrade` prints the migration commands. Default-provider policy lives in [`CLAUDE.md`](CLAUDE.md).
- **Rerankers**: ZeroEntropy `zerank-2` hosted (still the `tokenmax`-mode default; its hosted API sunsets 2026-09-04 — `gbrain upgrade` prints the switch instructions, and the `zerank` weights are Apache-2.0 so the local recipe below keeps working) plus the v0.40.6.1 `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted ZeroEntropy weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Credential gateway**: vault-aware secret distribution. [`docs/integrations/credential-gateway.md`](docs/integrations/credential-gateway.md).
- **MCP clients**: every major MCP client is supported. [`docs/mcp/`](docs/mcp/) per-client setup.
@@ -459,4 +459,4 @@ MIT. I built GBrain to run my OpenClaw and Hermes deployments — the production
Origin story: [`docs/ethos/ORIGIN.md`](docs/ethos/ORIGIN.md).
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that ships as the default. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that shipped as the default from v0.36.2.0 to v0.42.x. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
+12
View File
@@ -1,5 +1,17 @@
# ZeroEntropy — zembed-1 + zerank-2
> **Deprecated as a hosted provider. The ZeroEntropy hosted API shuts down
> 2026-09-04.** `zembed-1` was GBrain's default embedder from v0.36.2.0
> through v0.42.x; the default is now `ollama:bge-m3` at 1024 dimensions
> (open-weight, local — see the Default-provider policy in `CLAUDE.md`),
> with `openai:text-embedding-3-small` as the hosted fallback. If your
> brain still embeds through ZeroEntropy, migrate before that date —
> `gbrain upgrade` prints the exact commands for your brain, and
> [`../guides/embedding-migration.md`](../guides/embedding-migration.md)
> walks both targets. The `zembed-1` and `zerank` weights are Apache-2.0,
> so self-hosting via llama-server or Ollama is the other forward path and
> preserves your existing vectors outright.
[ZeroEntropy](https://zeroentropy.dev) ships two specialized small models
for retrieval pipelines:
+24 -3
View File
@@ -3,9 +3,30 @@
`gbrain migrate embeddings` re-embeds an entire brain onto a different
embedding provider/model, safely and resumably. It is the forward path off a
sunsetting provider (for example ZeroEntropy's hosted API, which shuts down
2026-09-04 and is the shipped default for brains that never picked a model) —
but it is provider-agnostic: any configured `provider:model` works as a
target.
2026-09-04 and was the shipped default from v0.36.2.0 through v0.42.x) — but
it is provider-agnostic: any configured `provider:model` works as a target.
**Coming off the ZeroEntropy default?** Two targets, pick one:
```bash
# A) The current default — open-weight, local, free. Requires Ollama with
# `ollama pull bge-m3`. Changes column width (e.g. 1280 → 1024), so this
# includes a dimension transition + index rebuild:
gbrain migrate embeddings --to ollama:bge-m3 --dim 1024 --dry-run
gbrain migrate embeddings --to ollama:bge-m3 --dim 1024
# B) Hosted, no schema change — pass --dim at your brain's CURRENT width
# (check `gbrain doctor`). OpenAI text-embedding-3-* is Matryoshka, so
# migrating at the width you already have reuses the existing vector(N)
# column and its HNSW index; only the vectors are rebuilt. Note: weaker
# on non-English content than bge-m3.
gbrain migrate embeddings --to openai:text-embedding-3-small --dim 1280 --dry-run
gbrain migrate embeddings --to openai:text-embedding-3-small --dim 1280
```
Omitting `--dim` resolves the target recipe's default width (1536 for the
OpenAI recipe), which forces a needless dimension transition — always pass it
explicitly.
Also reachable as `gbrain retrieval-upgrade` (the name `doctor` and the
README reference).
+22 -10
View File
@@ -15,7 +15,13 @@ gbrain init --pglite --model voyage # use a non-default provider
## Init resolves your provider from env keys
As of v0.37, `gbrain init --pglite` auto-detects which provider to use from your env vars. With `OPENAI_API_KEY` set, you get OpenAI. With `ZEROENTROPY_API_KEY` set, you get ZeroEntropy. If multiple provider keys are set, init fires an interactive picker. If no provider keys are set in a non-TTY context (CI, Docker build), init exits 1 with a paste-ready setup hint. Explicit flags (`--embedding-model`, `--no-embedding`) always win over env detection.
As of v0.37, `gbrain init --pglite` auto-detects which provider to use. Detection order:
1. **The default: `ollama:bge-m3` (1024d, local, open-weight).** Init probes the local Ollama daemon once (≤1.5s, `OLLAMA_BASE_URL` honored). If it's running with `bge-m3` pulled, the default wins — over every env key, since it needs no key and costs nothing.
2. **The hosted fallback: `openai:text-embedding-3-small` (1024d).** If Ollama is unreachable (or the model isn't pulled) and `OPENAI_API_KEY` is set, init falls back — loudly. The notice names the trade-off (text-embedding-3-small is the weakest multilingual performer among the candidates we evaluated) and the paste-ready way back; a config marker makes `gbrain doctor` re-probe for Ollama on every run, so installing Ollama later surfaces the switch automatically.
3. **Other single env keys** auto-pick that provider; multiple keys fire an interactive picker. If nothing resolves in a non-TTY context (CI, Docker build), init exits 1 with a paste-ready setup hint. Explicit flags (`--embedding-model`, `--no-embedding`) always win over detection.
The probe runs ONCE at init — never on embed calls.
The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atomically, so subsequent runs are deterministic across releases.
@@ -23,8 +29,9 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
| Provider | env vars | default dims | cost ($/1M tokens) | local? | multimodal? |
|---|---|---|---|---|---|
| `zeroentropyai` | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `openai` | `OPENAI_API_KEY` | 1536 | 0.13 | no | no |
| `ollama` (**default**: `bge-m3` @ 1024) | (none — runs locally; `OLLAMA_BASE_URL` optional) | per-model (bge-m3 1024, nomic 768, ...) | 0 | yes | no |
| `openai` (**hosted fallback**: `text-embedding-3-small` @ 1024) | `OPENAI_API_KEY` | recipe 1536; fallback pins 1024 | 0.02 (-small) / 0.13 (-large) | no | no |
| `zeroentropyai` (sunsets 2026-09-04) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `openrouter` | `OPENROUTER_API_KEY` | 1536 | 0.02 | no | model-dependent |
| `voyage` | `VOYAGE_API_KEY` | 1024 | 0.18 | no | yes (`voyage-multimodal-3`) |
| `google` | `GOOGLE_GENERATIVE_AI_API_KEY` | 768 | 0.025 | no | no |
@@ -32,7 +39,6 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
| `minimax` | `MINIMAX_API_KEY` | 1536 | 0.07 | no | no |
| `dashscope` | `DASHSCOPE_API_KEY` | 1024 | varies | no | no |
| `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) | yes (backend permitting) |
| `together` | `TOGETHER_API_KEY` | 768 | varies | no | no |
@@ -40,7 +46,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
| `deepseek` | (no embedding model — chat only) | — | — | — | — |
| `groq` | (no embedding model — chat only) | — | — | — | — |
**Note on local providers.** Ollama and llama-server have no required API key, so they don't show up in env-detection auto-pick. Pick them explicitly with `--embedding-model ollama:<model>` to avoid silently routing to a daemon that may not be running.
**Note on local providers.** llama-server has no required API key, so it never shows up in env-detection auto-pick — pick it explicitly with `--embedding-model llama-server:<model>`. Ollama is special-cased as the system default: init verifies the daemon is actually reachable AND `bge-m3` is pulled before selecting it, so it can never silently route to a daemon that isn't running. Other Ollama models remain explicit-only (`--embedding-model ollama:<model>`).
## If first import fails
@@ -75,7 +81,9 @@ The doctor distinguishes two repair paths:
### OpenAI
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.
**The hosted fallback.** When Ollama isn't available at init and `OPENAI_API_KEY` is set, init lands on `openai:text-embedding-3-small` at **1024** dimensions — pinned to bge-m3's width so a later switch to the default rebuilds vectors only (no column change, no HNSW rebuild). Be aware of the trade-off: `text-embedding-3-small` was the weakest multilingual performer among the candidates we evaluated; if your brain carries substantial non-English content, prefer the bge-m3 default.
Set `OPENAI_API_KEY`. Models: `text-embedding-3-large` (3072 max, 1536 recipe default), `text-embedding-3-small` (1536 native). Both are Matryoshka via the `dimensions` field (any integer width ≤ native) — gbrain pins it from `embedding_dimensions` config so existing 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.
@@ -139,13 +147,17 @@ CJK-dominant content tokenizes denser than OpenAI tiktoken; gbrain declares `cha
Set `ZHIPUAI_API_KEY`. Models: `embedding-3` (current; Matryoshka 256-2048 dims), `embedding-2`. v0.32 default is 1024 (HNSW-compatible). The 2048-dim option works but falls into the exact-scan branch (see Voyage 4 Large note above).
### Ollama (local)
### Ollama (local) — the default
No env required — Ollama runs unauthenticated locally. Optional `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`) and `OLLAMA_API_KEY` (for auth-enabled deployments).
**`ollama:bge-m3` at its native 1024 dimensions is gbrain's default embedder.** Open-weight (nobody can sunset it), free, local, and the strongest open-weight multilingual retriever among the candidates we evaluated — it holds retrieval quality on non-Latin-script content where small hosted models degrade sharply. Setup: install Ollama from https://ollama.ai, then `ollama pull bge-m3`; `gbrain init` detects it automatically.
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.
**Throughput expectation (one-time):** local bge-m3 embeds roughly 8× slower than hosted APIs (~7 docs/s vs ~60 docs/s on the eval hardware). For a 10K-page brain, that's the first full embed taking ~23 minutes instead of ~3. After the initial sync, embeds are incremental and the difference is unnoticeable — and queries are unaffected.
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.
No env required — Ollama runs unauthenticated locally. Optional `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`; also honored by init's availability probe) and `OLLAMA_API_KEY` (for auth-enabled deployments).
Recipe also ships `nomic-embed-text` (768d), `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:bge-m3` smoke-tests the local install.
Widths resolve per model (`model_dims` in the recipe); for models not named there, declare the native dimension with `--embedding-dimensions <N>` at init — gbrain trusts the value you declare for local recipes.
### llama-server (local, llama.cpp)
+7 -3
View File
@@ -492,7 +492,11 @@ OAuth source scoping only guards the HTTP MCP path. If the brain's Postgres and
## Part 13: Cost and speed expectations
Real numbers from the published benchmark, running the default stack (GBrain with ZeroEntropy for embedding + reranker):
Real numbers from the published benchmark. The benchmark run used ZeroEntropy
for embedding + reranker, which was the default through v0.42.x; the default
embedder is now the local `ollama:bge-m3` at 1024 dimensions ($0 per token,
see the Default-provider policy in `CLAUDE.md`), with
`openai:text-embedding-3-small` ($0.02/M tokens) as the hosted fallback:
- **Embedding cost:** $0.05 per million tokens. For comparison, GBrain configured with OpenAI is $0.13 (2.6× more expensive), Voyage is $0.18 (3.6× more).
- **Ingest speed:** about 22 seconds for a small test corpus of 164 pages on the host machine. For a 10K-page corpus, expect about 20 minutes the first time, then most syncs are incremental and finish in seconds.
@@ -502,7 +506,7 @@ Real numbers from the published benchmark, running the default stack (GBrain wit
Full methodology and per-run receipt JSONs live in [the gbrain-evals repo](https://github.com/garrytan/gbrain-evals/blob/main/docs/benchmarks/2026-05-23-v0.40.6.0-snapshot.md).
For a 25-person company at sustained use, expect about $35 a month in embeddings (ZeroEntropy at $0.05/million tokens), $50 a month in Anthropic calls for the synthesized-answer queries, plus your hosting bill. Under $100 a month for the AI side at most companies your size.
For a 25-person company at sustained use, expect $0 a month in embeddings on the default (local `ollama:bge-m3`) or about $15 a month on the hosted fallback (`text-embedding-3-small` at $0.02/million tokens), $50 a month in Anthropic calls for the synthesized-answer queries, plus your hosting bill. Under $100 a month for the AI side at most companies your size.
---
@@ -514,7 +518,7 @@ Check `gbrain auth list` on the host and confirm their client has `--source` set
### "Sync is slow and feels stuck"
The first sync embeds every page, which takes time. Check `gbrain sources status` for the live page count. If it's climbing you're not stuck, you're just embedding. If you've got a 10K-page corpus and ZeroEntropy is being throttled, the per-source parallel sync looks like progress on three sources at once rather than one source moving fast.
The first sync embeds every page, which takes time. Check `gbrain sources status` for the live page count. If it's climbing you're not stuck, you're just embedding. If you've got a 10K-page corpus and your embedding provider is slow or throttled (the local bge-m3 default embeds at roughly an eighth of hosted-API speed — about 23 minutes for a 10K-page first sync instead of ~3, one time), the per-source parallel sync looks like progress on three sources at once rather than one source moving fast.
### "I see a page I shouldn't see"
+1 -2
View File
@@ -103,11 +103,10 @@ Render will build a Docker container with the harness. First deploy takes about
In the AlphaClaw UI (Providers tab):
- **OpenAI API Key.** Required for embeddings if you use the OpenAI provider.
- **OpenAI API Key.** Recommended. GBrain's default embedder is the local `ollama:bge-m3`; on a hosted deployment without Ollama, init falls back to `openai:text-embedding-3-small` via this key.
- **Anthropic API Key.** Required for Claude (the main model the agent talks through).
- **Perplexity API Key.** Optional, for web search.
- **Voyage API Key.** Optional, alternative to OpenAI for embeddings.
- **ZeroEntropy API Key.** Recommended. GBrain ships with ZeroEntropy as the default embedder + reranker because it's about 2× faster than OpenAI and about 2.6× cheaper.
You can use the same keys across multiple agents.
+36 -9
View File
@@ -388,7 +388,27 @@ The install picker fires inside `gbrain init` AFTER `engine.initSchema()`
(non-TTY auto-selects). The upgrade banner fires once via `runPostUpgrade`
in `src/commands/upgrade.ts`, gated by `search.mode_upgrade_notice_shown`.
## Eval discipline (v0.32.3)
## Default-provider policy
**A gbrain DEFAULT embedding or reranking model must be either open-weight, or
from the vendor with the longest proven model-lifetime record. Novel/startup
providers may ship as opt-in recipes, never as the default.** Rationale: the
v0.36 zembed-1 default stranded every default-config brain when ZeroEntropy
was acquired and gave ~6 weeks notice (hosted API sunsets 2026-09-04).
Current defaults live in `src/core/ai/defaults.ts`:
`DEFAULT_EMBEDDING_MODEL = 'ollama:bge-m3'`,
`DEFAULT_EMBEDDING_DIMENSIONS = 1024` (bge-m3's native width — open-weight,
local, cannot be sunset by anyone). Because the default has no hosted vendor,
it also has a **declared hosted fallback**: `FALLBACK_EMBEDDING_MODEL =
'openai:text-embedding-3-small'` at 1024 (Matryoshka width pinned to bge-m3's
so a later fallback→default migration rebuilds vectors only — no column ALTER,
no HNSW rebuild). `gbrain init` probes Ollama ONCE (`src/core/ai/ollama-detect.ts`,
≤1.5s, fail-open), persists the resolved choice to config.json, and falls back
LOUDLY — the notice names the multilingual quality cost and the paste-ready way
back, and the `embedding_default_fallback` config marker makes `gbrain doctor`
re-probe on every run so "installed Ollama later" surfaces the switch. Embed
calls never probe. A silent downgrade onto the fallback is a bug.
Every metric printed by any `gbrain eval *` or `gbrain search stats` command
resolves through `src/core/eval/metric-glossary.ts` so industry terms
@@ -1030,16 +1050,23 @@ restart the shell or add the PATH export to the shell profile.
## Step 2: API Keys
Ask the user for these. gbrain defaults to the ZeroEntropy embedding + reranker stack
(as of v0.36.2.0); OpenAI/Voyage are still supported as fallbacks via `gbrain config
set embedding_model <provider:model>`.
gbrain's default embedder is `ollama:bge-m3` at 1024 dimensions — local,
open-weight, no API key. If Ollama is installed with the model pulled
(`ollama pull bge-m3`), `gbrain init` detects it automatically. Otherwise
init falls back (loudly) to the hosted `openai:text-embedding-3-small` when
`OPENAI_API_KEY` is set; other providers via `gbrain config set
embedding_model <provider:model>`.
```bash
export ZEROENTROPY_API_KEY=ze-... # default embedding + reranker (v0.36.2.0+)
export OPENAI_API_KEY=sk-... # fallback for vector search; also used for chat models
ollama pull bge-m3 # default embedding (local; install Ollama from https://ollama.ai)
export OPENAI_API_KEY=sk-... # hosted fallback embedding; also used for chat models
export ANTHROPIC_API_KEY=sk-ant-... # optional, improves search quality via query expansion
```
> ZeroEntropy was the default embedder + reranker from v0.36.2.0 through
> v0.42.x. Its hosted API shuts down 2026-09-04. Brains still on it get a
> one-time `gbrain upgrade` banner with the exact migration commands.
Save to shell profile or `.env`. Keys are picked up by `gbrain config set` automatically
or can be stored in `~/.gbrain/config.json` (file plane). Without any embedding provider,
keyword search still works. Without Anthropic, search works but skips query expansion.
@@ -1784,8 +1811,8 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
- **Voice**: Phone calls create brain pages via Twilio + OpenAI Realtime (or DIY STT+LLM+TTS). Setup recipe: [`recipes/twilio-voice-brain.md`](recipes/twilio-voice-brain.md).
- **Email + calendar**: webhook handlers that route to brain signals. [`docs/integrations/meeting-webhooks.md`](docs/integrations/meeting-webhooks.md).
- **Embedding providers**: 16 recipes covering OpenAI (default fallback), OpenRouter, Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md).
- **Rerankers**: ZeroEntropy `zerank-2` hosted (default in `tokenmax` mode) plus the v0.40.6.1 `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted ZeroEntropy weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Embedding providers**: 16 recipes covering Ollama (local — `bge-m3` at 1024d is the default: open-weight, multilingual, cannot be sunset), OpenAI (`text-embedding-3-small` is the hosted fallback when Ollama isn't available), OpenRouter, Voyage, Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md). ZeroEntropy was the default from v0.36.2.0 to v0.42.x; its hosted API sunsets 2026-09-04 — `gbrain upgrade` prints the migration commands. Default-provider policy lives in [`CLAUDE.md`](CLAUDE.md).
- **Rerankers**: ZeroEntropy `zerank-2` hosted (still the `tokenmax`-mode default; its hosted API sunsets 2026-09-04 — `gbrain upgrade` prints the switch instructions, and the `zerank` weights are Apache-2.0 so the local recipe below keeps working) plus the v0.40.6.1 `llama-server-reranker` recipe for fully-local cross-encoder rerank via llama.cpp — runs Qwen3-Reranker or self-hosted ZeroEntropy weights against the same `gateway.rerank()` seam. Setup walkthrough in [`docs/ai-providers/llama-server-reranker.md`](docs/ai-providers/llama-server-reranker.md).
- **Credential gateway**: vault-aware secret distribution. [`docs/integrations/credential-gateway.md`](docs/integrations/credential-gateway.md).
- **MCP clients**: every major MCP client is supported. [`docs/mcp/`](docs/mcp/) per-client setup.
@@ -1953,7 +1980,7 @@ MIT. I built GBrain to run my OpenClaw and Hermes deployments — the production
Origin story: [`docs/ethos/ORIGIN.md`](docs/ethos/ORIGIN.md).
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that ships as the default. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
Community PR contributors are credited in `CHANGELOG.md` per release. ZeroEntropy ([@zeroentropy](https://zeroentropy.dev)) for the embedding + reranker stack that shipped as the default from v0.36.2.0 to v0.42.x. Voyage AI for the asymmetric-encoding recipe template. Ramp Labs for the search quality improvements lineage.
---
+63
View File
@@ -2337,6 +2337,65 @@ export async function checkZeEmbeddingHealth(engine: BrainEngine): Promise<Check
}
}
/**
* embedding_default_fallback doctor check.
*
* When `gbrain init` couldn't reach Ollama (or bge-m3 wasn't pulled) it
* lands on the hosted fallback and writes the `embedding_default_fallback`
* marker to config.json. This check is how "install Ollama later" becomes
* visible: while the marker is set AND the brain is still on the fallback
* model, re-probe Ollama (bounded 1.5s, fail-open) and print the paste-ready
* migrate command back to the default. Exported for test/doctor tests.
*/
export async function checkEmbeddingDefaultFallback(_engine: BrainEngine): Promise<Check> {
const name = 'embedding_default_fallback';
try {
const { loadConfigFileOnly } = await import('../core/config.ts');
const cfg = loadConfigFileOnly();
if (!cfg?.embedding_default_fallback) {
return { name, status: 'ok', message: 'Not on the hosted embedding fallback — skip.' };
}
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS, FALLBACK_EMBEDDING_MODEL } =
await import('../core/ai/defaults.ts');
if (cfg.embedding_model !== FALLBACK_EMBEDDING_MODEL) {
// Stale marker (user moved to another model since); ignore.
return {
name,
status: 'ok',
message: `Fallback marker present but embedding_model="${cfg.embedding_model}" is no longer the fallback — stale, ignoring.`,
};
}
const bareModel = DEFAULT_EMBEDDING_MODEL.split(':')[1];
const { probeOllamaModel } = await import('../core/ai/ollama-detect.ts');
const probe = await probeOllamaModel(bareModel);
if (probe.ok) {
return {
name,
status: 'warn',
message:
`This brain is on the hosted embedding fallback (${FALLBACK_EMBEDDING_MODEL}), but Ollama ` +
`with ${bareModel} is now available. The default (${DEFAULT_EMBEDDING_MODEL}) is local, free, ` +
`open-weight, and stronger on non-English content. Switch (same column width, vectors ` +
`rebuilt, no schema change): gbrain migrate embeddings --to ${DEFAULT_EMBEDDING_MODEL} --dim ${DEFAULT_EMBEDDING_DIMENSIONS}`,
};
}
const why = probe.serverUp
? `Ollama is running but ${bareModel} is not pulled (fix: ollama pull ${bareModel})`
: 'Ollama is not reachable';
return {
name,
status: 'ok',
message:
`On the hosted embedding fallback (${FALLBACK_EMBEDDING_MODEL}); ${why}. ` +
`To move to the default: install Ollama, \`ollama pull ${bareModel}\`, then ` +
`\`gbrain migrate embeddings --to ${DEFAULT_EMBEDDING_MODEL} --dim ${DEFAULT_EMBEDDING_DIMENSIONS}\`.`,
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name, status: 'warn', message: `Could not check embedding fallback state: ${msg}` };
}
}
/**
* v0.36.0.0 (A5): embedding_width_consistency doctor check.
*
@@ -7678,6 +7737,10 @@ export async function buildChecks(
checks.push(await checkZeEmbeddingHealth(engine));
progress.heartbeat('embedding_width_consistency');
checks.push(await checkEmbeddingWidthConsistency(engine));
// Hosted-fallback re-check: nags (warn) only when Ollama+bge-m3 became
// available after an install that fell back to the hosted embedder.
progress.heartbeat('embedding_default_fallback');
checks.push(await checkEmbeddingDefaultFallback(engine));
// v0.41.15.0 (T6, codex #19/#20) — facts.embedding column drift
// parity check. Same drift class as content_chunks, separate column.
progress.heartbeat('facts_embedding_width_consistency');
+104 -15
View File
@@ -229,6 +229,13 @@ export interface ResolvedAIOptions {
chat_model?: string;
/** v0.37 (D9): user opted into deferred embedding setup. */
noEmbedding?: boolean;
/**
* Set when init landed on the hosted FALLBACK_EMBEDDING_MODEL because the
* declared default (ollama:bge-m3) was unavailable. Persisted to
* config.json as `embedding_default_fallback` so `gbrain doctor` can
* re-check for Ollama later and offer the way back to the default.
*/
embeddingFallback?: boolean;
}
/**
@@ -493,14 +500,15 @@ export async function findEnvKeyTypos(
/** Emit the fail-loud "no embedding provider" message + paste-ready setup. */
function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested: string }>): void {
console.error('\nNo embedding provider configured. Set one of:');
console.error(' export OPENAI_API_KEY=sk-… # openai:text-embedding-3-large (1536d)');
console.error(' export ZEROENTROPY_API_KEY=ze-… # zeroentropyai:zembed-1 (2560d, Matryoshka)');
console.error('\nNo embedding provider configured. The default is local + open-weight:');
console.error(' ollama pull bge-m3 # default: ollama:bge-m3 (1024d) — install Ollama from https://ollama.ai');
console.error('Or set a hosted provider key:');
console.error(' export OPENAI_API_KEY=sk-… # fallback: openai:text-embedding-3-small (1024d)');
console.error(' export VOYAGE_API_KEY=pa-… # voyage:voyage-3-large (1024d)');
console.error('Then re-run: gbrain init --pglite');
console.error('');
console.error('Or pick explicitly:');
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-large');
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-small');
console.error('');
console.error('Or defer setup: gbrain init --pglite --no-embedding');
console.error(' (you can configure later with `gbrain config set embedding_model <id>`)');
@@ -513,30 +521,83 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
}
}
async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boolean): Promise<void> {
/** Exported for unit tests (probe stubbed via __setOllamaProbeForTests). */
export async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boolean): Promise<void> {
// --- Tier 3a: the declared default (ollama:bge-m3, open-weight, local). ---
// One cheap probe (≤1.5s, instant ECONNREFUSED when no daemon) per init;
// the resolved choice persists into config.json so no other code path
// ever probes. When Ollama is up with bge-m3 pulled, the default wins
// over every env key — it needs no key, costs nothing, and cannot be
// sunset. See the Default-provider policy in CLAUDE.md.
const {
DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS,
FALLBACK_EMBEDDING_MODEL, FALLBACK_EMBEDDING_DIMENSIONS,
} = await import('../core/ai/defaults.ts');
const { probeOllamaModel } = await import('../core/ai/ollama-detect.ts');
const defaultBareModel = DEFAULT_EMBEDDING_MODEL.split(':')[1];
const probe = await probeOllamaModel(defaultBareModel);
if (probe.ok) {
out.embedding_model = DEFAULT_EMBEDDING_MODEL;
out.embedding_dimensions = DEFAULT_EMBEDDING_DIMENSIONS;
console.error(
`Detected Ollama with ${defaultBareModel}. ` +
`Using ${DEFAULT_EMBEDDING_MODEL} (${DEFAULT_EMBEDDING_DIMENSIONS}d, local, open-weight — the default). ` +
`Override with --embedding-model.`,
);
return;
}
const ready = await groupReadyByProvider('embedding');
const isTTY = !nonInteractive && !!process.stdin.isTTY;
// --- Tier 3b: hosted fallback (loud, never silent). --------------------
// Ollama is unreachable or bge-m3 isn't pulled. Rather than failing the
// install, land on the designated hosted fallback when its key is
// present — and say exactly what that costs and how to get back to the
// default. The `embedding_default_fallback` marker persists to config so
// `gbrain doctor` re-checks for Ollama on every run.
const fallbackProvider = FALLBACK_EMBEDDING_MODEL.split(':')[0];
if (ready.some(p => p.recipeId === fallbackProvider)) {
out.embedding_model = FALLBACK_EMBEDDING_MODEL;
out.embedding_dimensions = FALLBACK_EMBEDDING_DIMENSIONS;
out.embeddingFallback = true;
const why = probe.serverUp
? `Ollama is running but ${defaultBareModel} is not pulled`
: 'Ollama is not reachable';
console.error('');
console.error(`NOTE: gbrain's default embedder is ${DEFAULT_EMBEDDING_MODEL} (local, open-weight), but ${why}.`);
console.error(`Falling back to the hosted ${FALLBACK_EMBEDDING_MODEL} (${FALLBACK_EMBEDDING_DIMENSIONS}d) via OPENAI_API_KEY.`);
console.error('Trade-off: the fallback is noticeably weaker on non-English content (worst multilingual');
console.error('performer among evaluated candidates), and embedding stops working if the key is removed.');
console.error('To move to the default later:');
console.error(` ollama pull ${defaultBareModel} # after installing Ollama from https://ollama.ai`);
console.error(` gbrain migrate embeddings --to ${DEFAULT_EMBEDDING_MODEL} --dim ${DEFAULT_EMBEDDING_DIMENSIONS}`);
console.error(`(same ${FALLBACK_EMBEDDING_DIMENSIONS}d column width — vectors are rebuilt, no schema change.`);
console.error(' `gbrain doctor` will remind you when Ollama becomes available.)');
console.error('');
return;
}
if (ready.length === 1) {
const r = ready[0].recipe;
const tp = r.touchpoints.embedding!;
if (Array.isArray(tp.models) && tp.models.length > 0) {
const model = tp.models[0];
const fullModel = `${r.id}:${model}`;
// When the resolved provider matches the canonical default model
// (DEFAULT_EMBEDDING_MODEL), use the gateway's
// DEFAULT_EMBEDDING_DIMENSIONS instead of the recipe's `default_dims`
// (which is the recipe's "largest sensible" tier). This keeps
// fresh-install schema width aligned with the v0.37.11.0 system
// default — for ZE that means 1280 (the Matryoshka step closest to
// legacy OpenAI 1536), not the recipe's 2560.
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } =
await import('../core/ai/defaults.ts');
const { embeddingDimsForModel } = await import('../core/ai/model-resolver.ts');
// #2051: non-canonical models resolve per-model, not recipe-wide.
// The DEFAULT_EMBEDDING_MODEL check is kept for the day a keyed
// provider becomes the default again; today's default (ollama) never
// appears in `ready` (local-only providers are excluded above) and
// resolves in Tier 3a. The zembed-1 pin preserves the width the
// v0.36v0.42 canonical default gave ZE-key installs (1280, not the
// recipe's 2560) so new ZE brains stay consistent with the migration
// docs until the 2026-09-04 sunset.
const dims = fullModel === DEFAULT_EMBEDDING_MODEL
? DEFAULT_EMBEDDING_DIMENSIONS
: embeddingDimsForModel(r, model);
: fullModel === 'zeroentropyai:zembed-1'
? 1280
: embeddingDimsForModel(r, model);
out.embedding_model = fullModel;
out.embedding_dimensions = dims;
console.error(
@@ -1035,6 +1096,13 @@ async function initPGLite(opts: {
: (resolvedModel && resolvedDim)
? { embedding_model: resolvedModel, embedding_dimensions: resolvedDim }
: {}),
// Fallback marker: records that this install WANTED the default
// (ollama:bge-m3) but landed on the hosted fallback because Ollama
// was unavailable at init. `gbrain doctor` probes Ollama while this
// is set and prints the way back to the default.
...(opts.aiOpts?.embeddingFallback
? { embedding_default_fallback: (await import('../core/ai/defaults.ts')).DEFAULT_EMBEDDING_MODEL }
: {}),
...(opts.aiOpts?.expansion_model ? { expansion_model: opts.aiOpts.expansion_model } : {}),
...(opts.aiOpts?.chat_model ? { chat_model: opts.aiOpts.chat_model } : {}),
// v0.42 (T17): default new brains to the schema_pack selected at init
@@ -1049,6 +1117,13 @@ async function initPGLite(opts: {
// gbrain invocation). mode_prompted=true so the upgrade-time banner doesn't
// also fire on a fresh install. Hands-off: gbrain config set self_upgrade.mode auto
config.self_upgrade = { mode: 'notify', mode_prompted: true, ...(config.self_upgrade ?? {}) };
// Stale-marker hygiene: a re-init that resolves anything other than the
// hosted fallback (e.g. --embedding-model ollama:bge-m3 once Ollama is
// installed) clears the fallback marker so doctor stops re-probing.
if (!opts.aiOpts?.embeddingFallback && config.embedding_default_fallback) {
const { FALLBACK_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
if (config.embedding_model !== FALLBACK_EMBEDDING_MODEL) delete config.embedding_default_fallback;
}
saveConfig(config);
if (opts.schemaPack) {
process.stderr.write(
@@ -1285,6 +1360,13 @@ async function initPostgres(opts: {
: (resolvedModel && resolvedDim)
? { embedding_model: resolvedModel, embedding_dimensions: resolvedDim }
: {}),
// Fallback marker: records that this install WANTED the default
// (ollama:bge-m3) but landed on the hosted fallback because Ollama
// was unavailable at init. `gbrain doctor` probes Ollama while this
// is set and prints the way back to the default.
...(opts.aiOpts?.embeddingFallback
? { embedding_default_fallback: (await import('../core/ai/defaults.ts')).DEFAULT_EMBEDDING_MODEL }
: {}),
...(opts.aiOpts?.expansion_model ? { expansion_model: opts.aiOpts.expansion_model } : {}),
...(opts.aiOpts?.chat_model ? { chat_model: opts.aiOpts.chat_model } : {}),
// v0.42 (T17): same schema_pack default as PGLite path.
@@ -1297,6 +1379,13 @@ async function initPostgres(opts: {
// gbrain invocation). mode_prompted=true so the upgrade-time banner doesn't
// also fire on a fresh install. Hands-off: gbrain config set self_upgrade.mode auto
config.self_upgrade = { mode: 'notify', mode_prompted: true, ...(config.self_upgrade ?? {}) };
// Stale-marker hygiene: a re-init that resolves anything other than the
// hosted fallback (e.g. --embedding-model ollama:bge-m3 once Ollama is
// installed) clears the fallback marker so doctor stops re-probing.
if (!opts.aiOpts?.embeddingFallback && config.embedding_default_fallback) {
const { FALLBACK_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
if (config.embedding_model !== FALLBACK_EMBEDDING_MODEL) delete config.embedding_default_fallback;
}
saveConfig(config);
console.log('Config saved to ~/.gbrain/config.json');
if (opts.schemaPack) {
+30 -5
View File
@@ -472,8 +472,27 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
// `ze_sunset_notice_shown` (same pattern as the search-mode banner).
try {
const shown = await engine.getConfig('ze_sunset_notice_shown');
const { DEFAULT_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
const effectiveModel = cfgSchema.embedding_model ?? DEFAULT_EMBEDDING_MODEL;
const {
DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS,
FALLBACK_EMBEDDING_MODEL,
} = await import('../core/ai/defaults.ts');
// DEFAULT_EMBEDDING_MODEL is no longer a ZE model, so the file
// plane alone would stop detecting brains created under the
// v0.36v0.42 ZE default that never wrote `embedding_model` to
// ~/.gbrain/config.json. Those brains DO carry the DB-plane row
// seeded by initSchema, so read the DB plane as the second source
// before falling back to the compiled default.
const dbModel = await engine.getConfig('embedding_model');
const effectiveModel = cfgSchema.embedding_model ?? dbModel ?? DEFAULT_EMBEDDING_MODEL;
// Current width, for the keep-your-column hosted option
// (applyEmbeddingMigration only runs a schema transition when
// col.dims !== plan.to_dims — migrating AT the current width
// rebuilds vectors only).
const dbDims = await engine.getConfig('embedding_dimensions');
const parsedDbDims = dbDims ? parseInt(dbDims, 10) : NaN;
const currentDims = cfgSchema.embedding_dimensions
?? (Number.isFinite(parsedDbDims) && parsedDbDims > 0 ? parsedDbDims : undefined)
?? 1280; // the v0.36v0.42 ZE default width
const rerankerModel = await engine.getConfig('search.reranker.model');
const onZeEmbedding = effectiveModel.startsWith('zeroentropyai:');
const onZeReranker = !!rerankerModel?.startsWith('zeroentropyai:');
@@ -492,9 +511,15 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
}
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
console.log('Migrate before the sunset (resumable; preview cost first):');
console.log(' gbrain migrate embeddings --to <provider:model> --dry-run');
console.log(' gbrain migrate embeddings --to <provider:model>');
console.log('Migrate before 2026-09-04 (resumable; preview cost first with --dry-run):');
console.log('');
console.log(`Option A — the default (open-weight, local, free; requires Ollama +`);
console.log(`\`ollama pull ${DEFAULT_EMBEDDING_MODEL.split(':')[1]}\`; changes column width ${currentDims}${DEFAULT_EMBEDDING_DIMENSIONS}):`);
console.log(` gbrain migrate embeddings --to ${DEFAULT_EMBEDDING_MODEL} --dim ${DEFAULT_EMBEDDING_DIMENSIONS}`);
console.log('');
console.log(`Option B — hosted (needs OPENAI_API_KEY; keeps your vector(${currentDims})`);
console.log('column and HNSW index — vectors rebuilt only; weaker on non-English content):');
console.log(` gbrain migrate embeddings --to ${FALLBACK_EMBEDDING_MODEL} --dim ${currentDims}`);
console.log('');
console.log('Self-hosting zembed-1 (weights are Apache-2.0) via llama-server /');
console.log('ollama also works and preserves your existing vectors — point');
+13 -5
View File
@@ -36,14 +36,22 @@ export const collectSetupSmells: AdvisorCollector = {
collector: 'setup-smells',
ask_user: true,
});
} else if (!cfg.embedding_model && !cfg.zeroentropy_api_key && !process.env.ZEROENTROPY_API_KEY) {
// Default provider needs a key; none present anywhere → embeds will fail.
} else if (!cfg.embedding_model && !cfg.openai_api_key && !process.env.OPENAI_API_KEY) {
// No embedding_model configuredthe compiled default (ollama:bge-m3)
// applies at embed time, which needs a running Ollama daemon with the
// model pulled. Post-v0.37 installs always persist embedding_model at
// init, so landing here usually means setup never completed. No key
// for the hosted fallback either → flag it. (Deliberately no network
// probe here — advisor collectors stay cheap; `gbrain doctor` probes.)
findings.push({
id: 'embedding_key_missing',
severity: 'warn',
title: 'No embedding provider key is set — embedding will fail at write time.',
detail: 'Set zeroentropy_api_key (or choose another provider via embedding_model).',
fix: { command_argv: ['gbrain', 'config', 'set', 'zeroentropy_api_key', '<key>'] },
title: 'No embedding provider configured — embedding may fail at write time.',
detail:
'The default (ollama:bge-m3) needs Ollama running with the model pulled ' +
'(`ollama pull bge-m3`). Alternatively set openai_api_key for the hosted ' +
'fallback, or pick a provider via embedding_model. Run `gbrain doctor` to verify.',
fix: { command_argv: ['gbrain', 'doctor'] },
collector: 'setup-smells',
ask_user: true,
});
+34 -7
View File
@@ -12,10 +12,37 @@
* install AND every doctor consistency check.
*/
// v0.36.0 chose ZeroEntropy as the system default after evals showed
// 11/20 wins vs OpenAI (6) and Voyage (4) on real-corpus benchmarks.
// 1280 is the closest analog to legacy OpenAI 1536d while staying on
// the high-recall section of ZE's Matryoshka curve. Valid ZE Matryoshka
// steps: {2560, 1280, 640, 320, 160, 80, 40} — see ai/dims.ts.
export const DEFAULT_EMBEDDING_MODEL = 'zeroentropyai:zembed-1';
export const DEFAULT_EMBEDDING_DIMENSIONS = 1280;
// The default moved OFF ZeroEntropy (its hosted API — including
// /models/embed — shuts down 2026-09-04, which would have taken semantic
// retrieval with it on every default-config brain). See the
// Default-provider policy in CLAUDE.md: a gbrain DEFAULT must be
// open-weight or from the vendor with the longest proven model-lifetime
// record. bge-m3 is open-weight (MIT), served locally through Ollama —
// nobody can sunset it — and it is the strongest open-weight multilingual
// retriever in the 8-candidate eval that drove this choice (vector
// nDCG@10 ≥ 0.89 on every language slice tested, including the
// non-Latin-script slices where hosted small models collapse).
//
// 1024 is bge-m3's NATIVE width (see the ollama recipe's model_dims).
// Do not "round up": the Matryoshka free-truncation property measured
// for other families was NOT tested for bge-m3.
export const DEFAULT_EMBEDDING_MODEL = 'ollama:bge-m3';
export const DEFAULT_EMBEDDING_DIMENSIONS = 1024;
// Hosted fallback when Ollama is unreachable (or bge-m3 isn't pulled) at
// `gbrain init` time. text-embedding-3-small is the key most users
// already have (OPENAI_API_KEY), cheap ($0.02/MTok), and from the vendor
// with the longest hosted-embedding lifetime record — but it is the
// WEAKEST multilingual performer among the evaluated candidates
// (nDCG@10 0.645 on the Hebrew slice vs bge-m3's 0.901). That is why the
// fallback is loud, never silent: init prints the trade-off + the path
// back to the default, and `gbrain doctor` re-checks for Ollama.
//
// 1024 (not the model's native 1536, and not the legacy 1280): OpenAI
// text-embedding-3-* is Matryoshka (`isValidOpenAITextEmbedding3Dim`
// accepts any width ≤ native), so pinning the fallback at bge-m3's width
// means a later `gbrain migrate embeddings --to ollama:bge-m3 --dim 1024`
// rebuilds VECTORS only — the vector(1024) column and its HNSW index
// stay in place, no dimension transition.
export const FALLBACK_EMBEDDING_MODEL = 'openai:text-embedding-3-small';
export const FALLBACK_EMBEDDING_DIMENSIONS = 1024;
+65
View File
@@ -0,0 +1,65 @@
/**
* Cheap, non-blocking Ollama availability probe for the default embedding
* model (`ollama:bge-m3`).
*
* Called exactly ONCE per `gbrain init` (the resolved choice persists into
* config.json, so embed calls never probe) and on demand by
* `gbrain doctor`'s fallback re-check. Bounded by a short timeout and
* fail-open: any error means "not available", never a thrown exception —
* a probe bug must not break an install.
*
* Leaf module (no SDK imports) so init/doctor can load it without pulling
* the full gateway.
*/
export interface OllamaProbeResult {
/** Server reachable AND the model is pulled. */
ok: boolean;
/** Server responded to /api/tags at all. */
serverUp: boolean;
reason: 'ok' | 'model_missing' | 'unreachable';
}
/**
* Ollama's native API base (NOT the /v1 OpenAI-compat suffix the recipe's
* base_url_default carries). Honors OLLAMA_BASE_URL — the same env var the
* gateway's openai-compat transport uses — with any trailing `/v1` stripped
* so both spellings work.
*/
export function ollamaApiBase(env: NodeJS.ProcessEnv = process.env): string {
const raw = env.OLLAMA_BASE_URL?.trim() || 'http://localhost:11434';
return raw.replace(/\/+$/, '').replace(/\/v1$/, '');
}
/**
* Probe `{base}/api/tags` and check the given model is pulled. Matches
* bare names against Ollama's `name:tag` form (`bge-m3` matches
* `bge-m3:latest` and `bge-m3:567m`).
*/
/** Test seam (same pattern as gateway's __setEmbedTransportForTests). */
let probeOverride: ((model: string) => Promise<OllamaProbeResult>) | null = null;
export function __setOllamaProbeForTests(fn: typeof probeOverride): void {
probeOverride = fn;
}
export async function probeOllamaModel(
model: string,
opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
): Promise<OllamaProbeResult> {
if (probeOverride) return probeOverride(model);
const base = ollamaApiBase(opts.env ?? process.env);
try {
const res = await fetch(`${base}/api/tags`, {
signal: AbortSignal.timeout(opts.timeoutMs ?? 1500),
});
if (!res.ok) return { ok: false, serverUp: false, reason: 'unreachable' };
const body = (await res.json()) as { models?: Array<{ name?: string }> };
const names = (body.models ?? []).map(m => m.name ?? '');
const has = names.some(n => n === model || n.split(':')[0] === model);
return has
? { ok: true, serverUp: true, reason: 'ok' }
: { ok: false, serverUp: true, reason: 'model_missing' };
} catch {
return { ok: false, serverUp: false, reason: 'unreachable' };
}
}
+10 -1
View File
@@ -69,9 +69,18 @@ export interface GBrainConfig {
azure_openai_endpoint?: string;
azure_openai_deployment?: string;
azure_openai_use_entra?: string;
/** AI gateway config (v0.14+). v0.36+ default: "zeroentropyai:zembed-1" / 1280 / "anthropic:claude-haiku-4-5-20251001". */
/** AI gateway config (v0.14+). Default: "ollama:bge-m3" / 1024 / "anthropic:claude-haiku-4-5-20251001" (see src/core/ai/defaults.ts). */
embedding_model?: string;
embedding_dimensions?: number;
/**
* Set by `gbrain init` when the declared default embedder (ollama:bge-m3)
* was unavailable and init landed on the hosted fallback instead. Holds
* the default model that was skipped. While set AND embedding_model still
* equals the fallback, `gbrain doctor` probes Ollama and prints the
* migrate command back to the default. Cleared by a re-init that resolves
* anything other than the fallback.
*/
embedding_default_fallback?: string;
/**
* v0.37 (D9): user opted into deferred-setup mode at init time via
* `gbrain init --no-embedding`. When true, embed callsites and `gbrain
+4
View File
@@ -26,6 +26,10 @@ export interface EmbeddingPricing {
* gateway model strings (e.g. 'openai:text-embedding-3-large').
*/
export const EMBEDDING_PRICING: Record<string, EmbeddingPricing> = {
// The system default (src/core/ai/defaults.ts): local Ollama, no API cost.
// Listed so migrate/upgrade cost previews for the default show $0 rather
// than "estimate unavailable".
'ollama:bge-m3': { pricePerMTok: 0 },
// OpenAI (https://openai.com/api/pricing/, verified 2026-05-11)
'openai:text-embedding-3-large': { pricePerMTok: 0.13 },
'openai:text-embedding-3-small': { pricePerMTok: 0.02 },
+8 -6
View File
@@ -41,13 +41,15 @@ describe('gateway configuration', () => {
expect(getExpansionModel()).toBe('anthropic:claude-haiku-4-5-20251001');
});
test('defaults are ZE 1280d as of v0.36.0.0 (D3)', () => {
// The default flipped from openai:text-embedding-3-large 1536d to
// zeroentropyai:zembed-1 1280d in v0.36.0.0. The cost story is in
// CHANGELOG.md; the rationale lives in src/core/ai/gateway.ts:45-54.
test('defaults are ollama:bge-m3 1024d (open-weight default, ZE sunset)', () => {
// v0.36.0.0 flipped the default to zeroentropyai:zembed-1 @ 1280d. With
// ZE's hosted API sunsetting 2026-09-04, the default moved to the
// open-weight ollama:bge-m3 at its native 1024d — a model nobody can
// sunset. Rationale + hosted-fallback policy in src/core/ai/defaults.ts
// and CLAUDE.md's Default-provider policy.
configureGateway({ env: {} });
expect(getEmbeddingModel()).toBe('zeroentropyai:zembed-1');
expect(getEmbeddingDimensions()).toBe(1280);
expect(getEmbeddingModel()).toBe('ollama:bge-m3');
expect(getEmbeddingDimensions()).toBe(1024);
expect(getExpansionModel()).toBe('anthropic:claude-haiku-4-5-20251001');
});
});
+4 -4
View File
@@ -3,15 +3,15 @@ import { getPGLiteSchema, PGLITE_SCHEMA_SQL } from '../../src/core/pglite-schema
import { getPostgresSchema } from '../../src/core/postgres-engine.ts';
describe('getPGLiteSchema', () => {
test('default produces gateway-default schema (v0.37+: 1280d + zeroentropyai:zembed-1)', () => {
test('default produces gateway-default schema (1024d + ollama:bge-m3)', () => {
// v0.37 fix wave Lane A.1 + CDX2-1: defaults now track the canonical
// gateway constants in `ai/defaults.ts` instead of the stale v0.13
// OpenAI literals (1536 / text-embedding-3-large). Fixes the
// headline bug where bare `gbrain init --pglite` produced a 1536
// schema while the ZE default model emitted 1280-dim vectors.
// schema while the default model emitted a different width.
const sql = getPGLiteSchema();
expect(sql).toMatch(/vector\(1280\)/);
expect(sql).toMatch(/'zeroentropyai:zembed-1'/);
expect(sql).toMatch(/vector\(1024\)/);
expect(sql).toMatch(/'ollama:bge-m3'/);
expect(sql).not.toMatch(/__EMBEDDING_DIMS__/);
expect(sql).not.toMatch(/__EMBEDDING_MODEL__/);
});
+24 -16
View File
@@ -26,36 +26,44 @@ import {
describe('E2E: fresh gbrain init --pglite → import → embed works end-to-end', () => {
let tmpHome: string;
let origHome: string | undefined;
let origZeKey: string | undefined;
let origOpenaiKey: string | undefined;
let origVoyageKey: string | undefined;
let origOllamaUrl: string | undefined;
let fakeOllama: ReturnType<typeof Bun.serve> | null = null;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-e2e-fresh-'));
origHome = process.env.GBRAIN_HOME;
origZeKey = process.env.ZEROENTROPY_API_KEY;
// Save + clear OPENAI_API_KEY + VOYAGE_API_KEY so init only sees
// one provider as env-ready (ZE). Without this, dev machines with
// multi-provider env (Garry's setup) fail init's disambiguation gate
// ("Multiple embedding providers env-ready: openai, voyage,
// zeroentropyai") before the test body runs.
// The default embedder is ollama:bge-m3, resolved via a one-shot
// /api/tags probe at init. Serve a fake Ollama daemon so the bare-init
// happy path is hermetic and deterministic regardless of whether the
// dev machine runs a real Ollama (or has hosted provider keys set —
// the probe wins before env-key detection runs, so no key clearing
// is needed beyond OPENAI_API_KEY hygiene for the embed-check path).
fakeOllama = Bun.serve({
port: 0,
fetch(req) {
if (new URL(req.url).pathname === '/api/tags') {
return Response.json({ models: [{ name: 'bge-m3:latest' }] });
}
return new Response('not found', { status: 404 });
},
});
origOllamaUrl = process.env.OLLAMA_BASE_URL;
process.env.OLLAMA_BASE_URL = `http://127.0.0.1:${fakeOllama.port}`;
origOpenaiKey = process.env.OPENAI_API_KEY;
origVoyageKey = process.env.VOYAGE_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.VOYAGE_API_KEY;
process.env.GBRAIN_HOME = tmpHome;
// Stub key so init's setup-hint check passes.
process.env.ZEROENTROPY_API_KEY = 'sk-test-ze';
});
afterEach(() => {
fakeOllama?.stop(true);
fakeOllama = null;
rmSync(tmpHome, { recursive: true, force: true });
if (origHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = origHome;
if (origZeKey === undefined) delete process.env.ZEROENTROPY_API_KEY;
else process.env.ZEROENTROPY_API_KEY = origZeKey;
if (origOllamaUrl === undefined) delete process.env.OLLAMA_BASE_URL;
else process.env.OLLAMA_BASE_URL = origOllamaUrl;
if (origOpenaiKey !== undefined) process.env.OPENAI_API_KEY = origOpenaiKey;
if (origVoyageKey !== undefined) process.env.VOYAGE_API_KEY = origVoyageKey;
__setEmbedTransportForTests(null);
// Restore legacy-preload gateway state.
configureGateway({
@@ -65,7 +73,7 @@ describe('E2E: fresh gbrain init --pglite → import → embed works end-to-end'
});
});
test('bare `init --pglite`: schema sized to gateway defaults (ZE/1280)', async () => {
test('bare `init --pglite`: schema sized to gateway defaults (ollama:bge-m3/1024)', async () => {
// Reset gateway so init.ts has to resolve defaults from
// ai/defaults.ts. This is the actual production code path for a
// fresh install: bare `gbrain init --pglite` with no env or file
+20 -13
View File
@@ -58,6 +58,11 @@ function makeTempHome(): string {
return mkdtempSync(join(tmpdir(), 'gbrain-e2e-init-'));
}
// Pin the Ollama probe at a dead port so env-detection tests are
// deterministic on machines that run a real Ollama daemon (the default
// embedder ollama:bge-m3 would otherwise win over every env key).
const DEAD_OLLAMA = { OLLAMA_BASE_URL: 'http://127.0.0.1:9' };
// ============================================================================
describe('v0.37 T12 — fresh init env-detection (D1, D2, D3) + persistence (D5)', () => {
@@ -66,23 +71,25 @@ describe('v0.37 T12 — fresh init env-detection (D1, D2, D3) + persistence (D5)
beforeAll(() => { tmpHome = makeTempHome(); });
afterAll(() => { rmSync(tmpHome, { recursive: true, force: true }); });
test('OPENAI_API_KEY auto-picks OpenAI, persists embedding_model + embedding_dimensions', async () => {
test('OPENAI_API_KEY with Ollama absent lands on the hosted fallback, loudly', async () => {
const r = await runCli(['init', '--pglite'], {
gbrainHome: tmpHome,
env: { OPENAI_API_KEY: 'sk-test-only-for-init-resolution-NOT-CALLED' },
env: { OPENAI_API_KEY: 'sk-test-only-for-init-resolution-NOT-CALLED', GBRAIN_INIT_SKIP_EMBED_CHECK: '1', ...DEAD_OLLAMA },
});
// Init may or may not succeed (depends on whether OpenAI key is real for
// any side effect — but init.ts has no live embed call, just config
// writes + schema). Assert the auto-pick stderr notice fired.
expect(r.stderr).toMatch(/Detected OPENAI_API_KEY|Using openai:text-embedding-3-large/);
// The declared default is ollama:bge-m3; with Ollama unreachable and an
// OpenAI key present, init lands on the designated hosted fallback —
// and says so (never a silent downgrade).
expect(r.stderr).toMatch(/default embedder is ollama:bge-m3/);
expect(r.stderr).toMatch(/Falling back to the hosted openai:text-embedding-3-small/);
expect(r.exitCode).toBe(0);
// Config persisted with the right embedding fields.
// Config persisted with the fallback + the doctor-recheck marker.
const cfgPath = join(tmpHome, '.gbrain', 'config.json');
expect(existsSync(cfgPath)).toBe(true);
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
expect(cfg.embedding_model).toBe('openai:text-embedding-3-large');
expect(cfg.embedding_dimensions).toBe(1536);
expect(cfg.embedding_model).toBe('openai:text-embedding-3-small');
expect(cfg.embedding_dimensions).toBe(1024);
expect(cfg.embedding_default_fallback).toBe('ollama:bge-m3');
expect(cfg.engine).toBe('pglite');
}, 240000);
});
@@ -98,13 +105,13 @@ describe('v0.37 T12 — D3 non-TTY no-key fail-loud', () => {
test('--non-interactive with zero provider keys → exit 1 + paste-ready hint', async () => {
const r = await runCli(['init', '--pglite', '--non-interactive'], {
gbrainHome: tmpHome,
env: {}, // no provider keys
env: { ...DEAD_OLLAMA }, // no provider keys, no Ollama
});
expect(r.exitCode).toBe(1);
// Fail-loud message includes the canonical env var list.
// Fail-loud message leads with the default and lists hosted keys.
expect(r.stderr).toContain('No embedding provider configured');
expect(r.stderr).toContain('ollama pull bge-m3');
expect(r.stderr).toContain('OPENAI_API_KEY');
expect(r.stderr).toContain('ZEROENTROPY_API_KEY');
expect(r.stderr).toContain('VOYAGE_API_KEY');
// Suggests --no-embedding alternative.
expect(r.stderr).toContain('--no-embedding');
@@ -113,7 +120,7 @@ describe('v0.37 T12 — D3 non-TTY no-key fail-loud', () => {
test('--non-interactive with env-key typo surfaces Levenshtein hint', async () => {
const r = await runCli(['init', '--pglite', '--non-interactive'], {
gbrainHome: tmpHome,
env: { OPENAPI_API_KEY: 'sk-test-typo' },
env: { OPENAPI_API_KEY: 'sk-test-typo', ...DEAD_OLLAMA },
});
expect(r.exitCode).toBe(1);
// D13 typo detection: surfaces "did you mean OPENAI_API_KEY"
+254
View File
@@ -0,0 +1,254 @@
/**
* Default-embedder swap: ollama:bge-m3 @ 1024 with a loud hosted fallback.
*
* ZeroEntropy's hosted API (the previous default) sunsets 2026-09-04. The
* new default is open-weight + local (cannot be sunset); when Ollama is
* unreachable at `gbrain init`, init lands on the hosted fallback
* (openai:text-embedding-3-small @ 1024) loudly, with the way back and
* persists the `embedding_default_fallback` marker so `gbrain doctor`
* re-checks for Ollama on every run.
*
* Reachability is stubbed via `__setOllamaProbeForTests` (no fake daemon);
* the probe's own network behavior is covered only for the no-server case
* (dead port fail-open), which needs no listener.
*
* Master-discrimination: the "declared default" and "fallback resolution"
* tests fail BEHAVIORALLY on pre-swap code (wrong model/dims resolved, no
* marker, no notice) see also test/e2e/init-fresh-pglite.test.ts, whose
* updated subprocess tests prove the same through the real CLI.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { withEnv } from './helpers/with-env.ts';
import {
__setOllamaProbeForTests,
probeOllamaModel,
ollamaApiBase,
type OllamaProbeResult,
} from '../src/core/ai/ollama-detect.ts';
/**
* withEnv overrides that clear every embedding-provider auth key
* (enumerated from the recipe registry, not hardcoded) so resolution is
* deterministic on dev machines with ambient keys.
*/
async function embeddingKeyClears(): Promise<Record<string, undefined>> {
const { RECIPES } = await import('../src/core/ai/recipes/index.ts');
const overrides: Record<string, undefined> = {};
for (const recipe of RECIPES.values()) {
if (!recipe.touchpoints.embedding) continue;
for (const key of recipe.auth_env?.required ?? []) overrides[key] = undefined;
}
return overrides;
}
describe('declared default: ollama:bge-m3 @ 1024', () => {
test('DEFAULT_EMBEDDING_MODEL / DIMENSIONS are ollama:bge-m3 @ 1024', async () => {
const defaults = await import('../src/core/ai/defaults.ts');
expect(defaults.DEFAULT_EMBEDDING_MODEL).toBe('ollama:bge-m3');
// bge-m3's NATIVE width. Matryoshka free-truncation was measured for
// other families, not bge-m3 — do not "round" this in either direction.
expect(defaults.DEFAULT_EMBEDDING_DIMENSIONS).toBe(1024);
});
test('fallback is openai:text-embedding-3-small @ 1024 (same width as the default)', async () => {
const defaults = await import('../src/core/ai/defaults.ts');
expect(defaults.FALLBACK_EMBEDDING_MODEL).toBe('openai:text-embedding-3-small');
// 1024, not the model's native 1536: pinning the fallback at bge-m3's
// width makes the later fallback→default migration a vector-only
// rebuild (no column ALTER, no HNSW rebuild). Valid because OpenAI
// text-embedding-3-* accepts any Matryoshka width ≤ native.
expect(defaults.FALLBACK_EMBEDDING_DIMENSIONS).toBe(1024);
const { isValidOpenAITextEmbedding3Dim } = await import('../src/core/ai/dims.ts');
expect(isValidOpenAITextEmbedding3Dim('text-embedding-3-small', 1024)).toBe(true);
});
test('resolveSchemaEmbeddingDim ACCEPTS both the default and the fallback config', async () => {
const { resolveSchemaEmbeddingDim } = await import('../src/core/embedding-dim-check.ts');
const {
DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS,
FALLBACK_EMBEDDING_MODEL, FALLBACK_EMBEDDING_DIMENSIONS,
} = await import('../src/core/ai/defaults.ts');
for (const [model, dims] of [
[DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS],
[FALLBACK_EMBEDDING_MODEL, FALLBACK_EMBEDDING_DIMENSIONS],
] as const) {
const got = resolveSchemaEmbeddingDim({ embedding_model: model, embedding_dimensions: dims });
expect(got.ok).toBe(true);
if (got.ok) expect(got.dim).toBe(dims);
}
});
test('the default costs $0 in the embedding price table', async () => {
const { lookupEmbeddingPrice } = await import('../src/core/embedding-pricing.ts');
const { DEFAULT_EMBEDDING_MODEL } = await import('../src/core/ai/defaults.ts');
const price = lookupEmbeddingPrice(DEFAULT_EMBEDDING_MODEL);
expect(price.kind).toBe('known');
if (price.kind === 'known') expect(price.pricePerMTok).toBe(0);
});
});
describe('ollama probe (no daemon involved)', () => {
test('ollamaApiBase strips /v1 and trailing slashes from OLLAMA_BASE_URL', () => {
expect(ollamaApiBase({} as NodeJS.ProcessEnv)).toBe('http://localhost:11434');
expect(ollamaApiBase({ OLLAMA_BASE_URL: 'http://box:11434/v1' } as NodeJS.ProcessEnv)).toBe('http://box:11434');
expect(ollamaApiBase({ OLLAMA_BASE_URL: 'http://box:11434/' } as NodeJS.ProcessEnv)).toBe('http://box:11434');
});
test('unreachable server → fail-open {ok:false, serverUp:false}, never a throw', async () => {
const res = await probeOllamaModel('bge-m3', {
env: { OLLAMA_BASE_URL: 'http://127.0.0.1:9' } as NodeJS.ProcessEnv,
timeoutMs: 800,
});
expect(res.ok).toBe(false);
expect(res.serverUp).toBe(false);
expect(res.reason).toBe('unreachable');
});
});
describe('init embedding resolution (probe stubbed)', () => {
afterEach(() => {
__setOllamaProbeForTests(null);
});
/** Run resolveEmbeddingByEnv with stubbed probe + controlled env, capturing stderr. */
async function resolveWith(
probe: OllamaProbeResult,
envKeys: Record<string, string>,
): Promise<{ out: import('../src/commands/init.ts').ResolvedAIOptions; notice: string }> {
__setOllamaProbeForTests(async () => probe);
const clears = await embeddingKeyClears();
const errLines: string[] = [];
const origError = console.error;
console.error = (...args: unknown[]) => { errLines.push(args.join(' ')); };
try {
return await withEnv({ ...clears, ...envKeys }, async () => {
const { resolveEmbeddingByEnv } = await import('../src/commands/init.ts');
const out: import('../src/commands/init.ts').ResolvedAIOptions = {};
await resolveEmbeddingByEnv(out, /* nonInteractive */ true);
return { out, notice: errLines.join('\n') };
});
} finally {
console.error = origError;
}
}
test('happy path: Ollama + bge-m3 available → the default wins, even over env keys', async () => {
// A hosted key present must NOT shadow the default.
const { out, notice } = await resolveWith(
{ ok: true, serverUp: true, reason: 'ok' },
{ OPENAI_API_KEY: 'sk-test' },
);
expect(out.embedding_model).toBe('ollama:bge-m3');
expect(out.embedding_dimensions).toBe(1024);
expect(out.embeddingFallback).toBeUndefined();
expect(notice).toContain('Detected Ollama with bge-m3');
});
test('fallback path: Ollama absent + OPENAI_API_KEY → hosted fallback, marker, LOUD notice', async () => {
const { out, notice } = await resolveWith(
{ ok: false, serverUp: false, reason: 'unreachable' },
{ OPENAI_API_KEY: 'sk-test' },
);
expect(out.embedding_model).toBe('openai:text-embedding-3-small');
expect(out.embedding_dimensions).toBe(1024);
expect(out.embeddingFallback).toBe(true);
// Visible, not silent: names the default, the reason, the trade-off,
// and the paste-ready way back.
expect(notice).toContain('default embedder is ollama:bge-m3');
expect(notice).toContain('Ollama is not reachable');
expect(notice).toContain('weaker on non-English content');
expect(notice).toContain('ollama pull bge-m3');
expect(notice).toContain('gbrain migrate embeddings --to ollama:bge-m3 --dim 1024');
});
test('fallback notice distinguishes "running but model not pulled"', async () => {
const { out, notice } = await resolveWith(
{ ok: false, serverUp: true, reason: 'model_missing' },
{ OPENAI_API_KEY: 'sk-test' },
);
expect(out.embeddingFallback).toBe(true);
expect(notice).toContain('running but bge-m3 is not pulled');
});
test('no Ollama, no OpenAI key, one other provider key → existing single-key auto-pick unchanged', async () => {
const { out } = await resolveWith(
{ ok: false, serverUp: false, reason: 'unreachable' },
{ VOYAGE_API_KEY: 'pa-test' },
);
expect(out.embedding_model?.startsWith('voyage:')).toBe(true);
expect(out.embeddingFallback).toBeUndefined();
});
});
describe('doctor re-check: embedding_default_fallback', () => {
afterEach(() => {
__setOllamaProbeForTests(null);
});
/** Write a config.json into a throw-away GBRAIN_HOME and run the check there. */
async function checkWith(
cfg: Record<string, unknown>,
probe: OllamaProbeResult | null,
): Promise<{ status: string; message: string }> {
if (probe) __setOllamaProbeForTests(async () => probe);
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-fallback-doctor-'));
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
writeFileSync(join(tmpHome, '.gbrain', 'config.json'), JSON.stringify(cfg));
try {
return await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const { checkEmbeddingDefaultFallback } = await import('../src/commands/doctor.ts');
return checkEmbeddingDefaultFallback({} as never);
});
} finally {
rmSync(tmpHome, { recursive: true, force: true });
}
}
test('warns with the migrate command once Ollama becomes available', async () => {
const check = await checkWith({
engine: 'pglite',
embedding_model: 'openai:text-embedding-3-small',
embedding_dimensions: 1024,
embedding_default_fallback: 'ollama:bge-m3',
}, { ok: true, serverUp: true, reason: 'ok' });
expect(check.status).toBe('warn');
expect(check.message).toContain('gbrain migrate embeddings --to ollama:bge-m3 --dim 1024');
});
test('stays ok (informational) while Ollama is still unavailable', async () => {
const check = await checkWith({
engine: 'pglite',
embedding_model: 'openai:text-embedding-3-small',
embedding_dimensions: 1024,
embedding_default_fallback: 'ollama:bge-m3',
}, { ok: false, serverUp: false, reason: 'unreachable' });
expect(check.status).toBe('ok');
expect(check.message).toContain('hosted embedding fallback');
});
test('stale marker (user moved off the fallback) is ignored', async () => {
// Probe stubbed "available" to prove the staleness guard short-circuits
// before the probe even matters.
const check = await checkWith({
engine: 'pglite',
embedding_model: 'voyage:voyage-3-large',
embedding_dimensions: 1024,
embedding_default_fallback: 'ollama:bge-m3',
}, { ok: true, serverUp: true, reason: 'ok' });
expect(check.status).toBe('ok');
expect(check.message).toContain('stale');
});
test('no marker → skip', async () => {
const check = await checkWith(
{ engine: 'pglite', embedding_model: 'openai:text-embedding-3-small' },
null,
);
expect(check.status).toBe('ok');
expect(check.message).toContain('skip');
});
});
+17 -12
View File
@@ -19,28 +19,30 @@ describe('v0.37 Lane A — defaults sweep', () => {
// CDX2-1: these were file-private const; Lane A consumers (schema
// helpers, registry) need them exported. Importing here is the test.
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } = await import('../src/core/ai/gateway.ts');
expect(DEFAULT_EMBEDDING_MODEL).toBe('zeroentropyai:zembed-1');
expect(DEFAULT_EMBEDDING_DIMENSIONS).toBe(1280);
expect(DEFAULT_EMBEDDING_MODEL).toBe('ollama:bge-m3');
expect(DEFAULT_EMBEDDING_DIMENSIONS).toBe(1024);
});
test('A.0: ai/defaults.ts is the canonical source (leaf module, no SDK pulls)', async () => {
const defaults = await import('../src/core/ai/defaults.ts');
expect(defaults.DEFAULT_EMBEDDING_MODEL).toBe('zeroentropyai:zembed-1');
expect(defaults.DEFAULT_EMBEDDING_DIMENSIONS).toBe(1280);
expect(defaults.DEFAULT_EMBEDDING_MODEL).toBe('ollama:bge-m3');
expect(defaults.DEFAULT_EMBEDDING_DIMENSIONS).toBe(1024);
});
// T-11 / T-12: registry + schema defaults track gateway constants.
test('A.1: getPGLiteSchema() default-args produce a vector(1280) column', async () => {
test('A.1: getPGLiteSchema() default-args produce a vector(1024) column', async () => {
const { getPGLiteSchema } = await import('../src/core/pglite-schema.ts');
const sql = getPGLiteSchema(); // no args — uses defaults
expect(sql).toContain('vector(1280)');
expect(sql).toContain('vector(1024)');
expect(sql).toContain("'ollama:bge-m3'");
expect(sql).not.toContain('vector(1536)');
});
test('A.2: getPostgresSchema() default-args produce a vector(1280) column', async () => {
test('A.2: getPostgresSchema() default-args produce a vector(1024) column', async () => {
const { getPostgresSchema } = await import('../src/core/postgres-engine.ts');
const sql = getPostgresSchema();
expect(sql).toContain('vector(1280)');
expect(sql).toContain('vector(1024)');
expect(sql).toContain("'ollama:bge-m3'");
expect(sql).not.toContain('vector(1536)');
});
@@ -48,11 +50,14 @@ describe('v0.37 Lane A — defaults sweep', () => {
const { getPostgresSchema } = await import('../src/core/postgres-engine.ts');
const sql = getPostgresSchema(2048, 'voyage:voyage-4-large');
expect(sql).toContain('vector(2048)');
expect(sql).not.toContain('vector(1280)');
// The default model must not leak through when overridden. (Width 1024
// can't be asserted absent — the schema carries a fixed vector(1024)
// auxiliary embedding column unrelated to the default.)
expect(sql).not.toContain("'ollama:bge-m3'");
expect(sql).toContain('voyage:voyage-4-large');
});
test('A.5: embedding-column registry builtin defaults to ZE/1280 on empty config + gateway', async () => {
test('A.5: embedding-column registry builtin defaults to ollama/1024 on empty config + gateway', async () => {
// The registry's resolution chain is cfg > gateway > DEFAULT. With
// no cfg AND no gateway, it should fall through to the canonical
// default (ZE/1280). Hard-unconfigure first to exercise that path —
@@ -63,8 +68,8 @@ describe('v0.37 Lane A — defaults sweep', () => {
try {
const reg = getEmbeddingColumnRegistry({ engine: 'pglite' } as any);
expect(reg['embedding']).toBeDefined();
expect(reg['embedding'].provider).toBe('zeroentropyai:zembed-1');
expect(reg['embedding'].dimensions).toBe(1280);
expect(reg['embedding'].provider).toBe('ollama:bge-m3');
expect(reg['embedding'].dimensions).toBe(1024);
} finally {
// Restore the preload's legacy baseline so the rest of the file's
// tests (and subsequent files in this shard) see a configured gateway.