diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca2fa314..bda13c827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,22 @@ complete operation catalog — both speak the verbs). Run `gbrain protocol confo to self-certify, and `gbrain protocol stats` to watch adoption. Memories your agent saves are readable by every agent connected to the brain by default; pass `visibility: "private"` for local-only facts. +## [0.42.58.0] - 2026-07-06 + +**gbrain now runs cleanly on the stack you already have — a local Ollama box, a self-hosted LiteLLM proxy, llama.cpp's llama-server, or gbrain running as a Claude Code MCP subprocess — instead of silently degrading or hard-failing when you're not on a raw OpenAI/Anthropic key.** A provider-agnostic plumbing pass across the AI gateway: environment handling, base-URL normalization, and embedding-dimension validation all stop tripping on the non-frontier-vendor setups that used to fail without a clear signal. + +### Fixed +- **Running gbrain as a Claude Code MCP subprocess no longer breaks every AI call.** Some hosts inject an empty `ANTHROPIC_API_KEY` into the subprocess environment; that empty value used to override a real key set in your `~/.gbrain/config.json`, so every gateway call failed with a missing-key error. Empty environment values no longer clobber a configured key. (#1249) +- **A custom Anthropic or OpenAI base URL without a `/v1` suffix no longer 404s.** When the base URL comes from the environment as a bare host, gbrain normalizes it before the call instead of posting to a path the provider doesn't serve. Unset base URLs are untouched, so the default hosted endpoints are unaffected. (#1250) +- **Vector search stops silently going dark on a LiteLLM or llama-server embedding model.** A model-availability check was rejecting user-provided embedding recipes even when a model was configured, quietly disabling vector search so results looked empty. The check now validates what actually matters — that a dimension is set — and reports a clear, actionable message when it isn't. (#1292, #2295) +- **Local embedding models with non-standard dimensions are accepted.** Ollama, llama-server, and LiteLLM models (e.g. modern 1024- or 4096-dimension embedders) no longer get hard-rejected by the dimension validator; you declare the dimension and gbrain trusts it. Hosted fixed-dimension providers stay strictly validated. Modern Ollama embed models are recognized. (#2271) + +### Changed +- **LiteLLM setup guidance now names the `/v1` path convention** so OpenAI-shaped proxies that only serve the `/v1` route don't fail authentication with no hint. (#2209) + +### To take advantage of v0.42.58.0 +`gbrain upgrade`. If you run on Ollama, a LiteLLM proxy, llama-server, or as a Claude Code MCP subprocess, the fixes apply automatically — no migration, no config change. If you use a user-provided embedding recipe (LiteLLM / llama-server) and see a "no default embedding dimension" message, set it with `gbrain init --embedding-dimensions `. + ## [0.42.57.0] - 2026-07-02 **PGLite incident fix: a busy `gbrain dream` (or `embed`) could have its data-directory lock stolen and get its brain corrupted beyond in-place repair. The lock will no longer be taken from a process that is alive, and an already-corrupted store now tells you exactly how to recover.** diff --git a/TODOS.md b/TODOS.md index 573a3aea8..40512efd1 100644 --- a/TODOS.md +++ b/TODOS.md @@ -23,6 +23,47 @@ and the scope record at `~/.gstack/projects/garrytan-gbrain/ceo-plans/2026-06-12 `src/core/verbs/entity-card.ts` open-threads assembly + a new schema table (additive — the card field already exists, so this is a quality upgrade, not a contract change). +## provider-agnostic follow-ups (filed v0.42.58.0) + +Deferred from the provider-agnostic plumbing wave (#1249/#1250/#1292/#2271/#2209). +Plan + review trail at `~/.claude/plans/system-instruction-you-are-working-keen-newell.md`. +The eng-review + Codex outside-voice narrowed the wave to these deferrals: + +- [ ] **P2 — Capability-aware query expansion on OpenAI-compat providers (#2372).** + Expansion only runs for recipes that declare an `expansion` touchpoint, and only the + native providers (anthropic/openai/google) do. To make expansion work on + litellm/openrouter/groq/together/deepseek you must ADD expansion touchpoints to those + chat-capable recipes AND add a `generateObject`→`generateText` capability fallback for + backends without strict structured outputs. Feature-shaped; overlaps the general + OpenAI-compat proxy story (`docs/designs/COMMUNITY_IDEAS.md`). Community PR #2373 is a + starting point. Where: `src/core/ai/gateway.ts:expand`, recipe files, `types.ts` (ExpansionTouchpoint). +- [ ] **P2 — LiteLLM as a chat/expansion backend.** `litellm-proxy` declares ONLY an + embedding touchpoint, so `think`/chat on LiteLLM is dead. Add chat (and expansion) so a + LiteLLM proxy is a full LLM backend, not embedding-only. The general OpenAI-compat proxy story. +- [ ] **P3 — Per-model embedding dims metadata on `EmbeddingTouchpoint`.** `default_dims` + is recipe-wide, so a recipe (ollama) can't carry different native dims per model. This + wave added the modern ollama model NAMES + a `trust_custom_dims` passthrough (user supplies + `--embedding-dimensions`); per-model dims would let gbrain pick the right default. Then + ollama could fail-closed at preflight like litellm/llama-server instead of at first embed. +- [ ] **P3 — Google native baseURL normalization (#1250 follow-up).** `resolveNativeBaseUrl` + covers anthropic + openai; Google was deferred because Gemini's native suffix is unproven + (its OpenAI-compat route is `/v1beta/openai`). Verify the correct `@ai-sdk/google` suffix, + then add `google` to the helper. Where: `src/core/ai/gateway.ts:resolveNativeBaseUrl`. +- [ ] **P3 — Fold Voyage/Google/LiteLLM/OpenRouter API keys into `buildGatewayConfig`.** + It folds only OPENAI/ANTHROPIC/ZEROENTROPY file-plane keys today, so `config.json`-set keys + for other providers only work if also in `process.env`. Extend the mapping. Where: + `src/core/ai/build-gateway-config.ts`. +- [ ] **P3 — OpenRouter per-model custom-dim handling.** OpenRouter declares recipe-wide + `dims_options` and mixes fixed-dim + arbitrary models, so it's excluded from `trust_custom_dims`. + A per-model story would let OpenRouter accept custom dims for models that support them. +- [ ] **P1 — Gateway subagent-loop tool-result persistence + Date normalization (#2273/#2256).** + Confirmed crash-block: non-Anthropic subagent jobs dead-letter after any interruption + (tool-result user turns aren't persisted; raw Date values fail the AI SDK's strict JSON + check). Larger self-contained change with 6 competing community PRs + (#2274/#2257/#1934/#2065/#2112/#2336) — pick one canonical impl, preserve authorship. + This is the immediate fast-follow to the provider-agnostic wave. Where: + `src/core/ai/gateway.ts:toolLoop`/`toModelMessages`, `src/core/minions/handlers/subagent.ts`. + ## Life Chronicle follow-ups (filed v0.42.56.0, #2390) Deferred from the Life Chronicle wave (CEO Scope-Expansion + eng review CLEARED, diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 7455ab279..38e35b3f7 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -94,7 +94,7 @@ export interface BrainEngine { **Slug-based API, not ID-based.** Every method takes slugs, not numeric IDs. The engine resolves slugs to IDs internally. This keeps the interface portable... slugs are strings, IDs are database-specific. -**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that. This is intentional: embedding is an external API call (OpenAI), not a storage concern. All engines share the same embedding service. +**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that (a thin delegation to the provider-agnostic AI gateway in `src/core/ai/gateway.ts`). This is intentional: embedding is an external API call (OpenAI, Voyage, a local Ollama — whichever provider you configured), not a storage concern. All engines share the same embedding service. **Chunking is NOT in the engine.** Same logic. `src/core/chunkers/` handles chunking. The engine stores and retrieves chunks. All engines share the same chunkers. diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index 6cd657ae3..29901b633 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -107,8 +107,8 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/sync-delta.ts` — the single "what changed since last_commit" helper (#2139), consumed by BOTH `performSyncInner` (sync executor) and `estimateInlineNewTokens` (cost estimator) so the gate's dollar figure can't drift from what the sync imports. `computeSyncDelta(repoPath, fromCommit, toCommit, {detachedManifest?, detached?})` → `{status:'ok', manifest}` | `{status:'unavailable', reason:'anchor_missing'|'diff_failed'}`. Anchor reachability via `git cat-file -t` (#1970 discipline — a gc'd bookmark is `anchor_missing`, but a present-but-non-ancestor bookmark is still diffed tree-to-tree), then `git diff --name-status -M from..to` parsed by `buildSyncManifest`; merges the detached working-tree manifest when detached (`buildDetachedWorkingTreeManifest`, relocated here from sync.ts). NO dirty/untracked probe — attached-HEAD incremental sync imports only the commit diff, so pricing dirty files would re-introduce phantom costs on a busy brain. `execFileSync` array-args (shell-injection safe), 30s / 100 MiB budget. Test seam `_setGitRunnerForTests`. Pinned by `test/sync-delta.test.ts`. - `src/core/spend-posture.ts` — spend-control surface (#2139). `resolveSpendPosture(engine): 'gated'|'tokenmax'` (DB-plane `spend.posture`, fail-open `gated`); `tokenmax` makes every cost gate informational across sync/reindex/enrich/onboard (spend still ledgered — removes the ceiling, not the accounting). `parseUsdLimit(raw, def, {allowZero?})` accepts `off`/`unlimited`/`none` → `Infinity`; `formatUsdLimit(n)` renders `Infinity` as the string `'unlimited'` (never raw — `JSON.stringify(Infinity)` is `null`); `usdLimitToCap(n)` maps `Infinity` → `undefined` at the BudgetTracker boundary so ledger rows never serialize null. `normalizeSpendPosture`/`isValidSpendPosture` back the `config set` validation. Doc: `docs/operations/spend-controls.md`. Pinned by `test/sync-cost-preview.test.ts` + `test/spend-off-switch.test.ts`. - `src/core/ai/dims.ts` — per-provider `providerOptions` resolver for embed-time dimension passthrough; the single source of truth for "which provider needs which knob to produce `vector(N)`". Exports `dimsProviderOptions(implementation, modelId, dims)` (called by `embed()` in `gateway.ts`), `VOYAGE_OUTPUT_DIMENSION_MODELS` (private const — the 7 hosted Voyage models that accept `output_dimension`: `voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-3` — nano deliberately excluded), `VOYAGE_VALID_OUTPUT_DIMS = [256, 512, 1024, 2048] as const`, `supportsVoyageOutputDimension(modelId)`, `isValidVoyageOutputDim(dims)`. Voyage path uses the SDK-supported `dimensions` field (`{ openaiCompatible: { dimensions: N } }`), NOT Voyage's `output_dimension` wire-key — the `voyageCompatFetch` shim in `gateway.ts:541` translates `dimensions → output_dimension` before the HTTP body is built (the AI SDK's openai-compatible adapter doesn't recognize the wire-key, so sending it from here would be silently dropped and Voyage would return its default 1024-dim). Runtime guard: when a Voyage flexible-dim model is configured with `dims` outside `VOYAGE_VALID_OUTPUT_DIMS`, throws `AIConfigError` with a paste-ready `gbrain config set embedding_dimensions <256|512|1024|2048>` hint at the embed boundary (most common trigger: `embedding_model: voyage:voyage-4-large` without `embedding_dimensions`, falling back to `DEFAULT_EMBEDDING_DIMENSIONS=1536`, an OpenAI default not a Voyage one). -- `src/core/ai/types.ts` — provider/recipe types. `EmbeddingTouchpoint` has optional `chars_per_token` (default 4, matching OpenAI tiktoken on English) and `safety_factor` (default 0.8, budget-utilization ceiling), both consulted only when `max_batch_tokens` is also set; Voyage declares `chars_per_token=1` + `safety_factor=0.5` to handle dense payloads (CJK/JSON/base64). Pre-split budget = `max_batch_tokens × safety_factor / chars_per_token`. `EmbeddingTouchpoint.multimodal_models?: string[]` model-level allow-list for recipes mixing text-only + multimodal models under one touchpoint (Voyage's 12 models share `supports_multimodal: true` but only `voyage-multimodal-3` accepts `/multimodalembeddings`); when omitted, recipe-level `supports_multimodal` is sufficient. `AIGatewayConfig.embedding_multimodal_model?: string` lets `embedMultimodal()` route to a different model than `embedding_model` (OpenAI text + Voyage images without flipping the primary pipeline). `Recipe.default_headers?: Record` (static) and `Recipe.resolveDefaultHeaders?(env)` (env-templated) seam for per-recipe headers riding alongside auth on every openai-compat touchpoint; mutually exclusive (declaring both throws `AIConfigError` at gateway-configure time); keys conflicting with the resolved auth header (`Authorization`, the resolver's custom header) rejected at `applyResolveAuth` call time so defaults can't shadow auth. Used by OpenRouter for the `HTTP-Referer` + `X-OpenRouter-Title` + `X-Title` attribution triple. -- `src/core/ai/gateway.ts` — unified seam for every AI call. `embedQuery(text, opts?)` and `isAvailable(touchpoint, modelOverride?)` accept a model override so the resolved-column path embeds via the column's provider (Voyage / ZeroEntropy / OpenAI) instead of the global default; the hybrid path passes `{embeddingModel: resolved.provider, dimensions: resolved.dimensions}` and the gateway resolves the matching recipe via `instantiateEmbedding()`. `isAvailable('embedding', 'voyage:voyage-3-large')` checks the override's recipe (not the default) so hybrid skips vector search only when the active column's provider is actually down. `zeroEntropyCompatFetch` shim (sibling to `voyageCompatFetch`) handles ZE's non-OpenAI-compatible wire shape — rewrites the request URL `/embeddings → /models/embed`, injects `input_type` (default `'document'`; the threaded `'query'|'document'` crosses the SDK boundary via the module-level `__embedInputTypeStore` AsyncLocalStorage populated in `embedSubBatch()`, because the AI SDK's openai-compatible adapter strips `input_type` from `providerOptions` before building the wire body — #1400; `voyageCompatFetch` injects it opt-in the same way, and `openAICompatAsymmetricFetch` is the fallthrough shim for every other openai-compat recipe — llama-server/litellm/ollama — a strict pass-through when nothing was threaded) and explicit `encoding_format: 'float'`, and rewrites the response `{results: [{embedding}], usage: {total_bytes, total_tokens}}` → `{data: [{embedding, index}], usage: {prompt_tokens, total_tokens}}` so the SDK's openai-compatible Zod schema validates. Layer 1 (Content-Length) + Layer 2 (per-embedding) OOM caps via tagged `ZeroEntropyResponseTooLargeError` (kept separate from `VoyageResponseTooLargeError` because `test/voyage-response-cap.test.ts` does structural source-text greps pinning the Voyage name). Wired in `instantiateEmbedding()` via the `recipe.id === 'zeroentropyai'` branch. `gateway.rerank()` native HTTP path (no AI-SDK reranking abstraction): resolves the configured reranker via `getRerankerModel()`, posts to `${recipe.base_url}/models/rerank` with bearer auth, returns `RerankResult[]` sorted by relevance. `RerankError.reason` classifier: `auth | rate_limit | network | timeout | payload_too_large | unknown`. 5s default timeout (search hot path). Pre-flight payload guard rejects bodies over `recipe.touchpoints.reranker.max_payload_bytes` with `reason: 'payload_too_large'`. `_rerankTransport` test seam mirrors `_embedTransport`. `embedQuery(text)` threads `inputType: 'query'` through `dimsProviderOptions()` (4-arg). `getRerankerModel()` accessor + `isAvailable('reranker')` branch; `configureGateway` + `reconfigureGatewayWithEngine` thread `reranker_model`; `applyResolveAuth` + `defaultResolveAuth` widen touchpoint param to include `'reranker'`. `embedMultimodalOpenAICompat()` routes recipes with `implementation: 'openai-compatible'` (LiteLLM, Anyscale, vLLM, Gemini multimodal via proxy) through the standard `/embeddings` endpoint with content arrays carrying `image_url` entries; the Voyage `/multimodalembeddings` path is unchanged (gateway selects by recipe `implementation` tag). Runtime dimension validation throws `AIConfigError` (with model id + observed + expected) before the vector reaches storage when the provider returns a width that doesn't match the recipe's `default_dims` or the brain's `embedding_dimensions`. Pinned by `test/openai-compat-multimodal.test.ts`. Module-scoped `_embedTransport` defaults to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive `embed()` with a stubbed transport. `splitByTokenBudget` and `isTokenLimitError` exported `@internal` (pure functions reused by the test file). Module-level `_shrinkState: Map` halves the recipe's effective `safety_factor` on token-limit miss (floor 0.05) and heals back ×1.5 after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. `embedMultimodal()` reads `cfg.embedding_multimodal_model` first (falls back to `cfg.embedding_model`); after the recipe-level `supports_multimodal` fast-fail, validates the resolved model against `touchpoint.multimodal_models` when declared (closes the Voyage-text-only-into-multimodal-endpoint footgun before any HTTP call). `getMultimodalModel()` accessor mirrors `getEmbeddingModel` / `getChatModel`. Exported `VoyageResponseTooLargeError` tagged class: `voyageCompatFetch`'s two OOM-defense caps (Layer 1 Content-Length at `:595`, Layer 2 per-embedding base64 at `:619`) throw it; the inbound response-rewriter's try/catch (which swallows parse failures so misshaped responses fall through to the SDK parser) checks `instanceof VoyageResponseTooLargeError` and rethrows so the cap is actually effective (regression assertion in `test/voyage-response-cap.test.ts` pins the `instanceof ⇒ throw err` line). AI SDK v6 toolLoop compat (`gbrain skillopt` rollouts AND production background `subagent` jobs both route through `chat()` / `toolLoop`): in `chat()`, tool defs wrap the raw JSON Schema with the SDK's `jsonSchema()` helper (`inputSchema: jsonSchema(t.inputSchema)`) — v6's `asSchema()` treats a bare `{jsonSchema: ...}` object as a thunk and throws "schema is not a function"; new exported pure `toModelMessages(messages: ChatMessage[]): unknown[]` converts gbrain's provider-neutral `ChatMessage[]` into v6 `ModelMessage[]` — tool results (pushed by `toolLoop` as `role:'user'` with bare-value tool-result blocks) become a dedicated `role:'tool'` message with structured `output:{type:'json'|'text'|'error-text', value}` parts; `null` output preserved as `{type:'json', value:null}` (not dropped); text/tool-call blocks pass through with v6 field names (`toolCallId`/`toolName`/`input`); applied at the `generateText` call (`messages: toModelMessages(opts.messages)`). The converter is the load-bearing fix for the production subagent path, not just skillopt. Pinned by `test/gateway-model-messages.test.ts`. Companion fix in `src/core/skillopt/rollout.ts`: the inline `paramsToSchema` dropped `items` on array params; it now uses the shared `paramDefToSchema` from `src/mcp/tool-defs.ts` (single source of truth, recursive on items/enum/default). +- `src/core/ai/types.ts` — provider/recipe types. `EmbeddingTouchpoint` has optional `chars_per_token` (default 4, matching OpenAI tiktoken on English) and `safety_factor` (default 0.8, budget-utilization ceiling), both consulted only when `max_batch_tokens` is also set; Voyage declares `chars_per_token=1` + `safety_factor=0.5` to handle dense payloads (CJK/JSON/base64). Pre-split budget = `max_batch_tokens × safety_factor / chars_per_token`. `EmbeddingTouchpoint.multimodal_models?: string[]` model-level allow-list for recipes mixing text-only + multimodal models under one touchpoint (Voyage's 12 models share `supports_multimodal: true` but only `voyage-multimodal-3` accepts `/multimodalembeddings`); when omitted, recipe-level `supports_multimodal` is sufficient. `AIGatewayConfig.embedding_multimodal_model?: string` lets `embedMultimodal()` route to a different model than `embedding_model` (OpenAI text + Voyage images without flipping the primary pipeline). `EmbeddingTouchpoint.trust_custom_dims?: true` — passthrough tier for a user-declared `--embedding-dimensions` on local / bring-your-own-backend recipes (ollama, llama-server, litellm) where the model catalog can't be enumerated; consumed by `isCustomDimValidForProvider` in `src/core/embedding-dim-check.ts` AFTER Tier 1 (recipe `dims_options`) and Tier 2 (provider Matryoshka allowlists), so a recipe that declares fixed options (openrouter) is still governed by those and fixed-dim hosted providers (openai/voyage/zeroentropy) stay fail-closed; the provider's `/embeddings` response-dim validation catches a genuine mismatch pre-storage. `Recipe.default_headers?: Record` (static) and `Recipe.resolveDefaultHeaders?(env)` (env-templated) seam for per-recipe headers riding alongside auth on every openai-compat touchpoint; mutually exclusive (declaring both throws `AIConfigError` at gateway-configure time); keys conflicting with the resolved auth header (`Authorization`, the resolver's custom header) rejected at `applyResolveAuth` call time so defaults can't shadow auth. Used by OpenRouter for the `HTTP-Referer` + `X-OpenRouter-Title` + `X-Title` attribution triple. +- `src/core/ai/gateway.ts` — unified seam for every AI call. `embedQuery(text, opts?)` and `isAvailable(touchpoint, modelOverride?)` accept a model override so the resolved-column path embeds via the column's provider (Voyage / ZeroEntropy / OpenAI) instead of the global default; the hybrid path passes `{embeddingModel: resolved.provider, dimensions: resolved.dimensions}` and the gateway resolves the matching recipe via `instantiateEmbedding()`. `isAvailable('embedding', 'voyage:voyage-3-large')` checks the override's recipe (not the default) so hybrid skips vector search only when the active column's provider is actually down. `zeroEntropyCompatFetch` shim (sibling to `voyageCompatFetch`) handles ZE's non-OpenAI-compatible wire shape — rewrites the request URL `/embeddings → /models/embed`, injects `input_type` (default `'document'`; the threaded `'query'|'document'` crosses the SDK boundary via the module-level `__embedInputTypeStore` AsyncLocalStorage populated in `embedSubBatch()`, because the AI SDK's openai-compatible adapter strips `input_type` from `providerOptions` before building the wire body — #1400; `voyageCompatFetch` injects it opt-in the same way, and `openAICompatAsymmetricFetch` is the fallthrough shim for every other openai-compat recipe — llama-server/litellm/ollama — a strict pass-through when nothing was threaded) and explicit `encoding_format: 'float'`, and rewrites the response `{results: [{embedding}], usage: {total_bytes, total_tokens}}` → `{data: [{embedding, index}], usage: {prompt_tokens, total_tokens}}` so the SDK's openai-compatible Zod schema validates. Layer 1 (Content-Length) + Layer 2 (per-embedding) OOM caps via tagged `ZeroEntropyResponseTooLargeError` (kept separate from `VoyageResponseTooLargeError` because `test/voyage-response-cap.test.ts` does structural source-text greps pinning the Voyage name). Wired in `instantiateEmbedding()` via the `recipe.id === 'zeroentropyai'` branch. `gateway.rerank()` native HTTP path (no AI-SDK reranking abstraction): resolves the configured reranker via `getRerankerModel()`, posts to `${recipe.base_url}/models/rerank` with bearer auth, returns `RerankResult[]` sorted by relevance. `RerankError.reason` classifier: `auth | rate_limit | network | timeout | payload_too_large | unknown`. 5s default timeout (search hot path). Pre-flight payload guard rejects bodies over `recipe.touchpoints.reranker.max_payload_bytes` with `reason: 'payload_too_large'`. `_rerankTransport` test seam mirrors `_embedTransport`. `embedQuery(text)` threads `inputType: 'query'` through `dimsProviderOptions()` (4-arg). `getRerankerModel()` accessor + `isAvailable('reranker')` branch; `configureGateway` + `reconfigureGatewayWithEngine` thread `reranker_model`; `applyResolveAuth` + `defaultResolveAuth` widen touchpoint param to include `'reranker'`. `embedMultimodalOpenAICompat()` routes recipes with `implementation: 'openai-compatible'` (LiteLLM, Anyscale, vLLM, Gemini multimodal via proxy) through the standard `/embeddings` endpoint with content arrays carrying `image_url` entries; the Voyage `/multimodalembeddings` path is unchanged (gateway selects by recipe `implementation` tag). Runtime dimension validation throws `AIConfigError` (with model id + observed + expected) before the vector reaches storage when the provider returns a width that doesn't match the recipe's `default_dims` or the brain's `embedding_dimensions`. Pinned by `test/openai-compat-multimodal.test.ts`. Module-scoped `_embedTransport` defaults to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive `embed()` with a stubbed transport. `splitByTokenBudget` and `isTokenLimitError` exported `@internal` (pure functions reused by the test file). Module-level `_shrinkState: Map` halves the recipe's effective `safety_factor` on token-limit miss (floor 0.05) and heals back ×1.5 after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. `embedMultimodal()` reads `cfg.embedding_multimodal_model` first (falls back to `cfg.embedding_model`); after the recipe-level `supports_multimodal` fast-fail, validates the resolved model against `touchpoint.multimodal_models` when declared (closes the Voyage-text-only-into-multimodal-endpoint footgun before any HTTP call). `getMultimodalModel()` accessor mirrors `getEmbeddingModel` / `getChatModel`. Exported `VoyageResponseTooLargeError` tagged class: `voyageCompatFetch`'s two OOM-defense caps (Layer 1 Content-Length at `:595`, Layer 2 per-embedding base64 at `:619`) throw it; the inbound response-rewriter's try/catch (which swallows parse failures so misshaped responses fall through to the SDK parser) checks `instanceof VoyageResponseTooLargeError` and rethrows so the cap is actually effective (regression assertion in `test/voyage-response-cap.test.ts` pins the `instanceof ⇒ throw err` line). AI SDK v6 toolLoop compat (`gbrain skillopt` rollouts AND production background `subagent` jobs both route through `chat()` / `toolLoop`): in `chat()`, tool defs wrap the raw JSON Schema with the SDK's `jsonSchema()` helper (`inputSchema: jsonSchema(t.inputSchema)`) — v6's `asSchema()` treats a bare `{jsonSchema: ...}` object as a thunk and throws "schema is not a function"; new exported pure `toModelMessages(messages: ChatMessage[]): unknown[]` converts gbrain's provider-neutral `ChatMessage[]` into v6 `ModelMessage[]` — tool results (pushed by `toolLoop` as `role:'user'` with bare-value tool-result blocks) become a dedicated `role:'tool'` message with structured `output:{type:'json'|'text'|'error-text', value}` parts; `null` output preserved as `{type:'json', value:null}` (not dropped); text/tool-call blocks pass through with v6 field names (`toolCallId`/`toolName`/`input`); applied at the `generateText` call (`messages: toModelMessages(opts.messages)`). The converter is the load-bearing fix for the production subagent path, not just skillopt. Pinned by `test/gateway-model-messages.test.ts`. Companion fix in `src/core/skillopt/rollout.ts`: the inline `paramsToSchema` dropped `items` on array params; it now uses the shared `paramDefToSchema` from `src/mcp/tool-defs.ts` (single source of truth, recursive on items/enum/default). Provider-agnostic plumbing: `resolveNativeBaseUrl(provider, cfg)` normalizes a configured `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` to carry the `/v1` suffix and is passed explicitly at every native `createAnthropic` / `createOpenAI` site (chat/expansion/embedding), so an env-injected bare host doesn't 404; returns `undefined` when unset so the SDK default is preserved (Google deferred until its native suffix is verified). `diagnoseEmbedding` fails closed with `user_provided_dims_unset` when a user-provided / zero-default recipe (litellm/llama-server) has no configured `embedding_dimensions` — this REPLACED the old `user_provided_model_unset` guard, which was structurally unreachable (parseModelId throws on a bare provider) and only ever false-positived for `litellm:`, silently disabling vector search. `configureGateway` no longer backfills `embedding_dimensions` (readers default it themselves), keeping the "no dims set" signal honest for that guard and the multimodal skip. - `src/core/ai/recipes/zeroentropyai.ts` — ZeroEntropy openai-compatible recipe declaring BOTH `embedding` (`zembed-1`, 7 Matryoshka dims: 2560/1280/640/320/160/80/40) AND `reranker` (`zerank-2` flagship + `zerank-1` + `zerank-1-small`, 5MB payload cap) touchpoints. `implementation: 'openai-compatible'` (pinned by regression in `test/ai/zeroentropy-recipe.test.ts`). `base_url_default: 'https://api.zeroentropy.dev/v1'` already ends with `/v1`, so the `zeroEntropyCompatFetch` URL rewrite `/embeddings → /models/embed` produces `…/v1/models/embed` (NOT `…/v1/v1/…` — pinned by regression). `chars_per_token: 1` + `safety_factor: 0.5` match Voyage's dense-content hedge. - `src/core/ai/recipes/llama-server-reranker.ts` — sibling of `llama-server` (the embedding recipe) for llama.cpp in `--reranking` mode. Distinct recipe rather than dual-touchpoint extension because `--reranking` and `--embeddings` are mutually exclusive at server-launch time, so the two backends need independent base URLs (default 8081 here vs 8080 there). Declares `reranker` touchpoint with `models: []` (user-provided id matching the `--alias` the user launched with), `path: '/rerank'` (leaf-only; consumes `RerankerTouchpoint.path` override; gateway concatenates with `base_url_default` which ends in `/v1`, producing `…/v1/rerank`), `default_timeout_ms: 30_000` (consumed by `src/core/search/mode.ts`'s reranker timeout chain — CPU-only first-call warmup headroom; the 5s mode-bundle default would fail-open as `timeout`), `cost_per_1m_tokens_usd: 0` (recognized by `FREE_LOCAL_RERANK_PROVIDERS` in `src/core/budget/budget-tracker.ts` so `--max-cost` callers don't hard-fail on local rerank). Setup hint emphasizes `--alias` because llama-server's `/v1/models` defaults model id to the gguf file path without it. Covers Qwen3-Reranker via llama.cpp AND self-hosted ZE weights via llama.cpp — same recipe, different `--model` at launch. Pinned by `test/ai/recipe-llama-server-reranker.test.ts`. Voyage / Cohere / vLLM rerankers stay out of scope (different wire shapes). Same wave adds: `path?: string` + `default_timeout_ms?: number` on `RerankerTouchpoint` in `src/core/ai/types.ts`; consumed by the URL build at `src/core/ai/gateway.ts:rerank()` and by mode-resolution at `src/core/search/mode.ts:resolveSearchMode` (precedence: per-call > config-key > recipe touchpoint default > mode bundle); `LLAMA_SERVER_RERANKER_BASE_URL` env passthrough in `src/cli.ts:buildGatewayConfig`; `FREE_LOCAL_RERANK_PROVIDERS` set in `src/core/budget/budget-tracker.ts:lookupPricing` (rerank-kind-only zero-pricing for the local provider prefix); doctor-fix at `src/commands/models.ts:probeRerankerConfig` reads `search.reranker.model` via `loadSearchModeConfig` + `resolveSearchMode` (closes file-plane / DB-plane divergence where doctor said "not configured" while live search was actively reranking — the field-plane `getRerankerModel()` read nothing writes); `probeRerankerReachability` reads the recipe's `default_timeout_ms` so CPU-only cold-start doesn't false-fail. - `src/core/ai/recipes/openrouter.ts` — OpenRouter openai-compatible recipe: single key, many providers via `openrouter:/` strings. `base_url_default: 'https://openrouter.ai/api/v1'`. Embedding touchpoint: `openai/text-embedding-3-small` at 1536 dims with Matryoshka `dims_options: [512, 768, 1024, 1536]`; `max_batch_tokens: 300_000` = OpenAI's aggregate-per-request token cap (NOT per-input). Chat touchpoint declares 8 curated entry points (gpt-5.2, gpt-5.2-chat, gpt-5.5, claude-haiku-4.5, claude-sonnet-4.6, claude-opus-4.7, gemini-3-flash-preview, deepseek-chat) but openai-compat tier accepts any model ID; deliberately no `max_context_tokens` because OR's catalog spans 128K to 1M+. `supports_subagent_loop: false` is INFORMATIONAL — the real gate is `isAnthropicProvider()` in `src/core/model-config.ts` which hard-pins gbrain's subagent infra to Anthropic-direct. Declares `resolveDefaultHeaders(env)` returning OR's three attribution headers: `HTTP-Referer` (required for OR app-attribution), `X-OpenRouter-Title` (preferred), `X-Title` (back-compat alias); defaults to `https://gbrain.ai` / `gbrain`; forks override via `OPENROUTER_REFERER` / `OPENROUTER_TITLE` env vars. Smoke-tested by `test/ai/recipe-openrouter.test.ts` (incl. the shape-test regression guard: every model in the chat list matches `^[a-z0-9-]+\/[a-z0-9._-]+$`). @@ -134,7 +134,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/minions/queue.ts` extension — `MinionQueue.add()` rejects `subagent` jobs whose `data.model` resolves via `isAnthropicProvider()` to a non-Anthropic provider. Lazy-imports `model-config.ts` to avoid pulling engine types into queue's eager-load surface. Layer 1 of the three-layer subagent provider enforcement (layers 2+3: `model-config.ts:enforceSubagentAnthropic` runtime fallback + `src/commands/doctor.ts` `subagent_provider` check). Pinned by `test/agent-cli.test.ts`. - `src/commands/models.ts` — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain), every per-task override (11 `PER_TASK_KEYS`: `models.dream.synthesize`, `models.dream.patterns`, `models.drift`, `models.auto_think`, `models.think`, `models.subagent`, `facts.extraction_model`, `models.eval.longmemeval`, `models.expansion`, `models.chat`, `models.dream.synthesize_verdict`), the alias map, and a source-of-truth column (`default` / `config: ` / `env: `). `gbrain models doctor [--skip=] [--json]` fires a 1-token `gateway.chat()` probe against each configured chat + expansion model and classifies failures into `{model_not_found, auth, rate_limit, network, unknown}`. Wired into `cli.ts` dispatch + `CLI_ONLY` set. A zero-token `embedding_config` probe runs FIRST, before any chat/expansion probes spend money: `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. `ProbeStatus` variant `'config'` + optional `fix?: string` on `ProbeResult` surface a paste-ready `gbrain config set ...` line in human + JSON output; touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'`. - `src/core/init-embed-check.ts` — embedding-key validation at `gbrain init`. `runInitEmbedCheck(opts)` runs a config-only `diagnoseEmbedding` (catches a missing key for ANY provider) plus a best-effort `liveTestEmbed` (1-token `gateway.embed(['probe'], {inputType:'query', abortSignal})`, 5s `AbortController` timeout, never throws — catches an invalid/expired key). Loud warning to stderr; init still exits 0 (`--no-embedding` is the deferred-setup escape; `--skip-embed-check` / `GBRAIN_INIT_SKIP_EMBED_CHECK=1` skip the check). Builds the effective env (`process.env` + file-plane `openai/anthropic/zeroentropy_api_key` from `loadConfigFileOnly()` + `opts.apiKey`) and configures the gateway via `buildGatewayConfig` before diagnose/probe, so the check sees the same keys AND provider base URLs runtime will (no false "missing key" for config.json-keyed users; the probe hits the right endpoint). Init-specific warning text names `--no-embedding` / `--skip-embed-check`, not the sync-flavored `--no-embed`. Wired into `initPGLite` + `initPostgres` in `src/commands/init.ts`, with the result added to the `--json` envelope as `embedding_check {ok, reason?, live_ok?}`. Pinned by `test/init-embed-check.test.ts` (hermetic via the gateway embed-transport seam + `withEnv`). -- `src/core/ai/build-gateway-config.ts` — `buildGatewayConfig(c: GBrainConfig): AIGatewayConfig`, extracted from `src/cli.ts` (which re-exports it for back-compat). Lets core modules (`init-embed-check.ts`) reuse it without importing the CLI entrypoint. Single owner of folding file-plane API keys (openai/anthropic/zeroentropy) into the gateway env and threading local-server `*_BASE_URL` env vars into base_urls; `process.env` wins. Pinned by `test/ai/build-gateway-config.test.ts`. +- `src/core/ai/build-gateway-config.ts` — `buildGatewayConfig(c: GBrainConfig): AIGatewayConfig`, extracted from `src/cli.ts` (which re-exports it for back-compat). Lets core modules (`init-embed-check.ts`) reuse it without importing the CLI entrypoint. Single owner of folding file-plane API keys (openai/anthropic/zeroentropy) into the gateway env and threading local-server `*_BASE_URL` env vars into base_urls. `process.env` wins EXCEPT empty-string / undefined values are dropped before the merge, so an injected empty `ANTHROPIC_API_KEY=''` (Claude Code neuters subprocess LLM calls this way) can't clobber a valid config-plane key; `'0'` / `'false'` are preserved. Pinned by `test/ai/build-gateway-config.test.ts`. - `src/commands/doctor.ts` extension — `subagent_provider` check (layer 3 of 3). Warns when `models.tier.subagent` is explicitly set non-Anthropic (message names the bad value + paste-ready fix `gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6`); also warns when `models.default` would sneak `subagent` into a non-Anthropic provider via tier inheritance. OK when subagent tier resolves to Anthropic. Tests in `test/doctor.test.ts`. - `src/core/skill-trigger-index.ts` — Shared loader that unions per-skill SKILL.md frontmatter `triggers:` with curated RESOLVER.md / AGENTS.md rows from `skillsDir` AND the parent dir (preserves the OpenClaw workspace-root layout). UNION semantics: explicit RESOLVER.md rows ADD to frontmatter triggers (don't replace). Dedup keyed on `(skillPath, trigger.trim().toLowerCase())`. Three consumers fold through this primitive — `checkResolvable`, `runRoutingEvalCli`, `mounts-cache.composeResolvers` — so fixing frontmatter reaches all of them. Exports `loadSkillTriggerIndex(skillsDir): SkillTriggerEntry[]`, `entriesToResolverContent(entries): string` (synthesizes a markdown-table resolver string for `runRoutingEval`'s string-content API), `findPrimaryResolverPath(skillsDir): string | null`, the `FRONTMATTER_SECTION` constant, and `_resetWarnedSkillsForTests`. Skip rules: non-directory entries, `_*`/`.*` prefixes, `conventions/`+`migrations/` subdirs, skills with no `SKILL.md` (deprecated `install/` graceful-skipped), no `triggers:` array, or malformed YAML (warn-once + skip). Reuses `parseSkillFrontmatter` from `src/core/skill-frontmatter.ts` (regex-based, not full YAML). Pinned by `test/skill-trigger-index.test.ts` (18 hermetic cases). CI gate `bun run check:resolver` (= `bun src/cli.ts check-resolvable --strict --skills-dir skills/`) wired into `bun run verify`. - `src/core/skill-catalog.ts` — host-repo skill catalog backing the MCP `list_skills` / `get_skill` ops. Lets a thin MCP client (Codex desktop, Claude Code, Claude Cowork, Perplexity) DISCOVER + FOLLOW the agent repo's fat-markdown skills over `gbrain serve` — a skill is prose, so "using" one = fetching its body then calling the gbrain MCP tools the server already exposes. Read-scope, NOT localOnly (defensible only via the full mitigation stack): (1) **publish gate** — `assertPublishEnabled(ctx, publishSkills)`; remote callers require `mcp.publish_skills === true`, default-OFF so an upgrade never silently grants existing read tokens host-skill read; local callers (`ctx.remote === false`) always pass. (2) **path confinement** — `assertSkillNameShape` rejects separators/`..`/null/space before any FS access; the client `name` is a manifest LOOKUP KEY (via `loadOrDeriveManifest`), never a raw path segment; `confineManifestPath` does realpath + relative-containment + `SKILL.md`-regular-file check on EVERY entry (defeats poisoned manifest.json `path`, symlink/`..` escape). (3) **frontmatter allowlist** — `GetSkillResult.frontmatter` projects a safe subset; private `writes_to` + `sources` dropped. (4) **prose-only + 256KB cap** (`MAX_SKILL_MD_BYTES`, env `GBRAIN_MAX_SKILL_MD_BYTES`), size-checked twice (statSync + UTF-8 byte length). (5) **no install_path serve for remote** — remote callers use `autoDetectSkillsDir` (no install-path tier) so a hosted gbrain with no agent repo returns `storage_error`; local callers use `autoDetectSkillsDirReadOnly`. (6) MCP rate-limiter caps call rate. Config reads honor BOTH planes: `readMcpPublishSkills` / `readMcpSkillsDir` prefer the DB plane (`engine.getConfig`) over the file plane (`ctx.config.mcp`). Tool-honesty: `crossReferenceTools(declared, ctx)` splits a skill's declared `tools:` into `usable_tools` vs `unavailable_tools`; `buildSkillCatalog`'s `instructions` envelope (`SKILL_CATALOG_INSTRUCTIONS`) carries the "these are prose, follow-then-call-tools" protocol. Skills are host-filesystem repo-global — `sourceScopeOpts(ctx)` / `ctx.brainId` deliberately do NOT apply. `buildSkillCatalog` is resilient (one malformed/escaping skill is skipped, never throws). Config keys in `src/core/config.ts`: `GBrainConfig.mcp?: { publish_skills?, skills_dir? }` + `KNOWN_CONFIG_KEYS` entries `mcp.publish_skills`/`mcp.publish_skills_prompted`/`mcp.skills_dir` + `mcp.` prefix in `KNOWN_CONFIG_KEY_PREFIXES`. `src/commands/init.ts` writes `config.mcp = { publish_skills: true, ... }` for new installs (existing config wins on re-init). `src/commands/upgrade.ts:runPostUpgrade` adds a one-time consent prompt (gated by `mcp.publish_skills_prompted`; existing installs stay OFF until owner opts in). Two ops register in `src/core/operations.ts` (`list_skills` with optional `section` filter + `cliHints:{name:'skills'}`; `get_skill` taking `name` + `cliHints:{name:'skill', positional:['name']}`) and dynamically import this module to avoid the import cycle (skill-catalog statically imports the `operations` array). Descriptions in `src/core/operations-descriptions.ts` (`LIST_SKILLS_DESCRIPTION`, `GET_SKILL_DESCRIPTION`, `SKILL_CATALOG_INSTRUCTIONS`, `SKILL_CLIENT_GUIDANCE`), pinned by `test/operations-descriptions.test.ts`. CLI: `gbrain skills` / `gbrain skill `. Pinned by `test/skill-catalog.test.ts`, `test/skill-catalog-security.test.ts` (path-confinement / poisoned-manifest / symlink-escape), `test/skill-catalog-transports.test.ts` (publish-gate + remote-vs-local) over `test/fixtures/skill-catalog/`. diff --git a/docs/integrations/embedding-providers.md b/docs/integrations/embedding-providers.md index 36d981a80..75e405f69 100644 --- a/docs/integrations/embedding-providers.md +++ b/docs/integrations/embedding-providers.md @@ -34,7 +34,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom | `zhipu` | `ZHIPUAI_API_KEY` | 1024 | varies | no | no | | `ollama` | (none — runs locally) | 768 | 0 | yes | no | | `llama-server` | (none — runs locally) | user-set | 0 | yes | no | -| `litellm` | `LITELLM_API_KEY` (optional) | user-set | varies | yes (proxy) | no | +| `litellm` | `LITELLM_API_KEY` (optional) | user-set | varies | yes (proxy) | yes (backend permitting) | | `together` | `TOGETHER_API_KEY` | 768 | varies | no | no | | `anthropic` | (no embedding model — chat only) | — | — | — | — | | `deepseek` | (no embedding model — chat only) | — | — | — | — | @@ -77,6 +77,8 @@ The doctor distinguishes two repair paths: Default. Set `OPENAI_API_KEY`. Models: `text-embedding-3-large` (3072 max, 1536 default), `text-embedding-3-small` (1536). Matryoshka via the `dimensions` field — gbrain pins it from `embedding_dimensions` config so existing 1536-dim brains stay aligned across SDK upgrades. +Optional `OPENAI_BASE_URL` — point the native OpenAI provider at an OpenAI-compatible gateway. A bare host is normalized to carry the `/v1` suffix automatically (so `https://gw.example.com` and `https://gw.example.com/v1` both work); when unset, the SDK's default endpoint is untouched. `ANTHROPIC_BASE_URL` gets the same normalization for Anthropic chat/expansion calls. + ### Voyage AI Best-in-class quality on the Voyage 4 family (Jan 2026 release). Set `VOYAGE_API_KEY`. Models: `voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-4-nano`, `voyage-3.5`, `voyage-code-3` (code-tuned), `voyage-finance-2`, `voyage-law-2`, `voyage-multimodal-3` (text + image). @@ -141,13 +143,15 @@ Set `ZHIPUAI_API_KEY`. Models: `embedding-3` (current; Matryoshka 256-2048 dims) No env required — Ollama runs unauthenticated locally. Optional `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`) and `OLLAMA_API_KEY` (for auth-enabled deployments). -Recipe ships with `nomic-embed-text` (768d, recommended), `mxbai-embed-large` (1024d), `all-minilm` (384d). `gbrain providers test --model ollama:nomic-embed-text` smoke-tests the local install. +Recipe ships with `nomic-embed-text` (768d, recommended), `mxbai-embed-large` (1024d), `all-minilm` (384d), plus the larger modern embedders `qwen3-embed-8b` (4096d) and `snowflake-arctic-embed-l-v2` (1024d). `gbrain providers test --model ollama:nomic-embed-text` smoke-tests the local install. + +The recipe default is `nomic-embed-text`'s 768 dims. If you run one of the larger models, declare its native dimension with `--embedding-dimensions ` at init — gbrain trusts the value you declare for local recipes instead of rejecting a non-768 width. ### llama-server (local, llama.cpp) `llama.cpp`'s `llama-server --embeddings` endpoint. No env required. Optional `LLAMA_SERVER_BASE_URL` (default `http://localhost:8080/v1`) and `LLAMA_SERVER_API_KEY`. -User-driven models: launch llama-server with `--model --embeddings`, then run `gbrain init --embedding-model llama-server: --embedding-dimensions `. The recipe refuses the implicit shorthand `--model llama-server` because there's no canonical first model. +User-driven models: launch llama-server with `--model --embeddings`, then run `gbrain init --embedding-model llama-server: --embedding-dimensions `. gbrain trusts the dimension you declare (you know the GGUF you launched); the recipe refuses the implicit shorthand `--model llama-server` because there's no canonical first model. ### LiteLLM proxy (universal escape hatch) @@ -155,6 +159,8 @@ Run [LiteLLM](https://docs.litellm.ai/docs/proxy/quick_start) in front of any pr This is the catch-all for "my provider isn't in the list above." Set up LiteLLM, then `gbrain init --embedding-model litellm: --embedding-dimensions `. +**Include the `/v1` suffix in `LITELLM_BASE_URL` if your proxy serves the OpenAI route there** (e.g. `http://localhost:4000/v1`). Many LiteLLM deployments expose the OpenAI-compatible API only under `/v1`; pointing gbrain at the bare host 404s or fails authentication with no hint. gbrain trusts the dimension you declare for the proxy-backed model — the proxy's backend, not gbrain, decides the true width — so `--embedding-dimensions ` is required and accepted as-is. + ## Choosing dimensions Three numbers matter: @@ -183,5 +189,3 @@ The supported paths: - **Postgres (Supabase / self-hosted):** follow the SQL recipe in `docs/embedding-migrations.md` (drop the HNSW index, ALTER COLUMN TYPE, clear stale embeddings, recreate the index conditionally, then `gbrain init --supabase --embedding-model X --embedding-dimensions N` to update the file plane and re-embed). `gbrain doctor` 8c "alternative_providers" surfaces unconfigured providers whose env is already set — useful when you've configured OpenAI but also have e.g. `VOYAGE_API_KEY` exported and want to know you can switch without extra setup. - -`gbrain doctor` 8c "alternative_providers" surfaces unconfigured providers whose env is already set — useful when you've configured OpenAI but also have e.g. `VOYAGE_API_KEY` exported and want to know you can switch without extra setup. diff --git a/llms-full.txt b/llms-full.txt index eecdeb8ad..4b327649a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2041,7 +2041,7 @@ export interface BrainEngine { **Slug-based API, not ID-based.** Every method takes slugs, not numeric IDs. The engine resolves slugs to IDs internally. This keeps the interface portable... slugs are strings, IDs are database-specific. -**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that. This is intentional: embedding is an external API call (OpenAI), not a storage concern. All engines share the same embedding service. +**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that (a thin delegation to the provider-agnostic AI gateway in `src/core/ai/gateway.ts`). This is intentional: embedding is an external API call (OpenAI, Voyage, a local Ollama — whichever provider you configured), not a storage concern. All engines share the same embedding service. **Chunking is NOT in the engine.** Same logic. `src/core/chunkers/` handles chunking. The engine stores and retrieves chunks. All engines share the same chunkers. diff --git a/src/core/ai/build-gateway-config.ts b/src/core/ai/build-gateway-config.ts index c2b3f84a9..1b5eb9613 100644 --- a/src/core/ai/build-gateway-config.ts +++ b/src/core/ai/build-gateway-config.ts @@ -61,6 +61,18 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig { chat_model: c.chat_model, chat_fallback_chain: c.chat_fallback_chain, base_urls: { ...envBaseUrls, ...(c.provider_base_urls ?? {}) }, // config wins over env - env: { ...envFromConfig, ...process.env }, // process.env wins + // #1249: process.env still wins over the config-plane fallback, BUT only for + // keys that carry a real value. Claude Code (and some launchers) inject + // ANTHROPIC_API_KEY='' to neuter subprocess LLM calls; an unconditional + // `...process.env` lets that empty string clobber a valid config.json key, so + // every gateway op then throws NO_ANTHROPIC_API_KEY. Drop empty-string / + // undefined entries before the merge. Only '' and undefined are dropped — + // '0' and 'false' are legitimate values and survive. + env: { + ...envFromConfig, + ...Object.fromEntries( + Object.entries(process.env).filter(([, v]) => v !== undefined && v !== ''), + ), + }, }; } diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index cf41b1ab0..fdcae8779 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -389,11 +389,47 @@ export function applyOpenAICompatConfig( return { baseURL }; } +/** + * #1250: native providers (anthropic/openai) are instantiated as + * `create({ apiKey })` with NO explicit baseURL, so the AI SDK reads + * `_BASE_URL` verbatim. When a host injects a BARE base URL (Claude + * Code sets `ANTHROPIC_BASE_URL=https://api.anthropic.com` with no `/v1`), the + * SDK POSTs `/messages` → 404 and every chat/expansion/embedding call + * breaks. This normalizes a configured native base URL to carry the `/v1` + * suffix and returns it so callers can pass it explicitly. + * + * Returns undefined when the env provides no base URL, so the SDK's own default + * (which already includes `/v1`) is preserved untouched — the happy path is not + * altered. Google is intentionally NOT handled here: its native suffix is + * unproven (Gemini's OpenAI-compat route is `/v1beta/openai`), so it's deferred + * to a follow-up rather than risk a regression on a wrong assumption. + * + * @internal exported for tests. + */ +export function resolveNativeBaseUrl( + provider: 'anthropic' | 'openai', + cfg: AIGatewayConfig, +): string | undefined { + const envKey = provider === 'anthropic' ? 'ANTHROPIC_BASE_URL' : 'OPENAI_BASE_URL'; + const raw = cfg.env[envKey]; + if (!raw || !raw.trim()) return undefined; + const trimmed = raw.trim().replace(/\/+$/, ''); + return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; +} + /** Configure the gateway. Called by cli.ts#connectEngine. Clears cached models. */ export function configureGateway(config: AIGatewayConfig): void { _config = { embedding_model: config.embedding_model ?? DEFAULT_EMBEDDING_MODEL, - embedding_dimensions: config.embedding_dimensions ?? DEFAULT_EMBEDDING_DIMENSIONS, + // #1292/D6: do NOT fabricate a default here. Every gateway-internal reader + // already applies `?? DEFAULT_EMBEDDING_DIMENSIONS` (getEmbeddingDimensions, + // embedQuery, the dim self-check) or `?? 0` (the multimodal path, which is + // designed to SKIP validation when the dim is unknown rather than fabricate + // one). Backfilling 1280 here erased the "user never set a dimension" signal, + // which (a) defeated that intended skip and (b) let a user-provided recipe + // with no explicit dim look fully-configured to diagnoseEmbedding and then + // fail with a cryptic wrong-width error at embed time. Keep it honest. + embedding_dimensions: config.embedding_dimensions, embedding_multimodal_model: config.embedding_multimodal_model, expansion_model: config.expansion_model ?? DEFAULT_EXPANSION_MODEL, chat_model: config.chat_model ?? DEFAULT_CHAT_MODEL, @@ -656,7 +692,7 @@ export type EmbeddingDiagnosis = | { ok: false; reason: 'no_model_configured' } | { ok: false; reason: 'unknown_provider'; model: string; provider: string; message: string } | { ok: false; reason: 'no_touchpoint'; model: string; provider: string; recipeId: string } - | { ok: false; reason: 'user_provided_model_unset'; model: string; provider: string; recipeId: string } + | { ok: false; reason: 'user_provided_dims_unset'; model: string; provider: string; recipeId: string } | { ok: false; reason: 'missing_env'; model: string; provider: string; recipeId: string; missingEnvVars: string[] }; export function diagnoseEmbedding(modelOverride?: string): EmbeddingDiagnosis { @@ -704,16 +740,24 @@ export function diagnoseEmbedding(modelOverride?: string): EmbeddingDiagnosis { }; } - // Openai-compat recipes with empty models list require a user-provided model. + // #1292/D6: a user-provided / proxy embedding recipe that ships no default + // dimension (LiteLLM declares default_dims:0) needs an explicit + // embedding_dimensions — otherwise embed() silently falls back to a wrong + // vector width at write time. Fail closed here with a clear, actionable reason. + // + // This REPLACES the prior `user_provided_model_unset` guard, which was a + // structurally-unreachable "no model picked" check: parseModelId throws on a + // bare provider (model-resolver.ts), so by the time we reach here + // parsed.modelId is ALWAYS non-empty. That guard only ever false-positived for + // a fully-specified litellm: with dims set, silently disabling vector + // search. The genuine "picked a user-provided provider but no model" UX is + // handled at the config/init layer, where a bare provider string still exists. const isUserProvided = (tp as any).user_provided_models === true; - if ( - Array.isArray(tp.models) && - tp.models.length === 0 && - (recipe.id === 'litellm' || isUserProvided) - ) { + const recipeDefaultDims = tp.default_dims ?? 0; + if ((isUserProvided || recipeDefaultDims === 0) && !_config!.embedding_dimensions) { return { ok: false, - reason: 'user_provided_model_unset', + reason: 'user_provided_dims_unset', model: modelStr, provider: parsed.providerId, recipeId: recipe.id, @@ -1200,7 +1244,8 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon `OpenAI embedding requires OPENAI_API_KEY.`, recipe.setup_hint, ); - const client = createOpenAI({ apiKey }); + const baseURL = resolveNativeBaseUrl('openai', cfg); + const client = createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); // AI SDK v6: use .textEmbeddingModel() for embeddings return (client as any).textEmbeddingModel ? (client as any).textEmbeddingModel(modelId) @@ -2123,7 +2168,8 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon case 'native-openai': { const apiKey = cfg.env.OPENAI_API_KEY; if (!apiKey) throw new AIConfigError(`OpenAI expansion requires OPENAI_API_KEY.`, recipe.setup_hint); - return createOpenAI({ apiKey }).languageModel(modelId); + const baseURL = resolveNativeBaseUrl('openai', cfg); + return createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId); } case 'native-google': { const apiKey = cfg.env.GOOGLE_GENERATIVE_AI_API_KEY; @@ -2133,7 +2179,8 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon case 'native-anthropic': { const apiKey = cfg.env.ANTHROPIC_API_KEY; if (!apiKey) throw new AIConfigError(`Anthropic expansion requires ANTHROPIC_API_KEY.`, recipe.setup_hint); - return createAnthropic({ apiKey }).languageModel(modelId); + const baseURL = resolveNativeBaseUrl('anthropic', cfg); + return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId); } case 'openai-compatible': { // D12=A: unified auth via Recipe.resolveAuth (or default). @@ -2495,7 +2542,8 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig): case 'native-openai': { const apiKey = cfg.env.OPENAI_API_KEY; if (!apiKey) throw new AIConfigError(`OpenAI chat requires OPENAI_API_KEY.`, recipe.setup_hint); - return createOpenAI({ apiKey }).languageModel(modelId); + const baseURL = resolveNativeBaseUrl('openai', cfg); + return createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId); } case 'native-google': { const apiKey = cfg.env.GOOGLE_GENERATIVE_AI_API_KEY; @@ -2505,7 +2553,8 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig): case 'native-anthropic': { const apiKey = cfg.env.ANTHROPIC_API_KEY; if (!apiKey) throw new AIConfigError(`Anthropic chat requires ANTHROPIC_API_KEY.`, recipe.setup_hint); - return createAnthropic({ apiKey }).languageModel(modelId); + const baseURL = resolveNativeBaseUrl('anthropic', cfg); + return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId); } case 'openai-compatible': { // D12=A: unified auth via Recipe.resolveAuth (or default). diff --git a/src/core/ai/recipes/litellm-proxy.ts b/src/core/ai/recipes/litellm-proxy.ts index bc82b63e8..138e0746b 100644 --- a/src/core/ai/recipes/litellm-proxy.ts +++ b/src/core/ai/recipes/litellm-proxy.ts @@ -6,7 +6,7 @@ import type { Recipe } from '../types.ts'; * gbrain at it via `LITELLM_BASE_URL`. The proxy normalizes to * OpenAI-compatible API. * - * See docs/guides/litellm-proxy.md for the setup recipe. + * See docs/integrations/embedding-providers.md for the setup recipe. */ export const litellmProxy: Recipe = { id: 'litellm', @@ -25,6 +25,7 @@ export const litellmProxy: Recipe = { models: [], user_provided_models: true, // v0.32 D8=A wire-through for the litellm hardcode default_dims: 0, // user must declare --embedding-dimensions explicitly + trust_custom_dims: true, // #2271: proxy-backed model dim is user-declared cost_per_1m_tokens_usd: undefined, price_last_verified: '2026-04-20', // LiteLLM's batch capacity is determined by the backend it proxies; @@ -41,5 +42,5 @@ export const litellmProxy: Recipe = { supports_multimodal: true, }, }, - setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL + pass --embedding-model litellm: and --embedding-dimensions .', + setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm: and --embedding-dimensions .', }; diff --git a/src/core/ai/recipes/llama-server.ts b/src/core/ai/recipes/llama-server.ts index 1bc8b97cc..212bd5478 100644 --- a/src/core/ai/recipes/llama-server.ts +++ b/src/core/ai/recipes/llama-server.ts @@ -32,6 +32,7 @@ export const llamaServer: Recipe = { models: [], // user-driven; whatever model the server was launched with user_provided_models: true, default_dims: 0, // forces explicit --embedding-dimensions + trust_custom_dims: true, // #2271: user knows the launched model's native dim cost_per_1m_tokens_usd: 0, price_last_verified: '2026-05-10', // llama-server's batch capacity is set by `--ctx-size` at launch diff --git a/src/core/ai/recipes/ollama.ts b/src/core/ai/recipes/ollama.ts index 0f355cdae..361192f3f 100644 --- a/src/core/ai/recipes/ollama.ts +++ b/src/core/ai/recipes/ollama.ts @@ -13,8 +13,20 @@ export const ollama: Recipe = { }, touchpoints: { embedding: { - models: ['nomic-embed-text', 'mxbai-embed-large', 'all-minilm'], + // #2271: modern local embed models added so assertTouchpoint accepts them. + // Each carries its own native dim (qwen3-embed-8b=4096, arctic-l-v2=1024); + // the recipe-wide default_dims below is only the nomic fallback, so users + // of the larger models pass --embedding-dimensions (allowed via + // trust_custom_dims). Per-model dims metadata is a tracked follow-up. + models: [ + 'nomic-embed-text', + 'mxbai-embed-large', + 'all-minilm', + 'qwen3-embed-8b', + 'snowflake-arctic-embed-l-v2', + ], default_dims: 768, // nomic-embed-text native dim + trust_custom_dims: true, // #2271: local models carry varied native dims cost_per_1m_tokens_usd: 0, price_last_verified: '2026-04-20', // Ollama's batch capacity depends on the locally loaded model + the diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts index 79e4122e9..4dced5c6d 100644 --- a/src/core/ai/types.ts +++ b/src/core/ai/types.ts @@ -87,6 +87,17 @@ export interface EmbeddingTouchpoint { * for shorthand `--model ` and prints a setup hint. */ user_provided_models?: true; + /** + * #2271: trust a user-supplied `--embedding-dimensions` for this recipe even + * when it's not in the known-Matryoshka allowlist. Set ONLY on local / + * bring-your-own-backend recipes where the user knows their model's native dim + * and we can't enumerate every model (ollama, llama-server, litellm). The + * provider's `/embeddings` response-dim validation catches a genuine mismatch + * pre-storage. Must NOT be set on fixed-dim hosted providers (openai/voyage/ + * zeroentropy stay fail-closed) or on recipes that declare recipe-wide + * `dims_options` (e.g. openrouter, whose Tier-1 options legitimately govern). + */ + trust_custom_dims?: true; /** * v0.32 (#779 reworked): explicit opt-out of the missing-max_batch_tokens * startup warning. Set to `true` for recipes whose batch capacity is diff --git a/src/core/embed-preflight.ts b/src/core/embed-preflight.ts index 4f882aef7..bf7db47d0 100644 --- a/src/core/embed-preflight.ts +++ b/src/core/embed-preflight.ts @@ -91,11 +91,12 @@ export function formatEmbeddingCredsError(d: EmbeddingDiagnosis): string { ' Or run with --no-embed to import-only and embed later.', ].join('\n'); - case 'user_provided_model_unset': + case 'user_provided_dims_unset': return [ - `Provider "${d.provider}" requires a specific model name to be configured.`, + `Provider "${d.provider}" ships no default embedding dimension; set one explicitly.`, '', - ` Set one: gbrain config set embedding_model ${d.provider}:`, + ` Re-init with the dimension: gbrain init --embedding-dimensions `, + ' (embedding_dimensions is a schema-sizing field — `config set` rejects it on purpose)', ' Or run with --no-embed to import-only and embed later.', ].join('\n'); diff --git a/src/core/embedding-dim-check.ts b/src/core/embedding-dim-check.ts index 220bcca05..b8d022a8f 100644 --- a/src/core/embedding-dim-check.ts +++ b/src/core/embedding-dim-check.ts @@ -451,6 +451,18 @@ function isCustomDimValidForProvider( }; } + // Passthrough tier (#2271): local / bring-your-own-backend recipes (ollama, + // llama-server, litellm) flag trust_custom_dims because the user knows their + // model's native dim and we can't enumerate every locally-pulled model. Trust + // the requested dim; the provider's /embeddings response-dim validation catches + // a genuine mismatch pre-storage. Runs AFTER Tier 1 (recipe dims_options) and + // Tier 2 (provider Matryoshka allowlists) so a recipe that DOES declare fixed + // options (e.g. openrouter) is still governed by those, and fixed-dim hosted + // providers (openai/voyage/zeroentropy) never reach here as valid. + if (recipe.touchpoints.embedding?.trust_custom_dims === true) { + return { valid: true, error: '' }; + } + // Tier 3: provider not known to support custom dims at all. return { valid: false, diff --git a/src/core/init-embed-check.ts b/src/core/init-embed-check.ts index c9ad04cb5..5a5d5b217 100644 --- a/src/core/init-embed-check.ts +++ b/src/core/init-embed-check.ts @@ -115,8 +115,9 @@ function formatInitEmbedWarning(d: Exclude): s case 'no_touchpoint': lines.push(` Provider "${d.provider}" has no embedding touchpoint.`); break; - case 'user_provided_model_unset': - lines.push(` Provider "${d.provider}" needs an explicit model id (provider:model).`); + case 'user_provided_dims_unset': + lines.push(` Provider "${d.provider}" ships no default embedding dimension — set one explicitly.`); + lines.push(` re-run: gbrain init --embedding-dimensions (e.g. 1024 for bge-large)`); break; case 'no_model_configured': lines.push(' No embedding model is configured.'); diff --git a/test/ai/build-gateway-config.test.ts b/test/ai/build-gateway-config.test.ts index 72bd146d1..5284c5bc8 100644 --- a/test/ai/build-gateway-config.test.ts +++ b/test/ai/build-gateway-config.test.ts @@ -85,3 +85,35 @@ describe('buildGatewayConfig env-baseURL passthrough', () => { ); }); }); + +describe('buildGatewayConfig env empty-string clobber guard (#1249)', () => { + test('an empty-string process.env value does NOT clobber a valid config-plane key', async () => { + // Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls. + await withEnv({ ANTHROPIC_API_KEY: '' }, async () => { + const cfg = buildGatewayConfig({ + anthropic_api_key: 'sk-config-plane', + } as unknown as GBrainConfig); + expect(cfg.env.ANTHROPIC_API_KEY).toBe('sk-config-plane'); + }); + }); + + test('a real process.env value still wins over the config-plane fallback', async () => { + await withEnv({ ANTHROPIC_API_KEY: 'sk-env-plane' }, async () => { + const cfg = buildGatewayConfig({ + anthropic_api_key: 'sk-config-plane', + } as unknown as GBrainConfig); + expect(cfg.env.ANTHROPIC_API_KEY).toBe('sk-env-plane'); + }); + }); + + test("legitimate falsy-but-present values ('0' / 'false') are preserved, not dropped", async () => { + await withEnv( + { GBRAIN_TEST_ZERO_VAL: '0', GBRAIN_TEST_FALSE_VAL: 'false' }, + async () => { + const cfg = buildGatewayConfig(baseConfig); + expect(cfg.env.GBRAIN_TEST_ZERO_VAL).toBe('0'); + expect(cfg.env.GBRAIN_TEST_FALSE_VAL).toBe('false'); + }, + ); + }); +}); diff --git a/test/ai/diagnose-embedding-dims.test.ts b/test/ai/diagnose-embedding-dims.test.ts new file mode 100644 index 000000000..08ec1a17f --- /dev/null +++ b/test/ai/diagnose-embedding-dims.test.ts @@ -0,0 +1,82 @@ +/** + * diagnoseEmbedding dims-presence guard (#1292 / eng-review D6). + * + * The old `user_provided_model_unset` guard fired for ANY litellm config with an + * empty recipe model-allowlist — including a fully-specified `litellm:bge-large` + * with dims set — so vector search was silently disabled ("No results."). It was + * also structurally unreachable as a "no model" check (parseModelId throws on a + * bare provider). It's replaced with a real dimensions-presence check: a + * user-provided / zero-default recipe with no explicit embedding_dimensions fails + * CLOSED here with a clear reason, instead of returning ok and failing with a + * cryptic wrong-width error at embed time. + * + * No embed transport is installed (resetGateway clears it) so diagnoseEmbedding + * runs its real config-only logic rather than the test fast-path. + */ + +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { + configureGateway, + diagnoseEmbedding, + getEmbeddingDimensions, + resetGateway, +} from '../../src/core/ai/gateway.ts'; +import { DEFAULT_EMBEDDING_DIMENSIONS } from '../../src/core/ai/defaults.ts'; + +afterEach(() => resetGateway()); + +// Shard hygiene: a file must not END with a reset/non-legacy gateway. The +// legacy-embedding-preload restores 1536 per-TEST, but the NEXT file's +// beforeAll (often engine.initSchema, which sizes vector columns from the +// ambient gateway) runs before any beforeEach — so leaving the gateway null +// here would seed 1280-d schemas under that file's 1536-d fixtures. +afterAll(() => { + configureGateway({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 1536, + env: { ...process.env }, + }); +}); + +describe('diagnoseEmbedding dims-presence guard (#1292/D6)', () => { + test('litellm: WITH embedding_dimensions → available (the #1292 false-positive is gone)', () => { + configureGateway({ + embedding_model: 'litellm:bge-large', + embedding_dimensions: 1024, + env: {}, + }); + const d = diagnoseEmbedding(); + expect(d.ok).toBe(true); + }); + + test('litellm: WITHOUT embedding_dimensions → fails closed with user_provided_dims_unset', () => { + configureGateway({ + embedding_model: 'litellm:bge-large', + // no embedding_dimensions on purpose + env: {}, + }); + const d = diagnoseEmbedding(); + expect(d.ok).toBe(false); + if (!d.ok) expect(d.reason).toBe('user_provided_dims_unset'); + }); + + test('a fixed-dim provider with its own default_dims needs no explicit dimension', () => { + configureGateway({ + embedding_model: 'openai:text-embedding-3-small', + env: { OPENAI_API_KEY: 'sk-test' }, + }); + const d = diagnoseEmbedding(); + expect(d.ok).toBe(true); + }); + + test('backfill invariant: configureGateway keeps embedding_dimensions honest, but getEmbeddingDimensions() still defaults', () => { + // The dims-presence guard depends on _config.embedding_dimensions being + // undefined when unset (configureGateway no longer fabricates a default). + // Downstream readers must still see the default via their own `??`. + configureGateway({ + embedding_model: 'openai:text-embedding-3-small', + env: { OPENAI_API_KEY: 'sk-test' }, + }); + expect(getEmbeddingDimensions()).toBe(DEFAULT_EMBEDDING_DIMENSIONS); + }); +}); diff --git a/test/ai/resolve-native-base-url.test.ts b/test/ai/resolve-native-base-url.test.ts new file mode 100644 index 000000000..54a6a62cc --- /dev/null +++ b/test/ai/resolve-native-base-url.test.ts @@ -0,0 +1,63 @@ +/** + * resolveNativeBaseUrl unit tests (#1250). + * + * Native providers (anthropic/openai) are instantiated as create({ apiKey }) + * with no explicit baseURL, so the AI SDK reads _BASE_URL verbatim. A bare + * host (Claude Code injects ANTHROPIC_BASE_URL=https://api.anthropic.com with no /v1) + * makes the SDK POST /messages → 404. resolveNativeBaseUrl normalizes a configured + * base URL to carry /v1, and returns undefined when unset so the SDK default is preserved. + * + * Pure function over cfg.env — no process.env mutation, so no withEnv() needed. + */ + +import { describe, expect, test } from 'bun:test'; +import { resolveNativeBaseUrl } from '../../src/core/ai/gateway.ts'; +import type { AIGatewayConfig } from '../../src/core/ai/types.ts'; + +function cfgWith(env: Record): AIGatewayConfig { + return { env } as unknown as AIGatewayConfig; +} + +describe('resolveNativeBaseUrl (#1250)', () => { + test('anthropic: bare host gets /v1 appended', () => { + expect( + resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://api.anthropic.com' })), + ).toBe('https://api.anthropic.com/v1'); + }); + + test('anthropic: already-/v1 host is unchanged', () => { + expect( + resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://api.anthropic.com/v1' })), + ).toBe('https://api.anthropic.com/v1'); + }); + + test('anthropic: trailing slashes are normalized', () => { + expect( + resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://proxy.example/' })), + ).toBe('https://proxy.example/v1'); + expect( + resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: 'https://proxy.example/v1/' })), + ).toBe('https://proxy.example/v1'); + }); + + test('anthropic: unset / empty → undefined so the SDK default is preserved [REGRESSION]', () => { + expect(resolveNativeBaseUrl('anthropic', cfgWith({}))).toBeUndefined(); + expect(resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: '' }))).toBeUndefined(); + expect(resolveNativeBaseUrl('anthropic', cfgWith({ ANTHROPIC_BASE_URL: ' ' }))).toBeUndefined(); + }); + + test('openai: reads OPENAI_BASE_URL with the same normalization', () => { + expect( + resolveNativeBaseUrl('openai', cfgWith({ OPENAI_BASE_URL: 'https://api.openai.com' })), + ).toBe('https://api.openai.com/v1'); + expect( + resolveNativeBaseUrl('openai', cfgWith({ OPENAI_BASE_URL: 'https://api.openai.com/v1' })), + ).toBe('https://api.openai.com/v1'); + expect(resolveNativeBaseUrl('openai', cfgWith({}))).toBeUndefined(); + }); + + test('each provider only reads its own env var', () => { + expect(resolveNativeBaseUrl('openai', cfgWith({ ANTHROPIC_BASE_URL: 'https://x' }))).toBeUndefined(); + expect(resolveNativeBaseUrl('anthropic', cfgWith({ OPENAI_BASE_URL: 'https://x' }))).toBeUndefined(); + }); +}); diff --git a/test/embed-preflight.test.ts b/test/embed-preflight.test.ts index f61f1156d..5a0a0ca84 100644 --- a/test/embed-preflight.test.ts +++ b/test/embed-preflight.test.ts @@ -20,9 +20,21 @@ import type { AIGatewayConfig } from '../src/core/ai/types.ts'; // behind — and bun runs every file in a shard inside ONE process, so that // residue (e.g. OPENAI_API_KEY: 'sk-test') bleeds into the next file's // isAvailable('embedding') check. That's what made facts-backstop-gating -// fail intermittently (bin-pack-dependent) on CI shard 10. Clean up after -// the whole file so the gateway is pristine for whatever runs next. -afterAll(() => { resetGateway(); }); +// fail intermittently (bin-pack-dependent) on CI shard 10. +// +// Don't end on a bare resetGateway() either: the NEXT file's beforeAll +// (often engine.initSchema, which sizes vector columns from ambient gateway +// state) runs before the legacy-embedding-preload's per-test restore, so a +// null gateway here would seed 1280-d schemas under 1536-d fixtures. +// Restore the preload's legacy pin instead. +afterAll(() => { + resetGateway(); + configureGateway({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 1536, + env: { ...process.env }, + }); +}); function baseConfig(overrides: Partial = {}): AIGatewayConfig { return { @@ -36,6 +48,23 @@ function baseConfig(overrides: Partial = {}): AIGatewayConfig { }; } +describe('formatEmbeddingCredsError — user_provided_dims_unset (#1292/D6)', () => { + test('names the dimension fix, not a model fix', () => { + const msg = formatEmbeddingCredsError({ + ok: false, + reason: 'user_provided_dims_unset', + model: 'litellm:bge-large', + provider: 'litellm', + recipeId: 'litellm', + }); + expect(msg).toMatch(/dimension/i); + // Points at the ACCEPTED remediation, not the hard-rejected `config set` + // (config.ts refuses to write embedding_dimensions — a schema-sizing field). + expect(msg).toMatch(/gbrain init --embedding-dimensions/); + expect(msg).not.toMatch(/config set embedding_dimensions/); + }); +}); + describe('validateEmbeddingCreds', () => { beforeEach(() => { resetGateway(); }); diff --git a/test/embedding-dim-check.test.ts b/test/embedding-dim-check.test.ts index 6a945b98c..c2d2b6ba8 100644 --- a/test/embedding-dim-check.test.ts +++ b/test/embedding-dim-check.test.ts @@ -257,6 +257,55 @@ describe('resolveSchemaEmbeddingDim', () => { if (got.ok) expect(got.dim).toBe(768); }); + // #2271 — trust_custom_dims passthrough for local / BYO-backend recipes. + test('ollama accepts a custom dim for a modern model via trust_custom_dims', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'ollama:qwen3-embed-8b', + embedding_dimensions: 4096, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(4096); + }); + + test('litellm accepts a user-declared custom dim via trust_custom_dims', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'litellm:bge-large', + embedding_dimensions: 1024, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(1024); + }); + + test('llama-server accepts a user-declared custom dim via trust_custom_dims', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'llama-server:nomic-embed-text-v1.5', + embedding_dimensions: 2560, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(2560); + }); + + test('[REGRESSION] openrouter (declares dims_options, NOT flagged) still fail-closed on an unlisted dim', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openrouter:openai/text-embedding-3-small', + embedding_dimensions: 4096, + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/rejects custom dimensions 4096|does not support custom dimensions/); + }); + + test('[REGRESSION] trust_custom_dims does NOT bypass the pgvector column cap', () => { + // The passthrough tier trusts the user's dim, but the pgvector cap check runs + // BEFORE it — pin that ordering so a future refactor can't let an oversized + // dim through for a trusted local recipe. + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'ollama:qwen3-embed-8b', + embedding_dimensions: PGVECTOR_COLUMN_MAX_DIMS + 1, + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/exceed pgvector|column cap/i); + }); + test('unknown provider rejected with provider list hint', () => { const got = resolveSchemaEmbeddingDim({ embedding_model: 'notarealprovider:foo' }); expect(got.ok).toBe(false); diff --git a/test/engine-find-trajectory.test.ts b/test/engine-find-trajectory.test.ts index ab34ef61d..4415c4a4b 100644 --- a/test/engine-find-trajectory.test.ts +++ b/test/engine-find-trajectory.test.ts @@ -15,6 +15,7 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { configureGateway } from '../src/core/ai/gateway.ts'; import { detectRegressions, computeDriftScore, @@ -26,6 +27,17 @@ import type { TrajectoryPoint } from '../src/core/engine.ts'; let engine: PGLiteEngine; beforeAll(async () => { + // This file hardcodes 1536-d vectors (vecForMetric). initSchema sizes the + // facts halfvec column from the AMBIENT gateway state, and a beforeAll runs + // before the legacy-embedding-preload's per-test restore — so a preceding + // file in the shard that left the gateway reset or non-1536 would seed a + // 1280-d schema and every insert here would fail with a width mismatch. + // Pin the schema shape explicitly (the pattern bunfig's preload documents). + configureGateway({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 1536, + env: { ...process.env }, + }); engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); diff --git a/test/facts-extract-silent-no-op.test.ts b/test/facts-extract-silent-no-op.test.ts index b5c140922..4272bb5d6 100644 --- a/test/facts-extract-silent-no-op.test.ts +++ b/test/facts-extract-silent-no-op.test.ts @@ -18,7 +18,7 @@ * `ANTHROPIC_API_KEY`); this test is the structural regression guard that * runs in every parallel-test shard. */ -import { describe, test, expect, beforeEach } from 'bun:test'; +import { afterAll, describe, test, expect, beforeEach } from 'bun:test'; import { configureGateway, isAvailable, @@ -33,6 +33,25 @@ beforeEach(() => { __setChatTransportForTests(null); }); +// Shard hygiene: this file's tests each configureGateway WITHOUT +// embedding_dimensions, and the file used to END in that state. The +// legacy-embedding-preload only re-pins 1536 when the gateway is RESET +// (it checks by calling getEmbeddingDimensions, which doesn't throw on a +// configured-but-dimensionless gateway), and the NEXT file's beforeAll +// (often engine.initSchema, which sizes vector columns from ambient +// gateway state) runs before any beforeEach — so this file poisoned every +// later fresh-schema file in its shard down to 1280-d columns under +// 1536-d fixtures (bit engine-find-trajectory in CI shard 5). Restore the +// legacy pin on exit. +afterAll(() => { + __setChatTransportForTests(null); + configureGateway({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 1536, + env: { ...process.env }, + }); +}); + describe('facts extract — silent-no-op regression (v0.31.6 bug class)', () => { test('with valid model and ANTHROPIC_API_KEY present, isAvailable("chat") is true', () => { configureGateway({