diff --git a/CHANGELOG.md b/CHANGELOG.md index 96af47169..afe1118bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ All notable changes to GBrain will be documented in this file. +## [0.33.1.1] - 2026-05-13 + +**Voyage 2048-dim brains finally produce 2048-dim vectors. Fail-loud on every Voyage misconfiguration.** + +Voyage-backed brains configured for high-dimensional embeddings (2048 wide for richer recall on long-form content) were silently producing 1024-dim vectors on every embed call. The dimension instruction was being routed through a wire-key that the AI SDK's openai-compatible adapter doesn't recognize, so it got dropped before the HTTP request was built. Voyage returned its default 1024-dim, the gateway dimension check threw, and brains failed to ingest. This release lands the upstream fix from @100yenadmin and stacks three Voyage-adjacent correctness follow-ups that adversarial Codex review caught during PR review. + +### What you can now do + +**Run Voyage brains at 2048 dims and actually get 2048 dims.** `embedding_model: voyage:voyage-4-large` + `embedding_dimensions: 2048` now routes through the SDK-supported `dimensions` field, which the existing `voyageCompatFetch` shim rewrites to Voyage's `output_dimension` on the wire. New wire-level test asserts the actual outbound HTTP body contains `output_dimension: 2048`. Same fix covers voyage-3-large, voyage-3.5, voyage-3.5-lite, voyage-4, voyage-4-lite, voyage-code-3. + +**Get caught at config time, not first-embed.** `gbrain models doctor` now runs an `embedding_config` probe before any token-spending chat/expansion probes. If your brain is set to a Voyage flexible-dim model with `embedding_dimensions` outside `{256, 512, 1024, 2048}` (the most common trip: leaving `embedding_dimensions` unset, which falls back to the OpenAI default of 1536), doctor surfaces it with a paste-ready `gbrain config set` fix. The runtime `dimsProviderOptions` validator throws an `AIConfigError` at the embed boundary with the same fix hint, so even if you skipped doctor you get a clear message naming the valid values instead of an opaque Voyage HTTP 400. + +**voyage-4-nano stays in its lane.** Voyage's `voyage-4-nano` is an open-weight variant listed separately by Voyage as fixed 1024-dim — it doesn't accept `output_dimension`. Dropped from the flexible-dim allowlist; recipe docstring tightened to name the seven hosted flexible-dim models explicitly so the "all v4 variants are flexible" claim doesn't lead the next contributor to re-add nano. A negative regression test pins the contract. + +**Voyage OOM cap is now actually effective.** `voyageCompatFetch`'s 256 MB per-response cap (the defense-in-depth check that fires when Content-Length is missing on chunked encoding) was being silently swallowed by the surrounding parse-error try/catch — a malicious or misbehaving Voyage endpoint returning a multi-gigabyte response could have OOMed the worker. Now wrapped in a tagged `VoyageResponseTooLargeError` class that the catch rethrows on `instanceof`, so the cap fails loud instead of returning the oversized response into the AI SDK's JSON parser. + +### Why this matters + +Voyage is the gbrain-recommended embedding provider for users who want pgvector-native, high-quality embeddings without depending on OpenAI. The 2048-dim bug was a structural footgun: every first-time Voyage user hitting it without obvious cause. Eva (@100yenadmin) caught and fixed the root cause; the follow-up wave plugs the three adjacent correctness gaps that pre-empted future Voyage users hitting similar opaque failures. + +## To take advantage of v0.33.1.1 + +`gbrain upgrade` should do this automatically. If you're already on Voyage: + +1. **Verify your dim config is valid:** + ```bash + gbrain models doctor + ``` + You should see a new `embedding_config` line; if it reports `config` status, follow the paste-ready fix. +2. **No reindex required** if you were already on a Voyage flexible-dim model with the correct `embedding_dimensions` (the bug surfaced as fail-loud at embed time, so existing brains didn't silently get wrong-width vectors — they failed to embed). +3. **If `gbrain doctor` warns about anything,** file an issue at https://github.com/garrytan/gbrain/issues with: + - output of `gbrain doctor` + - your `embedding_model` + `embedding_dimensions` config values + +### Itemized changes + +#### Fixed + +- **Voyage 2048-dim embeddings (closes #866).** `src/core/ai/dims.ts` `dimsProviderOptions` now returns `{ openaiCompatible: { dimensions: N } }` (SDK-supported) instead of `{ openaiCompatible: { output_dimension: N } }` (Voyage wire-key the SDK silently dropped). Existing `voyageCompatFetch` in `src/core/ai/gateway.ts:541` translates `dimensions → output_dimension` before the HTTP body is sent. Contributed by @100yenadmin (PR #866, re-landed in #962 due to `maintainerCanModify` cross-fork push limitation — authorship preserved on the original commit). + +- **Voyage OOM-cap rethrow.** `src/core/ai/gateway.ts:619` Layer 2 base64 cap was throwing a generic `Error` that the surrounding `catch {}` swallowed, then returning the original (oversized) response to the AI SDK. New `VoyageResponseTooLargeError` tagged class is now thrown at both cap sites (Content-Length Layer 1 at `:595` and per-embedding Layer 2 at `:619`) and rethrown from the inbound try/catch via `instanceof` check. Parse-error fall-back behavior preserved for non-OOM errors. Codex P3 follow-up. + +- **Voyage flexible-dim runtime validation.** `dimsProviderOptions` now throws `AIConfigError` with a paste-ready fix hint when a Voyage flexible-dim model is configured with anything outside `{256, 512, 1024, 2048}`. Most common trigger: `embedding_model: voyage:voyage-4-large` without `embedding_dimensions` (falls back to default 1536 — not Voyage-accepted). Codex P3 follow-up. + +#### Changed + +- **voyage-4-nano dropped from `VOYAGE_OUTPUT_DIMENSION_MODELS`.** Open-weight model, fixed-dim 1024 per Voyage docs; doesn't accept `output_dimension`. Recipe docstring at `src/core/ai/recipes/voyage.ts:7-16` tightened so the "all v4 variants" claim doesn't lead future contributors to re-add it. Negative regression test in `test/ai/gateway.test.ts` pins the contract. + +- **`gbrain models doctor` runs an `embedding_config` probe first** (zero tokens, local-only). Surfaces Voyage flexible-dim mismatches before any chat/expansion probes spend money. New `config` probe status + `fix` hint rendered in both human and JSON output. New `embedding_config` touchpoint label appears alongside `chat` / `expansion`. + +#### Tests + +- New wire-level test (`test/ai/gateway.test.ts`): stubs `globalThis.fetch` and asserts the outbound Voyage request body contains `output_dimension: 2048` + `encoding_format: base64`. Catches the exact regression class that motivated #866. +- Two new behavioral tests for the OOM-cap rethrow (Content-Length over cap + oversized base64 string both propagate). +- Six new tests for the flexible-dim runtime validator (1536 + 3072 rejected, all valid sizes accepted, `voyage-3-lite` / `voyage-4-nano` bypass validator, fix-hint contents). +- Source-shape regression assertion in `test/voyage-response-cap.test.ts` pins the `instanceof VoyageResponseTooLargeError ⇒ throw err` line. + +#### For contributors + +- New tagged error class `VoyageResponseTooLargeError` is exported from `src/core/ai/gateway.ts` (test-only seam; not part of the public AI SDK surface). +- New exports from `src/core/ai/dims.ts`: `VOYAGE_VALID_OUTPUT_DIMS` (`[256, 512, 1024, 2048] as const`) and `isValidVoyageOutputDim(dims: number)`. Reuse these if you add a Voyage-adjacent probe or doctor check; do NOT inline the magic numbers. + ## [0.33.1.0] - 2026-05-10 **Ask gbrain who in your network knows about a topic, and get a ranked answer with the reasoning shown.** diff --git a/CLAUDE.md b/CLAUDE.md index 27c327de4..16048469f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,16 +92,17 @@ strict behavior when unset. - `docs/eval-capture.md` (v0.25.0) — stable NDJSON schema reference for gbrain-evals consumers. - `test/public-exports.test.ts` (v0.25.0 / R2) — runtime contract test. Imports each of the 17 public subpaths via package name and pins a canary symbol per module. Paired with `scripts/check-exports-count.sh`. - `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff. **v0.28.7:** `BATCH_SIZE` reverted 50→100 — the original Voyage safety guard halved OpenAI throughput on every page. Per-recipe pre-split + recursive halving + adaptive shrink-on-miss now live in the gateway, so the outer paginator goes back to its original purpose: progress-callback granularity, not batch protection. +- `src/core/ai/dims.ts` (v0.33.1.1, PR #962 + #866) — 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)`, and `isValidVoyageOutputDim(dims)`. **Voyage path uses the SDK-supported `dimensions` field** (`{ openaiCompatible: { dimensions: N } }`), NOT Voyage's `output_dimension` wire-key — the existing `voyageCompatFetch` shim in `gateway.ts:541` translates `dimensions → output_dimension` before the HTTP body is built. The reverse (sending `output_dimension` from here) was the v0.33.1.0 bug class: the AI SDK's openai-compatible adapter doesn't recognize the wire-key so it was silently dropped, Voyage returned its default 1024-dim, and the gateway dimension check threw on every embed call. 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 — fail-loud instead of opaque Voyage HTTP 400. Most common trigger: `embedding_model: voyage:voyage-4-large` configured without `embedding_dimensions` (falls back to `DEFAULT_EMBEDDING_DIMENSIONS=1536`, an OpenAI default not a Voyage one). Eva (@100yenadmin) shipped the wire-key fix in #866; Codex P3 follow-up landed the validator + valid-dims allowlist in #962. - `src/core/ai/types.ts` — provider/recipe types. **v0.28.7 (#680):** `EmbeddingTouchpoint` extended with optional `chars_per_token` (default 4 chars/token, 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) that overshoot tiktoken. The pre-split budget is `max_batch_tokens × safety_factor / chars_per_token`. **v0.28.11 (#719):** `EmbeddingTouchpoint.multimodal_models?: string[]` model-level allow-list for recipes that mix 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` — brains using OpenAI for text can use Voyage for images without flipping the primary embedding pipeline. -- `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.28.7 (#680):** module-scoped `_embedTransport` defaulting to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive the public `embed()` function with a stubbed transport instead of probing private helpers. `splitByTokenBudget` and `isTokenLimitError` are now exported `@internal` — pure functions reused directly 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 toward the ceiling after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction time and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path recipe). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. ASCII flow diagram embedded in the `embed()` JSDoc covers the routing decision, recursion + halving, and shrinkState lifecycle. **v0.28.11 (#719):** `embedMultimodal()` reads `cfg.embedding_multimodal_model` first (falls back to `cfg.embedding_model` for single-model setups). After the existing recipe-level `supports_multimodal` fast-fail, validates the resolved model against `touchpoint.multimodal_models` when declared — closes the Voyage-text-only-model-into-multimodal-endpoint footgun before any HTTP call (Codex F1 from PR review). New `getMultimodalModel()` accessor mirrors `getEmbeddingModel` / `getChatModel` so doctor and integration tests can read the gateway state. -- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage. **v0.28.11 (#719):** declares `multimodal_models: ['voyage-multimodal-3']` so the gateway rejects text-only Voyage models pointed at the multimodal endpoint with a clear `AIConfigError` instead of waiting for Voyage's HTTP 400. +- `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.28.7 (#680):** module-scoped `_embedTransport` defaulting to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive the public `embed()` function with a stubbed transport instead of probing private helpers. `splitByTokenBudget` and `isTokenLimitError` are now exported `@internal` — pure functions reused directly 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 toward the ceiling after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction time and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path recipe). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. ASCII flow diagram embedded in the `embed()` JSDoc covers the routing decision, recursion + halving, and shrinkState lifecycle. **v0.28.11 (#719):** `embedMultimodal()` reads `cfg.embedding_multimodal_model` first (falls back to `cfg.embedding_model` for single-model setups). After the existing recipe-level `supports_multimodal` fast-fail, validates the resolved model against `touchpoint.multimodal_models` when declared — closes the Voyage-text-only-model-into-multimodal-endpoint footgun before any HTTP call (Codex F1 from PR review). New `getMultimodalModel()` accessor mirrors `getEmbeddingModel` / `getChatModel` so doctor and integration tests can read the gateway state. **v0.33.1.1 (#962, Codex P3 follow-up):** new exported `VoyageResponseTooLargeError` tagged class at the top of the file. `voyageCompatFetch`'s two OOM-defense caps (Layer 1 Content-Length check at `:595`, Layer 2 per-embedding base64 cap at `:619`) now throw `VoyageResponseTooLargeError` instead of a generic `Error`. The inbound response-rewriter's surrounding try/catch (which intentionally swallows parse failures so misshaped Voyage responses fall through to the SDK's JSON parser) checks `instanceof VoyageResponseTooLargeError` and rethrows. Pre-fix, the Layer 2 throw was silently swallowed and the oversized response returned to the AI SDK anyway — Layer 2 was theatrical. Source-shape regression assertion in `test/voyage-response-cap.test.ts` pins the `instanceof ⇒ throw err` line. +- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage. **v0.28.11 (#719):** declares `multimodal_models: ['voyage-multimodal-3']` so the gateway rejects text-only Voyage models pointed at the multimodal endpoint with a clear `AIConfigError` instead of waiting for Voyage's HTTP 400. **v0.33.1.1 (#962, fixup):** recipe docstring at `:7-16` tightened to name the seven hosted flexible-dim models that accept `output_dimension` explicitly (`voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-3`) and call out that `voyage-4-nano` is the open-weight variant listed separately by Voyage as fixed 1024-dim — does NOT accept the parameter. The "all v4 variants are flexible" misread is what caused the original PR to include nano in `VOYAGE_OUTPUT_DIMENSION_MODELS`; the negative regression assertion in `test/ai/gateway.test.ts` (`dimsProviderOptions` returns `undefined` for `voyage-4-nano`) pins the contract. - `src/core/ai/recipes/anthropic.ts` — Anthropic recipe (chat + expansion touchpoints). **v0.31.12:** chat and expansion `models:` lists drop the v0.31.6 phantom `claude-sonnet-4-6-20250929` date suffix — canonical id is `claude-sonnet-4-6`. The wrong-direction alias `claude-sonnet-4-6 → claude-sonnet-4-6-20250929` is removed; a reverse alias `claude-sonnet-4-6-20250929 → claude-sonnet-4-6` keeps stale user configs working (rescues `facts.extraction_model` and `models.dream.synthesize` set by v0.31.6 installs). Recipe-shape regression pinned by `test/anthropic-model-ids.test.ts` (6 cases, verbatim cherry-pick of PR #830 plus the reverse-alias rescue case). - `src/core/anthropic-pricing.ts` — Single source of truth for Anthropic model pricing (per-MTok input/output). **v0.31.12:** Opus 4.7 corrected from `$15/$75` to `$5/$25` (the old number was from Opus 4 generation, never refreshed when 4.7 shipped); Opus 4.6 also corrected. Consumed by `src/core/budget-meter.ts` and `src/core/cross-modal-eval/runner.ts` — the cross-modal estimator now reads `ANTHROPIC_PRICING` for Anthropic models instead of duplicating the table, killing the v0.31.6 drift bug class. - `src/core/model-config.ts` — Model-string resolution (the seam every internal LLM call walks through). **v0.31.12:** four-tier system (`ModelTier = 'utility' | 'reasoning' | 'deep' | 'subagent'`) with `TIER_DEFAULTS` (utility→haiku-4-5, reasoning→sonnet-4-6, deep→opus-4-7, subagent→sonnet-4-6) and `tier?: ModelTier` on `ResolveModelOpts`. Resolution chain is now 8 steps: cliFlag → deprecated key → config key → `models.default` → `models.tier.` → env var → `TIER_DEFAULTS[tier]` → caller fallback. Two new exports — `isAnthropicProvider(modelString)` checks `provider:model` prefix OR `claude-` bare-id pattern, and `enforceSubagentAnthropic()` is the layer-2 runtime guard: when `tier === 'subagent'` resolves to a non-Anthropic provider, it emits a once-per-`(source, model)` stderr warn AND falls back to `TIER_DEFAULTS.subagent` instead of letting the Anthropic Messages API tool-loop attempt to run on OpenAI/Gemini. `_resetDeprecationWarningsForTest()` now also clears `_subagentTierWarningsEmitted` so tests re-emit. - `src/core/ai/model-resolver.ts` — Recipe-touchpoint validator. **v0.31.12:** `assertTouchpoint(recipe, touchpoint, modelId, extendedModels?)` gains an optional 4th `extendedModels: ReadonlySet` argument. When the modelId is in that set, the native-recipe allowlist throw is bypassed — the user explicitly opted into this model via config so we let provider rejection surface as `model_not_found` at HTTP call time (and `gbrain models doctor` catches it earlier). Default code paths with hardcoded model strings MUST NOT pass `extendedModels` — typos in source code still fail fast. Replaces the earlier plan to soften the validator wholesale (Codex F4/F5 in plan review flagged that as too broad — it would have removed the fail-fast contract for chat + expand + embed all three). - `src/core/ai/gateway.ts` extension (v0.31.12) — new module-scoped `_extendedModels: Map>` registry feeds `assertTouchpoint`'s 4th-arg path. New `reconfigureGatewayWithEngine(engine)` async function is called from `cli.ts` after `engine.connect()` (and before every command except `CLI_ONLY` no-DB commands) — re-resolves expansion + chat defaults through `resolveModel()` so `models.tier.*` and `models.default` overrides apply to expansion + chat both. `DEFAULT_CHAT_MODEL` corrected to `anthropic:claude-sonnet-4-6` (was the v0.31.6 phantom `-20250929`). New `__setChatTransportForTests` seam mirrors `__setEmbedTransportForTests` so tests drive `chat()` with a stubbed transport. - `src/core/minions/queue.ts` extension (v0.31.12) — `MinionQueue.add()` now rejects `subagent` jobs whose `data.model` resolves through `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 (Codex F1+F2 in plan review). Layers 2 + 3 live in `src/core/model-config.ts` (`enforceSubagentAnthropic` runtime fallback) and `src/commands/doctor.ts` (`subagent_provider` check). Pinned by 3 cases in `test/agent-cli.test.ts`. -- `src/commands/models.ts` (v0.31.12) — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain to attribute properly), every per-task override (11 `PER_TASK_KEYS` entries — `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 (defaults + user overrides), and a source-of-truth column showing `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}` — the structural fix for the v0.31.6 silent-no-op bug class. Wired into `cli.ts` dispatch table + `CLI_ONLY` set. +- `src/commands/models.ts` (v0.31.12) — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain to attribute properly), every per-task override (11 `PER_TASK_KEYS` entries — `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 (defaults + user overrides), and a source-of-truth column showing `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}` — the structural fix for the v0.31.6 silent-no-op bug class. Wired into `cli.ts` dispatch table + `CLI_ONLY` set. **v0.33.1.1 (#962, Codex P3 follow-up):** doctor gains a zero-token `embedding_config` probe that runs FIRST, before any chat/expansion probes spend money. `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` from the gateway, parses the model id, and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. New `ProbeStatus` variant `'config'` and optional `fix?: string` field on `ProbeResult` — surfaced in both human output (paste-ready `gbrain config set ...` line under the bad probe) and JSON output. New touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'` in the probe-row taxonomy. Closes the Voyage flexible-dim bug class at config time, not first-embed. - `src/commands/doctor.ts` extension (v0.31.12) — new `subagent_provider` check (layer 3 of 3 — Codex F13). Warns when `models.tier.subagent` is explicitly set to a non-Anthropic provider (fail-loud since the user clearly meant it — message names the bad value and prints the paste-ready fix command `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 status when subagent tier resolves to Anthropic. Tests cover all three paths in `test/doctor.test.ts`. - `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`. - `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic. **v0.31.7:** read-path / write-path split. `autoDetectSkillsDir` (shared, read+write-safe) gains tier-0 `$GBRAIN_SKILLS_DIR` explicit operator override (Docker mounts, CI, monorepo subdirs) ahead of the existing 4-tier chain. New `autoDetectSkillsDirReadOnly` wraps it with a tier-5 install-path fallback that walks up from `fileURLToPath(import.meta.url)` and gates on `isGbrainRepoRoot` so unrelated repos can't false-positive. Read-path callers (`doctor`, `check-resolvable`, `routing-eval`) use the read-only variant; write-path callers (`skillpack install`, `skillify scaffold`, `post-install-advisory`) deliberately stay on the shared function so `gbrain skillpack install` from `~` cannot silently retarget the bundled gbrain repo's `skills/` instead of the user's actual workspace. Two new `SkillsDirSource` variants: `'env_explicit'`, `'install_path'`. New `AUTO_DETECT_HINT_READ_ONLY` documents the extra tier. The D6 `--fix` safety gate in `doctor.ts` + `check-resolvable.ts` refuses auto-repair when `detected.source === 'install_path'` so `gbrain doctor --fix` from `~` cannot silently rewrite the bundled install tree. @@ -350,6 +351,12 @@ Key commands added in v0.32.7 (CJK fix wave): - `gbrain search ""` on PGLite brains now uses an `ILIKE`-based fallback with bigram-frequency-count ranking when the query contains Han / Hiragana / Katakana / Hangul Syllables. ASCII queries continue through `websearch_to_tsquery('english')` unchanged. Postgres-side CJK FTS still requires an extension (pgroonga / zhparser) — see v0.33+ TODO. - `gbrain upgrade` post-upgrade flow now prints a cost estimate before re-embedding: `[chunker-bump] Will re-embed ~N markdown pages via , est. ~$X.XX, ~Ymin. Press Ctrl-C within 10s to abort.` Sourced from real SQL counts + char totals; TTY-only wait (non-TTY auto-proceeds for CI / cron). Env overrides: `GBRAIN_NO_REEMBED=1` bails out entirely with a doctor-warning marker; `GBRAIN_REEMBED_GRACE_SECONDS=0` skips the wait. +Key commands added in v0.33.1.1 (Voyage 2048-dim correctness wave): +- `gbrain models doctor` learns a new zero-token `embedding_config` probe that runs FIRST, before any chat/expansion probes spend money. Catches Voyage flexible-dim misconfigs at config time, not first-embed: `embedding_model: voyage:voyage-4-large` with `embedding_dimensions` outside `{256, 512, 1024, 2048}` (most commonly: `embedding_dimensions` left unset, falling back to the OpenAI default 1536 which Voyage rejects with an opaque HTTP 400). Surfaces a paste-ready `gbrain config set embedding_dimensions <256|512|1024|2048>` fix in both human and JSON output. New probe status `'config'` joins `{ok, model_not_found, auth, rate_limit, network, unknown}`; new touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'`. +- Voyage 2048-dim brains now actually embed at 2048 dims. `embedding_model: voyage:voyage-4-large` + `embedding_dimensions: 2048` routes through the SDK-supported `dimensions` field, which `voyageCompatFetch` translates to Voyage's `output_dimension` on the wire. Same fix covers `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-4`, `voyage-4-lite`, `voyage-code-3`. `voyage-4-nano` (open-weight, fixed 1024-dim) intentionally NOT in the flexible-dim allowlist — sending `output_dimension` to nano's endpoint produces an error. +- Runtime validator: `dimsProviderOptions()` throws `AIConfigError` at the embed boundary with a paste-ready fix hint when a Voyage flexible-dim model is configured with an invalid dim — fail-loud even if you skipped `gbrain models doctor`. +- `VoyageResponseTooLargeError` (new tagged class exported from `src/core/ai/gateway.ts`): the 256 MB per-response cap inside `voyageCompatFetch` was previously throwing a generic `Error` that the surrounding parse-error try/catch silently swallowed, returning the oversized response to the AI SDK anyway. Now thrown at both cap sites (Content-Length Layer 1, per-embedding base64 Layer 2) and rethrown from the catch via `instanceof` check — the cap is now actually effective. + Key commands added in v0.31.12 (model tier system + routing CLI): - `gbrain models [--json]` — read-only routing dashboard. Prints the four tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (after re-walking `models.default` → `models.tier.` → env → `TIER_DEFAULTS`), every per-task override (`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 (defaults + user overrides), and a source-of-truth column (`default` / `config: ` / `env: `). - `gbrain models doctor [--skip=] [--json]` — 1-token reachability probe against each configured chat + expansion model. Classifies failures into `{model_not_found, auth, rate_limit, network, unknown}`. The structural fix for the bug class that motivated v0.31.12 (v0.31.6's `claude-sonnet-4-6-20250929` chat default 404'd silently on every install). diff --git a/README.md b/README.md index 1a2d849b2..a15a14d02 100644 --- a/README.md +++ b/README.md @@ -759,8 +759,10 @@ ADMIN gbrain config set models.default opus gbrain config set models.tier.deep opus gbrain models doctor 1-token reachability probe for each configured - chat/expansion model. Catches `model_not_found` - before the next agent run silently degrades. + chat/expansion model + a zero-token embedding_config + probe (catches Voyage flexible-dim misconfigs before + first embed). Catches `model_not_found` before the + next agent run silently degrades. [--skip=] [--json] gbrain serve MCP server (stdio) gbrain serve --http [--port 3131] HTTP MCP server with OAuth 2.1 + admin dashboard diff --git a/VERSION b/VERSION index a725d6e3d..788dd47db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.1.0 \ No newline at end of file +0.33.1.1 diff --git a/llms-full.txt b/llms-full.txt index 88ca309a1..f79fb4a0c 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -192,16 +192,17 @@ strict behavior when unset. - `docs/eval-capture.md` (v0.25.0) — stable NDJSON schema reference for gbrain-evals consumers. - `test/public-exports.test.ts` (v0.25.0 / R2) — runtime contract test. Imports each of the 17 public subpaths via package name and pins a canary symbol per module. Paired with `scripts/check-exports-count.sh`. - `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff. **v0.28.7:** `BATCH_SIZE` reverted 50→100 — the original Voyage safety guard halved OpenAI throughput on every page. Per-recipe pre-split + recursive halving + adaptive shrink-on-miss now live in the gateway, so the outer paginator goes back to its original purpose: progress-callback granularity, not batch protection. +- `src/core/ai/dims.ts` (v0.33.1.1, PR #962 + #866) — 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)`, and `isValidVoyageOutputDim(dims)`. **Voyage path uses the SDK-supported `dimensions` field** (`{ openaiCompatible: { dimensions: N } }`), NOT Voyage's `output_dimension` wire-key — the existing `voyageCompatFetch` shim in `gateway.ts:541` translates `dimensions → output_dimension` before the HTTP body is built. The reverse (sending `output_dimension` from here) was the v0.33.1.0 bug class: the AI SDK's openai-compatible adapter doesn't recognize the wire-key so it was silently dropped, Voyage returned its default 1024-dim, and the gateway dimension check threw on every embed call. 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 — fail-loud instead of opaque Voyage HTTP 400. Most common trigger: `embedding_model: voyage:voyage-4-large` configured without `embedding_dimensions` (falls back to `DEFAULT_EMBEDDING_DIMENSIONS=1536`, an OpenAI default not a Voyage one). Eva (@100yenadmin) shipped the wire-key fix in #866; Codex P3 follow-up landed the validator + valid-dims allowlist in #962. - `src/core/ai/types.ts` — provider/recipe types. **v0.28.7 (#680):** `EmbeddingTouchpoint` extended with optional `chars_per_token` (default 4 chars/token, 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) that overshoot tiktoken. The pre-split budget is `max_batch_tokens × safety_factor / chars_per_token`. **v0.28.11 (#719):** `EmbeddingTouchpoint.multimodal_models?: string[]` model-level allow-list for recipes that mix 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` — brains using OpenAI for text can use Voyage for images without flipping the primary embedding pipeline. -- `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.28.7 (#680):** module-scoped `_embedTransport` defaulting to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive the public `embed()` function with a stubbed transport instead of probing private helpers. `splitByTokenBudget` and `isTokenLimitError` are now exported `@internal` — pure functions reused directly 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 toward the ceiling after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction time and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path recipe). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. ASCII flow diagram embedded in the `embed()` JSDoc covers the routing decision, recursion + halving, and shrinkState lifecycle. **v0.28.11 (#719):** `embedMultimodal()` reads `cfg.embedding_multimodal_model` first (falls back to `cfg.embedding_model` for single-model setups). After the existing recipe-level `supports_multimodal` fast-fail, validates the resolved model against `touchpoint.multimodal_models` when declared — closes the Voyage-text-only-model-into-multimodal-endpoint footgun before any HTTP call (Codex F1 from PR review). New `getMultimodalModel()` accessor mirrors `getEmbeddingModel` / `getChatModel` so doctor and integration tests can read the gateway state. -- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage. **v0.28.11 (#719):** declares `multimodal_models: ['voyage-multimodal-3']` so the gateway rejects text-only Voyage models pointed at the multimodal endpoint with a clear `AIConfigError` instead of waiting for Voyage's HTTP 400. +- `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.28.7 (#680):** module-scoped `_embedTransport` defaulting to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive the public `embed()` function with a stubbed transport instead of probing private helpers. `splitByTokenBudget` and `isTokenLimitError` are now exported `@internal` — pure functions reused directly 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 toward the ceiling after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction time and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path recipe). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. ASCII flow diagram embedded in the `embed()` JSDoc covers the routing decision, recursion + halving, and shrinkState lifecycle. **v0.28.11 (#719):** `embedMultimodal()` reads `cfg.embedding_multimodal_model` first (falls back to `cfg.embedding_model` for single-model setups). After the existing recipe-level `supports_multimodal` fast-fail, validates the resolved model against `touchpoint.multimodal_models` when declared — closes the Voyage-text-only-model-into-multimodal-endpoint footgun before any HTTP call (Codex F1 from PR review). New `getMultimodalModel()` accessor mirrors `getEmbeddingModel` / `getChatModel` so doctor and integration tests can read the gateway state. **v0.33.1.1 (#962, Codex P3 follow-up):** new exported `VoyageResponseTooLargeError` tagged class at the top of the file. `voyageCompatFetch`'s two OOM-defense caps (Layer 1 Content-Length check at `:595`, Layer 2 per-embedding base64 cap at `:619`) now throw `VoyageResponseTooLargeError` instead of a generic `Error`. The inbound response-rewriter's surrounding try/catch (which intentionally swallows parse failures so misshaped Voyage responses fall through to the SDK's JSON parser) checks `instanceof VoyageResponseTooLargeError` and rethrows. Pre-fix, the Layer 2 throw was silently swallowed and the oversized response returned to the AI SDK anyway — Layer 2 was theatrical. Source-shape regression assertion in `test/voyage-response-cap.test.ts` pins the `instanceof ⇒ throw err` line. +- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage. **v0.28.11 (#719):** declares `multimodal_models: ['voyage-multimodal-3']` so the gateway rejects text-only Voyage models pointed at the multimodal endpoint with a clear `AIConfigError` instead of waiting for Voyage's HTTP 400. **v0.33.1.1 (#962, fixup):** recipe docstring at `:7-16` tightened to name the seven hosted flexible-dim models that accept `output_dimension` explicitly (`voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-3`) and call out that `voyage-4-nano` is the open-weight variant listed separately by Voyage as fixed 1024-dim — does NOT accept the parameter. The "all v4 variants are flexible" misread is what caused the original PR to include nano in `VOYAGE_OUTPUT_DIMENSION_MODELS`; the negative regression assertion in `test/ai/gateway.test.ts` (`dimsProviderOptions` returns `undefined` for `voyage-4-nano`) pins the contract. - `src/core/ai/recipes/anthropic.ts` — Anthropic recipe (chat + expansion touchpoints). **v0.31.12:** chat and expansion `models:` lists drop the v0.31.6 phantom `claude-sonnet-4-6-20250929` date suffix — canonical id is `claude-sonnet-4-6`. The wrong-direction alias `claude-sonnet-4-6 → claude-sonnet-4-6-20250929` is removed; a reverse alias `claude-sonnet-4-6-20250929 → claude-sonnet-4-6` keeps stale user configs working (rescues `facts.extraction_model` and `models.dream.synthesize` set by v0.31.6 installs). Recipe-shape regression pinned by `test/anthropic-model-ids.test.ts` (6 cases, verbatim cherry-pick of PR #830 plus the reverse-alias rescue case). - `src/core/anthropic-pricing.ts` — Single source of truth for Anthropic model pricing (per-MTok input/output). **v0.31.12:** Opus 4.7 corrected from `$15/$75` to `$5/$25` (the old number was from Opus 4 generation, never refreshed when 4.7 shipped); Opus 4.6 also corrected. Consumed by `src/core/budget-meter.ts` and `src/core/cross-modal-eval/runner.ts` — the cross-modal estimator now reads `ANTHROPIC_PRICING` for Anthropic models instead of duplicating the table, killing the v0.31.6 drift bug class. - `src/core/model-config.ts` — Model-string resolution (the seam every internal LLM call walks through). **v0.31.12:** four-tier system (`ModelTier = 'utility' | 'reasoning' | 'deep' | 'subagent'`) with `TIER_DEFAULTS` (utility→haiku-4-5, reasoning→sonnet-4-6, deep→opus-4-7, subagent→sonnet-4-6) and `tier?: ModelTier` on `ResolveModelOpts`. Resolution chain is now 8 steps: cliFlag → deprecated key → config key → `models.default` → `models.tier.` → env var → `TIER_DEFAULTS[tier]` → caller fallback. Two new exports — `isAnthropicProvider(modelString)` checks `provider:model` prefix OR `claude-` bare-id pattern, and `enforceSubagentAnthropic()` is the layer-2 runtime guard: when `tier === 'subagent'` resolves to a non-Anthropic provider, it emits a once-per-`(source, model)` stderr warn AND falls back to `TIER_DEFAULTS.subagent` instead of letting the Anthropic Messages API tool-loop attempt to run on OpenAI/Gemini. `_resetDeprecationWarningsForTest()` now also clears `_subagentTierWarningsEmitted` so tests re-emit. - `src/core/ai/model-resolver.ts` — Recipe-touchpoint validator. **v0.31.12:** `assertTouchpoint(recipe, touchpoint, modelId, extendedModels?)` gains an optional 4th `extendedModels: ReadonlySet` argument. When the modelId is in that set, the native-recipe allowlist throw is bypassed — the user explicitly opted into this model via config so we let provider rejection surface as `model_not_found` at HTTP call time (and `gbrain models doctor` catches it earlier). Default code paths with hardcoded model strings MUST NOT pass `extendedModels` — typos in source code still fail fast. Replaces the earlier plan to soften the validator wholesale (Codex F4/F5 in plan review flagged that as too broad — it would have removed the fail-fast contract for chat + expand + embed all three). - `src/core/ai/gateway.ts` extension (v0.31.12) — new module-scoped `_extendedModels: Map>` registry feeds `assertTouchpoint`'s 4th-arg path. New `reconfigureGatewayWithEngine(engine)` async function is called from `cli.ts` after `engine.connect()` (and before every command except `CLI_ONLY` no-DB commands) — re-resolves expansion + chat defaults through `resolveModel()` so `models.tier.*` and `models.default` overrides apply to expansion + chat both. `DEFAULT_CHAT_MODEL` corrected to `anthropic:claude-sonnet-4-6` (was the v0.31.6 phantom `-20250929`). New `__setChatTransportForTests` seam mirrors `__setEmbedTransportForTests` so tests drive `chat()` with a stubbed transport. - `src/core/minions/queue.ts` extension (v0.31.12) — `MinionQueue.add()` now rejects `subagent` jobs whose `data.model` resolves through `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 (Codex F1+F2 in plan review). Layers 2 + 3 live in `src/core/model-config.ts` (`enforceSubagentAnthropic` runtime fallback) and `src/commands/doctor.ts` (`subagent_provider` check). Pinned by 3 cases in `test/agent-cli.test.ts`. -- `src/commands/models.ts` (v0.31.12) — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain to attribute properly), every per-task override (11 `PER_TASK_KEYS` entries — `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 (defaults + user overrides), and a source-of-truth column showing `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}` — the structural fix for the v0.31.6 silent-no-op bug class. Wired into `cli.ts` dispatch table + `CLI_ONLY` set. +- `src/commands/models.ts` (v0.31.12) — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain to attribute properly), every per-task override (11 `PER_TASK_KEYS` entries — `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 (defaults + user overrides), and a source-of-truth column showing `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}` — the structural fix for the v0.31.6 silent-no-op bug class. Wired into `cli.ts` dispatch table + `CLI_ONLY` set. **v0.33.1.1 (#962, Codex P3 follow-up):** doctor gains a zero-token `embedding_config` probe that runs FIRST, before any chat/expansion probes spend money. `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` from the gateway, parses the model id, and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. New `ProbeStatus` variant `'config'` and optional `fix?: string` field on `ProbeResult` — surfaced in both human output (paste-ready `gbrain config set ...` line under the bad probe) and JSON output. New touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'` in the probe-row taxonomy. Closes the Voyage flexible-dim bug class at config time, not first-embed. - `src/commands/doctor.ts` extension (v0.31.12) — new `subagent_provider` check (layer 3 of 3 — Codex F13). Warns when `models.tier.subagent` is explicitly set to a non-Anthropic provider (fail-loud since the user clearly meant it — message names the bad value and prints the paste-ready fix command `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 status when subagent tier resolves to Anthropic. Tests cover all three paths in `test/doctor.test.ts`. - `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`. - `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic. **v0.31.7:** read-path / write-path split. `autoDetectSkillsDir` (shared, read+write-safe) gains tier-0 `$GBRAIN_SKILLS_DIR` explicit operator override (Docker mounts, CI, monorepo subdirs) ahead of the existing 4-tier chain. New `autoDetectSkillsDirReadOnly` wraps it with a tier-5 install-path fallback that walks up from `fileURLToPath(import.meta.url)` and gates on `isGbrainRepoRoot` so unrelated repos can't false-positive. Read-path callers (`doctor`, `check-resolvable`, `routing-eval`) use the read-only variant; write-path callers (`skillpack install`, `skillify scaffold`, `post-install-advisory`) deliberately stay on the shared function so `gbrain skillpack install` from `~` cannot silently retarget the bundled gbrain repo's `skills/` instead of the user's actual workspace. Two new `SkillsDirSource` variants: `'env_explicit'`, `'install_path'`. New `AUTO_DETECT_HINT_READ_ONLY` documents the extra tier. The D6 `--fix` safety gate in `doctor.ts` + `check-resolvable.ts` refuses auto-repair when `detected.source === 'install_path'` so `gbrain doctor --fix` from `~` cannot silently rewrite the bundled install tree. @@ -450,6 +451,12 @@ Key commands added in v0.32.7 (CJK fix wave): - `gbrain search ""` on PGLite brains now uses an `ILIKE`-based fallback with bigram-frequency-count ranking when the query contains Han / Hiragana / Katakana / Hangul Syllables. ASCII queries continue through `websearch_to_tsquery('english')` unchanged. Postgres-side CJK FTS still requires an extension (pgroonga / zhparser) — see v0.33+ TODO. - `gbrain upgrade` post-upgrade flow now prints a cost estimate before re-embedding: `[chunker-bump] Will re-embed ~N markdown pages via , est. ~$X.XX, ~Ymin. Press Ctrl-C within 10s to abort.` Sourced from real SQL counts + char totals; TTY-only wait (non-TTY auto-proceeds for CI / cron). Env overrides: `GBRAIN_NO_REEMBED=1` bails out entirely with a doctor-warning marker; `GBRAIN_REEMBED_GRACE_SECONDS=0` skips the wait. +Key commands added in v0.33.1.1 (Voyage 2048-dim correctness wave): +- `gbrain models doctor` learns a new zero-token `embedding_config` probe that runs FIRST, before any chat/expansion probes spend money. Catches Voyage flexible-dim misconfigs at config time, not first-embed: `embedding_model: voyage:voyage-4-large` with `embedding_dimensions` outside `{256, 512, 1024, 2048}` (most commonly: `embedding_dimensions` left unset, falling back to the OpenAI default 1536 which Voyage rejects with an opaque HTTP 400). Surfaces a paste-ready `gbrain config set embedding_dimensions <256|512|1024|2048>` fix in both human and JSON output. New probe status `'config'` joins `{ok, model_not_found, auth, rate_limit, network, unknown}`; new touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'`. +- Voyage 2048-dim brains now actually embed at 2048 dims. `embedding_model: voyage:voyage-4-large` + `embedding_dimensions: 2048` routes through the SDK-supported `dimensions` field, which `voyageCompatFetch` translates to Voyage's `output_dimension` on the wire. Same fix covers `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-4`, `voyage-4-lite`, `voyage-code-3`. `voyage-4-nano` (open-weight, fixed 1024-dim) intentionally NOT in the flexible-dim allowlist — sending `output_dimension` to nano's endpoint produces an error. +- Runtime validator: `dimsProviderOptions()` throws `AIConfigError` at the embed boundary with a paste-ready fix hint when a Voyage flexible-dim model is configured with an invalid dim — fail-loud even if you skipped `gbrain models doctor`. +- `VoyageResponseTooLargeError` (new tagged class exported from `src/core/ai/gateway.ts`): the 256 MB per-response cap inside `voyageCompatFetch` was previously throwing a generic `Error` that the surrounding parse-error try/catch silently swallowed, returning the oversized response to the AI SDK anyway. Now thrown at both cap sites (Content-Length Layer 1, per-embedding base64 Layer 2) and rethrown from the catch via `instanceof` check — the cap is now actually effective. + Key commands added in v0.31.12 (model tier system + routing CLI): - `gbrain models [--json]` — read-only routing dashboard. Prints the four tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (after re-walking `models.default` → `models.tier.` → env → `TIER_DEFAULTS`), every per-task override (`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 (defaults + user overrides), and a source-of-truth column (`default` / `config: ` / `env: `). - `gbrain models doctor [--skip=] [--json]` — 1-token reachability probe against each configured chat + expansion model. Classifies failures into `{model_not_found, auth, rate_limit, network, unknown}`. The structural fix for the bug class that motivated v0.31.12 (v0.31.6's `claude-sonnet-4-6-20250929` chat default 404'd silently on every install). @@ -2623,8 +2630,10 @@ ADMIN gbrain config set models.default opus gbrain config set models.tier.deep opus gbrain models doctor 1-token reachability probe for each configured - chat/expansion model. Catches `model_not_found` - before the next agent run silently degrades. + chat/expansion model + a zero-token embedding_config + probe (catches Voyage flexible-dim misconfigs before + first embed). Catches `model_not_found` before the + next agent run silently degrades. [--skip=] [--json] gbrain serve MCP server (stdio) gbrain serve --http [--port 3131] HTTP MCP server with OAuth 2.1 + admin dashboard diff --git a/package.json b/package.json index 540d2bef7..2811deb6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.33.1.0", + "version": "0.33.1.1", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/src/commands/models.ts b/src/commands/models.ts index 5fbb4d2b7..1fee07ac9 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -156,14 +156,15 @@ function formatText(report: ModelsReport): string { // ── Doctor (probe) mode ──────────────────────────────────────────── -type ProbeStatus = 'ok' | 'model_not_found' | 'auth' | 'rate_limit' | 'network' | 'unknown'; +type ProbeStatus = 'ok' | 'model_not_found' | 'auth' | 'rate_limit' | 'network' | 'config' | 'unknown'; interface ProbeResult { model: string; - touchpoint: 'chat' | 'expansion'; + touchpoint: 'chat' | 'expansion' | 'embedding_config'; status: ProbeStatus; message: string; elapsed_ms: number; + fix?: string; } function classifyError(err: unknown): { status: ProbeStatus; message: string } { @@ -184,6 +185,67 @@ function classifyError(err: unknown): { status: ProbeStatus; message: string } { return { status: 'unknown', message: msg }; } +/** + * Validate the configured embedding model + dims combo without spending tokens. + * Catches the bug class where a brain configured for Voyage with a missing or + * out-of-allowlist `embedding_dimensions` value would fail at first-embed with + * an opaque HTTP 400. Runs purely against local config + recipe metadata — + * zero network I/O. + */ +async function probeEmbeddingConfig(): Promise { + const start = Date.now(); + const { getEmbeddingModel, getEmbeddingDimensions } = await import('../core/ai/gateway.ts'); + const { parseModelId } = await import('../core/ai/model-resolver.ts'); + const { supportsVoyageOutputDimension, isValidVoyageOutputDim, VOYAGE_VALID_OUTPUT_DIMS } = + await import('../core/ai/dims.ts'); + + const modelStr = getEmbeddingModel(); + const dims = getEmbeddingDimensions(); + + try { + const { providerId, modelId } = parseModelId(modelStr); + + // Voyage flexible-dim check — the bug class that motivated this probe. + if (providerId === 'voyage' && supportsVoyageOutputDimension(modelId)) { + if (!isValidVoyageOutputDim(dims)) { + return { + model: modelStr, + touchpoint: 'embedding_config', + status: 'config', + message: + `embedding_dimensions=${dims} is not a valid Voyage output_dimension ` + + `for "${modelId}" (allowed: ${VOYAGE_VALID_OUTPUT_DIMS.join('/')}).`, + fix: + `gbrain config set embedding_dimensions <${VOYAGE_VALID_OUTPUT_DIMS.join('|')}>, ` + + `or switch to a fixed-dim Voyage model (e.g. voyage-3, voyage-3-lite).`, + elapsed_ms: Date.now() - start, + }; + } + } + + return { + model: modelStr, + touchpoint: 'embedding_config', + status: 'ok', + message: `embedding_dimensions=${dims} ok for ${modelStr}`, + elapsed_ms: Date.now() - start, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const fix = err && typeof err === 'object' && 'fix' in err + ? (err as { fix?: string }).fix + : undefined; + return { + model: modelStr, + touchpoint: 'embedding_config', + status: 'config', + message: msg, + fix, + elapsed_ms: Date.now() - start, + }; + } +} + async function probeModel(modelStr: string, touchpoint: 'chat' | 'expansion'): Promise { const start = Date.now(); try { @@ -260,6 +322,12 @@ Tiers: utility (haiku-class) | reasoning (sonnet) | deep (opus) | subagent (Anth const expansionModel = getExpansionModel(); const results: ProbeResult[] = []; + + // Config-only probe runs first: zero tokens, catches the bug class where a + // brain misconfigured for Voyage with the wrong embedding_dimensions would + // 400 on first embed. Fast feedback before we spend a single token. + results.push(await probeEmbeddingConfig()); + for (const [modelStr, touchpoint] of [[chatModel, 'chat'], [expansionModel, 'expansion']] as const) { if (shouldSkipProvider(modelStr, skip)) { if (!json) process.stderr.write(`[skip] ${touchpoint}: ${modelStr} (provider in --skip)\n`); @@ -284,9 +352,10 @@ Tiers: utility (haiku-class) | reasoning (sonnet) | deep (opus) | subagent (Anth process.stdout.write('Model reachability probe:\n'); for (const r of results) { const icon = r.status === 'ok' ? '✔' : '✘'; - process.stdout.write(` ${icon} ${r.touchpoint.padEnd(10)} ${r.model.padEnd(50)} ${r.status} (${r.elapsed_ms}ms)\n`); + process.stdout.write(` ${icon} ${r.touchpoint.padEnd(17)} ${r.model.padEnd(50)} ${r.status} (${r.elapsed_ms}ms)\n`); if (r.status !== 'ok') { process.stdout.write(` ${r.message}\n`); + if (r.fix) process.stdout.write(` fix: ${r.fix}\n`); } } process.stdout.write(`\nSummary: ${report.summary.ok}/${report.summary.total} reachable.\n`); diff --git a/src/core/ai/dims.ts b/src/core/ai/dims.ts index 2b3bb030a..6b92cd1d5 100644 --- a/src/core/ai/dims.ts +++ b/src/core/ai/dims.ts @@ -10,7 +10,16 @@ */ import type { Implementation } from './types.ts'; +import { AIConfigError } from './errors.ts'; +// Voyage hosted models that accept `output_dimension` (values: +// 256 / 512 / 1024 / 2048). Per Voyage's API parameter docs as of 2026-05. +// voyage-4-nano is intentionally NOT in this set: it's the open-weight +// variant listed separately by Voyage as fixed 1024-dim. Adding it here +// would tell the SDK to send `dimensions: N`, which voyageCompatFetch then +// rewrites to `output_dimension: N` — and Voyage's hosted nano endpoint +// rejects the parameter. The negative regression assertion in +// test/ai/gateway.test.ts pins this contract. const VOYAGE_OUTPUT_DIMENSION_MODELS = new Set([ 'voyage-4-large', 'voyage-4', @@ -21,13 +30,32 @@ const VOYAGE_OUTPUT_DIMENSION_MODELS = new Set([ 'voyage-code-3', ]); +// Voyage's flexible-dim endpoint only accepts these four discrete values. +// Per Voyage's API docs (2026-05). Out-of-range requests are rejected with +// HTTP 400 by the upstream — catching it locally produces a clearer error +// with the valid-values hint. The most common way to hit this in +// production: `embedding_model: voyage:voyage-4-large` configured without +// `embedding_dimensions`, where the gateway falls back to +// DEFAULT_EMBEDDING_DIMENSIONS=1536 (an OpenAI default, not a Voyage one). +export const VOYAGE_VALID_OUTPUT_DIMS = [256, 512, 1024, 2048] as const; + +export function supportsVoyageOutputDimension(modelId: string): boolean { + return VOYAGE_OUTPUT_DIMENSION_MODELS.has(modelId); +} + +export function isValidVoyageOutputDim(dims: number): boolean { + return (VOYAGE_VALID_OUTPUT_DIMS as readonly number[]).includes(dims); +} + /** * Build the providerOptions blob for embedMany() that pins output dimensions. * * Matryoshka providers (OpenAI text-embedding-3, Gemini embedding-001) can be * asked to return reduced-dim vectors. Anthropic does not take a dimension - * parameter. Most openai-compatible providers do not either, but Voyage's - * OpenAI-compatible embeddings endpoint accepts `output_dimension`. + * parameter. Most openai-compatible providers do not either. Voyage's + * endpoint accepts `output_dimension`, but the AI SDK openai-compatible + * adapter only forwards `dimensions`; gateway.ts translates that field to + * Voyage's wire name in voyageCompatFetch. */ export function dimsProviderOptions( implementation: Implementation, @@ -53,10 +81,27 @@ export function dimsProviderOptions( return undefined; case 'openai-compatible': // Most openai-compatible providers (Ollama, LM Studio, vLLM, LiteLLM) - // do not expose a standard dimensions knob. Voyage's compat endpoint is - // the exception: it accepts output_dimension and defaults to 1024 dims. - if (VOYAGE_OUTPUT_DIMENSION_MODELS.has(modelId)) { - return { openaiCompatible: { output_dimension: dims } }; + // do not expose a standard dimensions knob. Voyage is the exception, + // but it needs the SDK-supported field here so voyageCompatFetch can + // translate it to `output_dimension` before the HTTP request is sent. + if (supportsVoyageOutputDimension(modelId)) { + // Fail-loud at the embed boundary if the user configured a dim + // Voyage doesn't accept. The most common path here: a brain with + // `embedding_model: voyage:voyage-4-large` but no explicit + // `embedding_dimensions`, where the gateway falls back to the + // module default (1536). Without this guard, Voyage's HTTP 400 is + // the only signal — usually mis-attributed as a transient network + // error. + if (!isValidVoyageOutputDim(dims)) { + throw new AIConfigError( + `Voyage model "${modelId}" supports output_dimension only in ` + + `{${VOYAGE_VALID_OUTPUT_DIMS.join(', ')}}, got ${dims}.`, + `Set \`embedding_dimensions\` to one of ` + + `${VOYAGE_VALID_OUTPUT_DIMS.join('/')} in your gbrain config, or ` + + `switch to a fixed-dim Voyage model (e.g. voyage-3, voyage-3-lite).`, + ); + } + return { openaiCompatible: { dimensions: dims } }; } // OpenAI text-embedding-3 family on the openai-compatible adapter // (Azure OpenAI hosts these via its OpenAI-compatible /embeddings diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 619932c7e..f6deb2e4c 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -139,6 +139,24 @@ const DEFAULT_SAFETY_FACTOR = 0.8; */ const MAX_VOYAGE_RESPONSE_BYTES = 256 * 1024 * 1024; +/** + * Tagged error class for the OOM-defense caps in voyageCompatFetch. The + * inbound response-rewriter at the bottom of voyageCompatFetch is wrapped + * in a try/catch that silently falls back to the original response on parse + * failure — that's correct for "Voyage returned something I can't reshape, + * let the SDK handle it" but WRONG for OOM caps where letting the response + * through could blow up the worker. The catch checks `instanceof + * VoyageResponseTooLargeError` and rethrows in that case. + * + * Exported for tests; not part of the public surface. + */ +export class VoyageResponseTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = 'VoyageResponseTooLargeError'; + } +} + // ---- Unified auth resolution (D12=A) ---- // // Pre-v0.32, openai-compatible auth was duplicated across instantiateEmbedding, @@ -592,7 +610,7 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) if (contentLengthHeader) { const len = parseInt(contentLengthHeader, 10); if (Number.isFinite(len) && len > MAX_VOYAGE_RESPONSE_BYTES) { - throw new Error( + throw new VoyageResponseTooLargeError( `Voyage response Content-Length=${len} exceeds ${MAX_VOYAGE_RESPONSE_BYTES} bytes — ` + `likely compromised endpoint or misconfiguration`, ); @@ -617,7 +635,7 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) // base64 → bytes ratio). const estDecoded = Math.ceil(item.embedding.length * 0.75); if (estDecoded > MAX_VOYAGE_RESPONSE_BYTES) { - throw new Error( + throw new VoyageResponseTooLargeError( `Voyage embedding base64 exceeds ${MAX_VOYAGE_RESPONSE_BYTES} bytes ` + `(estimated ${estDecoded} bytes from ${item.embedding.length} base64 chars)`, ); @@ -646,7 +664,14 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) statusText: resp.statusText, headers: resp.headers, }); - } catch { + } catch (err) { + // OOM-cap throws MUST propagate. The catch is here for "Voyage returned + // JSON I can't reshape" (parse error, unexpected schema) — falling back + // to the original response is correct in that case. Letting the + // too-large response through here would defeat the entire purpose of + // Layer 2 (the per-embedding cap that fires when Content-Length wasn't + // available to Layer 1). + if (err instanceof VoyageResponseTooLargeError) throw err; // If parsing/transformation fails, fall back to the original response. return resp; } diff --git a/src/core/ai/recipes/voyage.ts b/src/core/ai/recipes/voyage.ts index 202ea783c..516344777 100644 --- a/src/core/ai/recipes/voyage.ts +++ b/src/core/ai/recipes/voyage.ts @@ -4,9 +4,15 @@ import type { Recipe } from '../types.ts'; * Voyage AI exposes an OpenAI-compatible /embeddings endpoint. * Base URL: https://api.voyageai.com/v1 * - * Voyage 4 family (Jan 2026): shared embedding space across all v4 variants, - * flexible dims (256/512/1024/2048), 32K context, MoE architecture (large). - * You can index with voyage-4-large and query with voyage-4-lite — no reindex. + * Hosted v4 trio (voyage-4-large / voyage-4 / voyage-4-lite, Jan 2026): + * shared embedding space, flexible dims (256/512/1024/2048), 32K context, + * MoE architecture (large). You can index with voyage-4-large and query with + * voyage-4-lite — no reindex. + * + * voyage-4-nano is a DIFFERENT thing: an open-weight variant Voyage lists + * separately. It does NOT accept the `output_dimension` parameter on + * Voyage's hosted API — fixed 1024-dim. See VOYAGE_OUTPUT_DIMENSION_MODELS + * in src/core/ai/dims.ts; nano is intentionally excluded. * * voyage-multimodal-3 (v0.27.1): text + image inputs in the same 1024-dim * space. supports_multimodal flips routing to embedMultimodal() in the diff --git a/test/ai/gateway.test.ts b/test/ai/gateway.test.ts index e9f781047..c99b2fcb0 100644 --- a/test/ai/gateway.test.ts +++ b/test/ai/gateway.test.ts @@ -3,12 +3,18 @@ import { configureGateway, resetGateway, isAvailable, + embed, getEmbeddingModel, getEmbeddingDimensions, getExpansionModel, + VoyageResponseTooLargeError, } from '../../src/core/ai/gateway.ts'; import { parseModelId, resolveRecipe } from '../../src/core/ai/model-resolver.ts'; -import { dimsProviderOptions } from '../../src/core/ai/dims.ts'; +import { + dimsProviderOptions, + VOYAGE_VALID_OUTPUT_DIMS, + isValidVoyageOutputDim, +} from '../../src/core/ai/dims.ts'; import { AIConfigError } from '../../src/core/ai/errors.ts'; describe('gateway configuration', () => { @@ -156,13 +162,219 @@ describe('dims.dimsProviderOptions', () => { expect(opts).toBeUndefined(); }); - test('Voyage openai-compatible returns output_dimension', () => { - const opts = dimsProviderOptions('openai-compatible', 'voyage-4-large', 2048); - expect(opts).toEqual({ openaiCompatible: { output_dimension: 2048 } }); + test('Voyage flexible-dim models return dimensions for the SDK shim', () => { + const opts = dimsProviderOptions('openai-compatible', 'voyage-3-large', 1024); + expect(opts).toEqual({ openaiCompatible: { dimensions: 1024 } }); + const v4Opts = dimsProviderOptions('openai-compatible', 'voyage-4-large', 2048); + expect(v4Opts).toEqual({ openaiCompatible: { dimensions: 2048 } }); }); test('Voyage model without flexible dimensions returns undefined', () => { const opts = dimsProviderOptions('openai-compatible', 'voyage-3-lite', 1024); expect(opts).toBeUndefined(); }); + + // Negative regression pin: voyage-4-nano is an open-weight variant that + // Voyage's hosted API rejects `output_dimension` on (fixed 1024-dim). + // Don't re-add it to VOYAGE_OUTPUT_DIMENSION_MODELS without cross-checking + // Voyage's docs. See src/core/ai/dims.ts for the rationale. + test('voyage-4-nano returns undefined (open-weight, fixed-dim)', () => { + const opts = dimsProviderOptions('openai-compatible', 'voyage-4-nano', 512); + expect(opts).toBeUndefined(); + }); +}); + +describe('Voyage openai-compatible request shim', () => { + beforeEach(() => resetGateway()); + + test('sends output_dimension on the actual Voyage embedding request body', async () => { + const originalFetch = globalThis.fetch; + let requestBody: Record | undefined; + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body ?? '{}')); + return new Response(JSON.stringify({ + object: 'list', + data: [ + { + object: 'embedding', + index: 0, + embedding: new Array(2048).fill(0.01), + }, + ], + model: 'voyage-4-large', + usage: { total_tokens: 3 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + try { + configureGateway({ + embedding_model: 'voyage:voyage-4-large', + embedding_dimensions: 2048, + env: { VOYAGE_API_KEY: 'voyage-fake' }, + }); + + const vectors = await embed(['dimension probe']); + + expect(vectors[0].length).toBe(2048); + expect(requestBody?.output_dimension).toBe(2048); + expect(requestBody?.encoding_format).toBe('base64'); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Voyage OOM-cap rethrow regression (Codex P3 follow-up after PR #962). +// Pins the contract that VoyageResponseTooLargeError thrown from the +// inbound rewriter is NOT swallowed by the surrounding try/catch. +// ───────────────────────────────────────────────────────────────────── +describe('Voyage OOM-cap: too-large response throws (Codex P3 follow-up)', () => { + beforeEach(() => resetGateway()); + + test('Layer 1 — Content-Length above cap propagates as VoyageResponseTooLargeError', async () => { + const originalFetch = globalThis.fetch; + // 257 MB > 256 MB cap. + const oversized = String(257 * 1024 * 1024); + globalThis.fetch = (async () => { + return new Response('{"data": []}', { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': oversized, + }, + }); + }) as unknown as typeof fetch; + try { + configureGateway({ + embedding_model: 'voyage:voyage-4-large', + embedding_dimensions: 1024, + env: { VOYAGE_API_KEY: 'voyage-fake' }, + }); + let caught: unknown; + try { + await embed(['probe']); + } catch (e) { + caught = e; + } + // The OOM throw propagates. Provider plumbing may wrap it, but the + // VoyageResponseTooLargeError class name + characteristic message + // must survive. + const msg = caught instanceof Error ? caught.message : String(caught); + expect(msg).toContain('Content-Length='); + expect(msg).toContain('exceeds'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('Layer 2 — oversized base64 embedding string propagates (not swallowed)', async () => { + const originalFetch = globalThis.fetch; + // Build a JSON response with an `embedding` base64 string that decodes + // to > 256 MB. base64 ratio is ~0.75; 360 MB of base64 chars ≈ 270 MB + // decoded. + const oversizedBase64 = 'A'.repeat(360 * 1024 * 1024); + const respBody = `{"object":"list","data":[{"object":"embedding","index":0,"embedding":"${oversizedBase64}"}],"model":"voyage-4-large","usage":{"total_tokens":1}}`; + globalThis.fetch = (async () => { + // No Content-Length header → Layer 1 skipped, Layer 2 must fire. + return new Response(respBody, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch; + try { + configureGateway({ + embedding_model: 'voyage:voyage-4-large', + embedding_dimensions: 1024, + env: { VOYAGE_API_KEY: 'voyage-fake' }, + }); + let caught: unknown; + try { + await embed(['probe']); + } catch (e) { + caught = e; + } + const msg = caught instanceof Error ? caught.message : String(caught); + // The Layer 2 throw fired and was not swallowed by the inbound + // try/catch (pre-fix bug: bare `catch {}` returned the original + // response and let the AI SDK OOM trying to parse it). + expect(msg).toContain('Voyage embedding base64 exceeds'); + } finally { + globalThis.fetch = originalFetch; + } + }, 15000); + + test('VoyageResponseTooLargeError is exported as a tagged class', () => { + expect(VoyageResponseTooLargeError).toBeDefined(); + const err = new VoyageResponseTooLargeError('test'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(VoyageResponseTooLargeError); + expect(err.name).toBe('VoyageResponseTooLargeError'); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Voyage flexible-dim runtime validation (Codex P3 follow-up after PR #962). +// The bug class: brain configured for Voyage flexible-dim model without +// `embedding_dimensions` → gateway falls back to DEFAULT 1536 → Voyage +// HTTP 400. Catch it at the embed-call boundary with a clear AIConfigError. +// ───────────────────────────────────────────────────────────────────── +describe('Voyage flexible-dim runtime validation', () => { + test('rejects 1536 (the default that bites Voyage-first users) with AIConfigError', () => { + expect(() => dimsProviderOptions('openai-compatible', 'voyage-4-large', 1536)) + .toThrow(AIConfigError); + expect(() => dimsProviderOptions('openai-compatible', 'voyage-4-large', 1536)) + .toThrow(/embedding_dimensions|256.*512.*1024.*2048/); + }); + + test('rejects 3072 with AIConfigError', () => { + expect(() => dimsProviderOptions('openai-compatible', 'voyage-3-large', 3072)) + .toThrow(AIConfigError); + }); + + test('accepts every Voyage-allowed flexible dim', () => { + for (const dim of VOYAGE_VALID_OUTPUT_DIMS) { + const opts = dimsProviderOptions('openai-compatible', 'voyage-4-large', dim); + expect(opts).toEqual({ openaiCompatible: { dimensions: dim } }); + } + }); + + test('VOYAGE_VALID_OUTPUT_DIMS pins exactly the four Voyage values', () => { + expect([...VOYAGE_VALID_OUTPUT_DIMS]).toEqual([256, 512, 1024, 2048]); + }); + + test('isValidVoyageOutputDim returns true only for the four valid sizes', () => { + expect(isValidVoyageOutputDim(256)).toBe(true); + expect(isValidVoyageOutputDim(512)).toBe(true); + expect(isValidVoyageOutputDim(1024)).toBe(true); + expect(isValidVoyageOutputDim(2048)).toBe(true); + expect(isValidVoyageOutputDim(1536)).toBe(false); + expect(isValidVoyageOutputDim(3072)).toBe(false); + expect(isValidVoyageOutputDim(0)).toBe(false); + expect(isValidVoyageOutputDim(-1)).toBe(false); + }); + + test('voyage-3-lite (non-flexible-dim) bypasses the validator — still returns undefined', () => { + // Sanity: the validator only fires inside the flexible-dim branch, so + // a fixed-dim Voyage model with any dim value goes straight through to + // the `undefined` return path (no error, no providerOptions). + expect(dimsProviderOptions('openai-compatible', 'voyage-3-lite', 1536)).toBeUndefined(); + expect(dimsProviderOptions('openai-compatible', 'voyage-4-nano', 1536)).toBeUndefined(); + }); + + test('AIConfigError fix hint names the canonical recovery commands', () => { + let caught: AIConfigError | undefined; + try { + dimsProviderOptions('openai-compatible', 'voyage-4-large', 1536); + } catch (e) { + caught = e as AIConfigError; + } + expect(caught).toBeInstanceOf(AIConfigError); + expect(caught?.fix).toContain('embedding_dimensions'); + expect(caught?.fix).toContain('256'); + expect(caught?.fix).toContain('2048'); + }); }); diff --git a/test/voyage-response-cap.test.ts b/test/voyage-response-cap.test.ts index bb0b90606..c873ed223 100644 --- a/test/voyage-response-cap.test.ts +++ b/test/voyage-response-cap.test.ts @@ -59,10 +59,12 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => { test('Layer 1 throws on Content-Length over the cap (not silent return)', async () => { const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text(); - // The cap check must use `throw new Error(...)` so the embed flow's - // retry/backoff sees a failure, NOT a silent pass-through. + // The cap check must throw a VoyageResponseTooLargeError so the inbound + // try/catch at the bottom of voyageCompatFetch rethrows it (instead of + // the pre-fix bare `catch {}` that swallowed `throw new Error(...)` and + // returned the original response — making the cap theatrical). expect(source).toMatch(/exceeds[^`]*MAX_VOYAGE_RESPONSE_BYTES[^`]*bytes/); - expect(source).toMatch(/throw new Error\([\s\S]{0,200}Voyage response Content-Length/); + expect(source).toMatch(/throw new VoyageResponseTooLargeError\([\s\S]{0,200}Voyage response Content-Length/); }); test('Layer 2: per-embedding base64 cap fires inside the json.data iteration', async () => { @@ -70,7 +72,18 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => { // Defense-in-depth: even when Content-Length header is missing // (chunked encoding), each embedding string is bounded. expect(source).toMatch(/item\.embedding\.length\s*\*\s*0\.75/); - expect(source).toMatch(/throw new Error\([\s\S]{0,200}Voyage embedding base64/); + expect(source).toMatch(/throw new VoyageResponseTooLargeError\([\s\S]{0,200}Voyage embedding base64/); + }); + + test('inbound try/catch rethrows VoyageResponseTooLargeError (Codex P3 follow-up)', async () => { + const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text(); + // Critical structural invariant after the Codex P3 fix: the catch + // around the inbound JSON-rewrite block MUST rethrow OOM-cap errors. + // Pre-fix, a bare `catch {}` swallowed the throw and returned the + // original (oversized) response, making Layer 2 ineffective. The + // tagged class + instanceof check restores fail-loud behavior. + expect(source).toContain('VoyageResponseTooLargeError'); + expect(source).toMatch(/if\s*\(\s*err\s+instanceof\s+VoyageResponseTooLargeError\s*\)\s*throw\s+err/); }); test('comment thread documents both layers + the cap-sizing decision', async () => {