mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9eaebc383 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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.36–v0.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
@@ -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.36–v0.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.36–v0.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');
|
||||
|
||||
@@ -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 configured → the 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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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__/);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user