From 430d784a76cb444714ca432869211d5183bf2eab Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 21 May 2026 08:15:14 -0700 Subject: [PATCH] v0.37.6.0 feat(ai): OpenRouter recipe + generic default_headers seam (cherry-pick #1210) (#1246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai): add default_headers / resolveDefaultHeaders seam to Recipe Generalizes per-recipe header attachment so attribution headers (OpenRouter's HTTP-Referer + X-OpenRouter-Title) ride alongside Bearer auth on every openai-compatible touchpoint. Two safety guards fire at applyResolveAuth time: declaring both default_headers AND resolveDefaultHeaders throws AIConfigError (mutual exclusion); a default header whose key shadows the resolved auth header (Authorization, the resolver's custom header) also throws. Reranker HTTP path at gateway.ts:2281 now merges both Authorization Bearer AND auth.headers (where default_headers flow) into the request Headers map. Pre-fix the ternary picked one or the other; default_headers would have been silently dropped on the manual rerank path. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ai): add OpenRouter provider recipe One key, many hosted models. Configures openrouter:/ for chat (GPT-5.2 family, Claude 4.5/4.6/4.7, Gemini 3 Flash Preview, DeepSeek) and embedding (OpenAI text-embedding-3-small with Matryoshka dims_options). max_batch_tokens=300_000 (OpenAI's aggregate per-request token cap, not the per-input 8192 the original PR conflated). resolveDefaultHeaders returns HTTP-Referer + X-OpenRouter-Title + X-Title (back-compat alias) so traffic is attributed to gbrain on OR's leaderboard. Forks override via OPENROUTER_REFERER / OPENROUTER_TITLE env vars. supports_subagent_loop: false is informational — gbrain's subagent infra is hard-pinned to Anthropic-direct via isAnthropicProvider() upstream regardless of this flag. Filed as TODO to verify tool_use_id stability through OR. Cherry-picked from PR #1210. Contributed by @davemorin. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(cli): export buildGatewayConfig + thread OPENROUTER_BASE_URL Exports buildGatewayConfig for unit-test access. Adds one-line passthrough for OPENROUTER_BASE_URL matching the existing LITELLM/OLLAMA/LMSTUDIO/ LLAMA_SERVER pattern so users can point at a self-hosted OR-compatible proxy. Co-Authored-By: Claude Opus 4.7 (1M context) * test(ai): cover OpenRouter recipe + default_headers seam + wire-level headers Four test additions: - test/ai/recipe-openrouter.test.ts (11 cases) — recipe shape, Matryoshka dims_options, max_batch_tokens=300K, arbitrary-ID acceptance via assertTouchpoint, defaultResolveAuth happy/error, resolveDefaultHeaders defaults + fork-override path, setup_hint coverage. Shape regression on every chat/embedding model ID (catches typos without pinning the dynamic catalog). - test/ai/recipes-existing-regression.test.ts (+6 cases) — IRON RULE preserved; adds default_headers contract: Bearer+defaults returns both apiKey AND headers, custom-header+defaults merges with resolver winning, mutual-exclusion guard, Authorization-shadow guard, custom-auth-shadow guard, cross-touchpoint parity for all four (embedding/expansion/chat/ reranker). - test/ai/header-transport.test.ts (3 cases) — proves headers actually reach the wire. Synthetic recipes with resolveOpenAICompatConfig fetch wrappers capture outgoing Headers on embed/chat/rerank. Asserts Authorization + HTTP-Referer + X-OpenRouter-Title + X-Title all present. Codex flagged the return-shape-only coverage gap during plan review. - test/ai/build-gateway-config.test.ts (7 cases) — 5-way env-baseURL passthrough sweep through the now-exported buildGatewayConfig. Uses withEnv() from test/helpers/with-env.ts for isolation compliance. Mops up pre-existing untested drift on LLAMA_SERVER/OLLAMA/LMSTUDIO/LITELLM in the same pass. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: add OpenRouter to embedding-providers + bump recipe count 15 -> 16 recipes. Adds OpenRouter row to the TL;DR table, a setup section covering the value-prop (one key, many hosted models), env-var overrides (OPENROUTER_BASE_URL, OPENROUTER_REFERER, OPENROUTER_TITLE), the subagent- loop limitation (isAnthropicProvider() gate), and a "One key for many hosted models" bullet under the decision tree. README updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump v0.37.2.0 version refs to v0.37.4.0 across in-tree comments v0.37.2.0 was claimed by master's takes_resolution_consistency hotfix (#1211) before this branch could land. This commit re-stamps the source comments that reference the OpenRouter recipe / default_headers seam to v0.37.4.0 so the in-tree version markers match the actual landing version. No behavior change — comments only. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump version and changelog (v0.37.4.0) One key, many hosted models — OpenRouter recipe lands. Cherry-picked from #1210 (@davemorin), with codex review corrections folded in: - recipe count math (16 not 17) - current OR attribution header name (X-OpenRouter-Title, X-Title back-compat) - max_batch_tokens semantic (300K aggregate per-request, not 8192 per-input) - Matryoshka dims_options for text-embedding-3-small - auth-shadow guard at applyResolveAuth Adds the generic Recipe.default_headers / resolveDefaultHeaders seam so attribution headers ride alongside Bearer auth. Future Together/Groq adoption tracked in TODOS.md. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: rebump to v0.37.6.0 (queue moved past v0.37.4/v0.37.5) VERSION + package.json + CHANGELOG header + CLAUDE.md + TODOS.md + in-tree source comments + llms regen. No code-behavior change. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 69 +++++ CLAUDE.md | 3 +- README.md | 2 +- TODOS.md | 11 + VERSION | 2 +- docs/integrations/embedding-providers.md | 27 +- llms-full.txt | 5 +- package.json | 2 +- src/cli.ts | 15 +- src/core/ai/gateway.ts | 56 ++++- src/core/ai/recipes/index.ts | 2 + src/core/ai/recipes/openrouter.ts | 105 ++++++++ src/core/ai/types.ts | 23 ++ test/ai/build-gateway-config.test.ts | 87 +++++++ test/ai/header-transport.test.ts | 265 ++++++++++++++++++++ test/ai/recipe-openrouter.test.ts | 138 ++++++++++ test/ai/recipes-existing-regression.test.ts | 142 +++++++++++ 17 files changed, 926 insertions(+), 28 deletions(-) create mode 100644 src/core/ai/recipes/openrouter.ts create mode 100644 test/ai/build-gateway-config.test.ts create mode 100644 test/ai/header-transport.test.ts create mode 100644 test/ai/recipe-openrouter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf5100a5..b46a30a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,75 @@ All notable changes to GBrain will be documented in this file. +## [0.37.6.0] - 2026-05-20 + +**One key, many hosted models.** + +You can now configure `openrouter:/` directly in gbrain. OpenRouter proxies OpenAI, Anthropic, Google, DeepSeek, Meta, Qwen, and dozens of other hosted models through one OpenAI-compatible endpoint with one API key. Instead of juggling per-provider keys (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, DEEPSEEK_API_KEY, etc.), you set `OPENROUTER_API_KEY` once and pick the model at call time. Embedding goes through too, defaulting to `openai/text-embedding-3-small` with Matryoshka shrink to 512/768/1024. + +OpenRouter shows up in `gbrain providers list` as the 16th recipe (alphabetical between Ollama and OpenAI). Your traffic gets attributed to gbrain on OR's leaderboard via `HTTP-Referer` + `X-OpenRouter-Title` headers; if you're running gbrain inside a different agent stack (a downstream agent, your own fork, anything else) set `OPENROUTER_REFERER` + `OPENROUTER_TITLE` so your traffic gets attributed to you instead. + +**How to turn it on** + +```bash +export OPENROUTER_API_KEY=sk-or-... +gbrain providers list | grep -i openrouter # see the new recipe +gbrain providers env openrouter # see all OR-related env vars +gbrain config set chat_model openrouter:anthropic/claude-sonnet-4.6 +gbrain config set embedding_model openrouter:openai/text-embedding-3-small +gbrain config set embedding_dimensions 1024 # Matryoshka, optional +``` + +For forks attributing their own traffic: + +```bash +export OPENROUTER_REFERER=https://your-app.example +export OPENROUTER_TITLE="Your App" +``` + +**A few sharp edges to know about** + +- **Subagent loops stay Anthropic-direct.** gbrain's subagent infrastructure (the long tool-calling loop that powers `gbrain agent run`) is hard-pinned to Anthropic-direct because crash-replay needs stable `tool_use_id` blocks across attempts and OR's response normalization doesn't guarantee that. `openrouter:anthropic/claude-haiku-4.5` works fine for chat; it gets rejected at subagent submit time. Keep an Anthropic key if you use `gbrain agent`. +- **Per-model context limits vary.** The OR catalog spans 128K to 1M+ context windows. The recipe declares no recipe-wide `max_context_tokens` — upstream errors surface per-model. +- **Catalog churns.** The 8 curated chat slugs in the recipe (gpt-5.2, gpt-5.5, claude-haiku-4.5, claude-sonnet-4.6, claude-opus-4.7, gemini-3-flash-preview, deepseek-chat) are starting points, not a closed enum. Pass any OR model ID and it routes through; check https://openrouter.ai/models for the live catalog. + +**Under the hood — `default_headers` seam on Recipe** + +To ship attribution headers cleanly, this release adds a generic `Recipe.default_headers` (static) and `Recipe.resolveDefaultHeaders?(env)` (env-templated) seam. Headers from these fields ride alongside the existing Bearer auth on every openai-compatible touchpoint (embedding, expansion, chat, reranker). Two guards fire at `applyResolveAuth` time: declaring both fields throws `AIConfigError` (mutual exclusion); a default header that would shadow the auth header (`Authorization`, or any custom-header recipe's auth key) also throws. Together/Groq/any future recipe can opt into the same seam in a follow-up. + +The reranker HTTP path at `gateway.ts:2281` now merges both `Authorization: Bearer ` AND `auth.headers` (where default_headers flow) into the request's Headers map. Pre-fix the ternary picked one or the other; default_headers would have been silently dropped on the manual rerank path. + +**What's tested** + +Four new test files prove the seam reaches the wire, not just the return shape: + +- `test/ai/recipe-openrouter.test.ts` — 11 cases: recipe shape, Matryoshka `dims_options: [512, 768, 1024, 1536]`, `max_batch_tokens: 300_000` (OpenAI aggregate per-request cap, not per-input), arbitrary-ID acceptance, `resolveDefaultHeaders` defaults + env override, setup_hint coverage. +- `test/ai/header-transport.test.ts` — 3 cases: synthetic recipes with custom `fetch` wrappers capture outgoing headers on `embed()`, `chat()`, and `rerank()`. Asserts Authorization + HTTP-Referer + X-OpenRouter-Title + X-Title all reach the wire. +- `test/ai/recipes-existing-regression.test.ts` — IRON RULE preserved + 6 new contract cases for the `default_headers`/`resolveDefaultHeaders` merge and both safety guards. +- `test/ai/build-gateway-config.test.ts` — 7 cases pinning the 5-way env-baseURL passthrough (LLAMA_SERVER, OLLAMA, LMSTUDIO, LITELLM, OPENROUTER) through the now-exported `buildGatewayConfig`. Mops up pre-existing untested drift on four legacy env vars in the same pass. + +Cherry-picked from [#1210](https://github.com/garrytan/gbrain/pull/1210). Contributed by @davemorin; corrections from an outside-voice review (Codex) folded in: recipe count math (16 not 17), current OR attribution header name (`X-OpenRouter-Title` preferred, `X-Title` back-compat), `max_batch_tokens` semantic (aggregate not per-input), Matryoshka dims for `text-embedding-3-small`, and the auth-shadow guard at `applyResolveAuth`. + +## To take advantage of v0.37.6.0 + +`gbrain upgrade` should do this automatically. If it didn't, or if you want to verify the recipe shipped: + +1. **Check the recipe is registered:** + ```bash + gbrain providers list | grep -i openrouter + ``` +2. **Check env-var reporting:** + ```bash + OPENROUTER_API_KEY=fake-test gbrain providers env openrouter + ``` + Should list `OPENROUTER_API_KEY` (required), `OPENROUTER_BASE_URL`, `OPENROUTER_REFERER`, `OPENROUTER_TITLE` (optional). +3. **Smoke-test embeddings against a real key:** + ```bash + export OPENROUTER_API_KEY=sk-or-... + gbrain providers test --model openrouter:openai/text-embedding-3-small + ``` +4. **If any step fails,** file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor`. + ## [0.37.5.0] - 2026-05-20 **`gbrain doctor` stops flagging your tags as broken when they're not.** diff --git a/CLAUDE.md b/CLAUDE.md index d811418b9..99c36d92b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,9 +97,10 @@ strict behavior when unset. - `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/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. **v0.37.6.0 (#1210):** new `Recipe.default_headers?: Record` (static) and `Recipe.resolveDefaultHeaders?(env)` (env-templated) seam for per-recipe headers that ride 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) are rejected at `applyResolveAuth` call time so defaults cannot accidentally shadow auth. Used by OpenRouter for the `HTTP-Referer` + `X-OpenRouter-Title` + `X-Title` attribution triple; usable by any future recipe (Together/Groq) that wants attribution. - `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.36.3.0:** `embedQuery(text, opts?)` and `isAvailable(touchpoint, modelOverride?)` accept a model override so the resolved-column path can embed via the column's provider (Voyage / ZeroEntropy / OpenAI) instead of the global default. The hybrid path passes `{embeddingModel: resolved.provider, dimensions: resolved.dimensions}`; the gateway resolves the matching recipe and routes through its `instantiateEmbedding()` branch. `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 — fixes the CDX-4 bug where a healthy Voyage column would skip vector retrieval because OpenAI happened to be unconfigured. **v0.35.0.0:** ZeroEntropy support lands. New `zeroEntropyCompatFetch` shim (sibling to `voyageCompatFetch`) handles ZE's non-OpenAI-compatible wire shape — rewrites the request URL from `/embeddings` to `/models/embed`, injects `input_type` (default `'document'`; `'query'` when threaded via `providerOptions.openaiCompatible.input_type`) and explicit `encoding_format: 'float'`, and rewrites the response from `{results: [{embedding}], usage: {total_bytes, total_tokens}}` to `{data: [{embedding, index}], usage: {prompt_tokens, total_tokens}}` so the SDK's openai-compatible Zod schema validates (Voyage's shim hit the same `prompt_tokens` requirement at `:655`). Layer 1 (Content-Length) + Layer 2 (per-embedding) OOM caps via a new tagged `ZeroEntropyResponseTooLargeError` class (kept separate from `VoyageResponseTooLargeError` because `test/voyage-response-cap.test.ts` does structural source-text greps pinning the Voyage name — class unification is a deferred cleanup). Wired in `instantiateEmbedding()` via the same `recipe.id === 'zeroentropyai'` branch pattern Voyage uses. New `gateway.rerank()` native HTTP path (no AI-SDK reranking abstraction): resolves the configured reranker model via `getRerankerModel()`, posts to `${recipe.base_url}/models/rerank` with bearer auth, returns `RerankResult[]` sorted by relevance score. `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'` so callers can fail-open without an HTTP call. `_rerankTransport` test seam mirrors `_embedTransport`. New `gateway.embedQuery(text)` companion threads `inputType: 'query'` through `dimsProviderOptions()` (now 4-arg). `getRerankerModel()` accessor + `isAvailable('reranker')` branch added. `configureGateway` + `reconfigureGatewayWithEngine` thread `reranker_model` through the same path as embedding/expansion/chat. `applyResolveAuth` + `defaultResolveAuth` widen touchpoint param to include `'reranker'`. **v0.34.1.0 (#875):** new `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 pre-existing Voyage `/multimodalembeddings` path is unchanged; the 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` config — no more cryptic `vector dimension mismatch` at INSERT time. Pinned by 11 cases in `test/openai-compat-multimodal.test.ts`. **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/zeroentropyai.ts` (v0.35.0.0) — 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'` (NOT the misspelled `'openai-compat'` the original plan draft had — pinned by F1 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 F2 regression). `chars_per_token: 1` + `safety_factor: 0.5` match Voyage's dense-content hedge. +- `src/core/ai/recipes/openrouter.ts` (v0.37.6.0, #1210) — 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]` (native breakpoints from Weaviate's MRL analysis); `max_batch_tokens: 300_000` = OpenAI's aggregate-per-request token cap (NOT per-input — Codex caught the semantic in pre-merge review). 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. The recipe declares `resolveDefaultHeaders(env)` (the env-templated variant of the new `default_headers` seam) returning OR's three attribution headers: `HTTP-Referer` (required for OR to create an app-attribution entry), `X-OpenRouter-Title` (current preferred name per OR docs), `X-Title` (documented back-compat alias). Defaults to `https://gbrain.ai` / `gbrain`; forks (downstream agent deployments) override via `OPENROUTER_REFERER` / `OPENROUTER_TITLE` env vars so their traffic gets their attribution on OR's leaderboard. Smoke-tested by `test/ai/recipe-openrouter.test.ts` (11 cases including the D5 shape-test regression guard: every model in the chat list matches `^[a-z0-9-]+\/[a-z0-9._-]+$`, catching typos and malformed IDs without pinning the catalog's churn). - `src/core/rerank-audit.ts` (v0.35.0.0) — failure-only JSONL audit at `~/.gbrain/audit/rerank-failures-YYYY-Www.jsonl` (ISO-week rotation, mirrors `src/core/audit-slug-fallback.ts`). Exports `logRerankFailure({reason, model, query_hash, doc_count, error_summary})` + `readRecentRerankFailures(days)`. **Deliberately no `logRerankSuccess`** (CDX2-F22 in plan review): writing once per tokenmax search is hot-path I/O churn AND success events leak query volume + timing into a local audit file. `gbrain doctor`'s `reranker_health` check reads `search.reranker.enabled` first so "no events in window" is interpreted correctly (disabled → ok; enabled → ok). Query text is SHA-256-prefix-hashed (8 hex chars) for privacy. `GBRAIN_AUDIT_DIR` env override honored via the shared `resolveAuditDir()`. - `src/core/search/embedding-column.ts` (v0.36.3.0) — single source of truth for "which `content_chunks.*` column does this query rank against?" Pure functions, no engine I/O: `loadRegistry(cfg)` walks the `embedding_columns` config (DB plane, JSON map keyed by column name with `{provider, dimensions, type}` entries), seeds the OpenAI `embedding` builtin when unset, validates everything before it lands (column-name regex, type ∈ `vector | halfvec`, dims in [1, 8192], provider format) using `Object.create(null)` + `Object.hasOwn` so a key like `constructor` rejects instead of resolving to `Object.prototype.constructor`. `resolveColumn(registry, override?, cfg)` is the boundary call: returns a `ResolvedColumn` descriptor (frozen `{name, provider, dimensions, type}`) honoring per-call override → `search_embedding_column` config → `'embedding'` default. Throws `UnknownEmbeddingColumnError` with the list of registered names on miss. `isCacheSafe(resolved, cfg)` compares the full embedding SPACE (provider + dimensions + name) against cfg's default — a user who repointed the `embedding` builtin at Voyage doesn't accidentally serve OpenAI-shaped cache rows. `validateResolvedColumn(descriptor)` re-validates hand-rolled descriptors that bypass the registry (internal-SDK passthrough path) so the SQL-injection escape hatch through the descriptor field is closed. Consumed by `hybridSearch` (resolves once at the boundary, threads a `ResolvedColumn` into per-engine `searchVector` instead of a raw string), `gateway.embedQuery(text, {embeddingModel, dimensions})` (resolved column's provider drives the query-time embed call), `cosineReScore` (engine pulls vectors from the active column, not the hardcoded `embedding`), and the `query` MCP op (per-call `embedding_column` param). 538 lines of pure resolver, 511 unit cases in `test/search/embedding-column.test.ts` covering the 16 codex-flagged corners (prototype-pollution, descriptor passthrough, env-only Postgres install, empty-brain coverage gate, cache-space comparison). - `src/core/search/rerank.ts` (v0.35.0.0) — the call-site abstraction. `applyReranker(query, results, opts)` slots between `dedupResults()` and `enforceTokenBudget()` in `src/core/search/hybrid.ts`. Slices `opts.topNIn` (default 30) by current RRF order, sends to `gateway.rerank()`, reorders by `relevanceScore` desc, and appends the un-reranked tail unchanged (recall protection). **Fail-open on every `RerankError.reason`**: any error logs via `logRerankFailure` and returns the input array unchanged. Stamps `rerank_score` onto reordered items so downstream telemetry sees the new ordering signal. `topNOut: null` is the explicit "don't truncate" signal — semantically distinct from `undefined` which means "fall through to mode bundle" (CDX2-F16). Test seam: `opts.rerankerFn` lets tests stub `gateway.rerank` without touching the network. diff --git a/README.md b/README.md index aa0dccf41..8289fdd96 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h - **Voice**: Phone calls create brain pages via Twilio + OpenAI Realtime (or DIY STT+LLM+TTS). Setup recipe: [`recipes/twilio-voice-brain.md`](recipes/twilio-voice-brain.md). - **Email + calendar**: webhook handlers that route to brain signals. [`docs/integrations/meeting-webhooks.md`](docs/integrations/meeting-webhooks.md). -- **Embedding providers**: 14 recipes covering OpenAI (default fallback), Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md). +- **Embedding providers**: 16 recipes covering OpenAI (default fallback), OpenRouter, Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md). - **Credential gateway**: vault-aware secret distribution. [`docs/integrations/credential-gateway.md`](docs/integrations/credential-gateway.md). - **MCP clients**: every major MCP client is supported. [`docs/mcp/`](docs/mcp/) per-client setup. diff --git a/TODOS.md b/TODOS.md index 42bdfedb0..7a0533a50 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,6 +1,17 @@ # TODOS +## v0.37.6.0 OpenRouter recipe follow-ups (v0.37.x+ / v0.38.x) + +- [ ] **v0.37.x: Verify `tool_use_id` stability through OpenRouter with a live test, then decide whether to relax `isAnthropicProvider()`'s subagent-only gate.** v0.37.6.0 ships `supports_subagent_loop: false` on the OR recipe as informational only — the real gate is `isAnthropicProvider()` in `src/core/model-config.ts`, which hard-rejects every non-Anthropic provider at subagent submit time. OR proxies Anthropic-direct models that DO support stable `tool_use_id` by contract, but OR's response normalization may strip or re-encode them. A short live test: spin up a real OR account, run a subagent loop via `openrouter:anthropic/claude-haiku-4.5`, deliberately abort mid-loop, retry. Assert tool_use_id blocks are byte-identical across attempts. If they are, the `isAnthropicProvider()` check could relax to allow Anthropic models proxied through OR, giving users OR's price/availability story for subagent work. This is a deeper structural change than a recipe-flag flip; needs its own /plan-eng-review pass. Filed during v0.37.6.0 codex review. + +- [ ] **v0.37.x: Quarterly OR catalog refresh.** v0.37.6.0 ships 8 curated chat slugs (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) with `price_last_verified: '2026-05-20'`. OR's catalog churns weekly; specific slugs get deprecated, renamed, or merged. Refresh cadence: every 90 days, walk https://openrouter.ai/models, prune deprecated slugs, add new frontier IDs that match the recipe's curation logic (frontier-tier + cheap-routing entry points). Bump `price_last_verified`. The shape-test regression in `test/ai/recipe-openrouter.test.ts` (`MODEL_SHAPE` regex) means typos surface immediately; the catalog refresh is about discovery, not validation. + +- [ ] **v0.37.x: Adopt `resolveDefaultHeaders` for Together / Groq / other attribution-bearing recipes.** v0.37.6.0's `default_headers` / `resolveDefaultHeaders` seam is generic — any recipe whose provider benefits from app-attribution headers can opt in. Together and Groq both have rankings/analytics tied to per-app headers. Add their respective attribution headers to each recipe, similar to OR's `HTTP-Referer` + `X-OpenRouter-Title`. No type-system or gateway changes needed; just `default_headers` blocks on the existing recipes plus `_REFERER` / `_TITLE` env vars in their `auth_env.optional`. Filed during v0.37.6.0 eng review as a D4 generalization opportunity. + +- [ ] **v0.37.x: Guard cli.ts `main()` so importing `buildGatewayConfig` doesn't print help.** v0.37.6.0 exported `buildGatewayConfig` from `src/cli.ts` for test access. Importing it triggers the file's top-level `main()` which prints help to stdout during tests — functionally harmless (tests pass) but noisy. Fix: wrap `main()` in `if (import.meta.main)` so it only runs when cli.ts is the entry point, not when imported. Touches one line; trivial. Filed during v0.37.6.0 implementation. + + ## v0.37.5.0 NESTED_QUOTES validator follow-up - [ ] **v0.37.x+: unify `serializeFrontmatter` tag/title quoting with `brain-writer.ts:184`'s single-quote-with-`''`-escape style for consistency.** Cosmetic only now that the validator at `src/core/markdown.ts:219-238` is YAML-aware (v0.37.5.0). Today the emitter still produces `tags: ["yc"]` (double-quoted via `JSON.stringify`) while the repair path produces `tags: ['yc']` (single-quoted). Both are valid YAML and the validator accepts both, so this is cosmetic — but new writes drifting from repair-side output reads as inconsistency. Original signal: PR #1217 by @garrytan-agents (closed in favor of the validator fix). Touch `src/core/frontmatter-inference.ts:391-416` only; should be ~5 LOC + the existing test at `test/frontmatter-inference.test.ts:239` updated. diff --git a/VERSION b/VERSION index 62767a28d..c71a3ab49 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.5.0 \ No newline at end of file +0.37.6.0 \ No newline at end of file diff --git a/docs/integrations/embedding-providers.md b/docs/integrations/embedding-providers.md index 943728e96..6ff03d900 100644 --- a/docs/integrations/embedding-providers.md +++ b/docs/integrations/embedding-providers.md @@ -1,6 +1,6 @@ # Embedding providers -GBrain ships with 14 embedding-provider recipes covering OpenAI, the major hosted alternatives, three local options, and a universal escape hatch (LiteLLM proxy). Run `gbrain providers list` to see the live registry; `gbrain providers explain --json` emits a machine-readable matrix for agents. +GBrain ships with 16 embedding-provider recipes covering OpenAI, OpenRouter (single key, many hosted models), the major hosted alternatives, three local options, and a universal escape hatch (LiteLLM proxy). Run `gbrain providers list` to see the live registry; `gbrain providers explain --json` emits a machine-readable matrix for agents. This page is the human-readable counterpart: capability per provider, env-var setup, dimensions, cost, and known constraints. @@ -18,6 +18,7 @@ gbrain init --pglite --model voyage # use a non-default provider | Provider | env vars | default dims | cost ($/1M tokens) | local? | multimodal? | |---|---|---|---|---|---| | `openai` | `OPENAI_API_KEY` | 1536 | 0.13 | no | no | +| `openrouter` | `OPENROUTER_API_KEY` | 1536 | 0.02 | no | model-dependent | | `voyage` | `VOYAGE_API_KEY` | 1024 | 0.18 | no | yes (`voyage-multimodal-3`) | | `google` | `GOOGLE_GENERATIVE_AI_API_KEY` | 768 | 0.025 | no | no | | `azure-openai` | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT` | 1536 | 0.13 | no | no | @@ -37,6 +38,7 @@ gbrain init --pglite --model voyage # use a non-default provider - **Cost-sensitive, English-only**: Ollama (free, local) or Voyage (paid, best quality per dollar). - **Quality-first**: Voyage `voyage-4-large` (1024-2048 dims, ~3-4× more dense tokens than OpenAI tiktoken). - **Reranking pair**: Voyage (their reranker `rerank-2.5` pairs cleanly with Voyage embeddings). +- **One key for many hosted models**: OpenRouter. Set `OPENROUTER_API_KEY` and use `openrouter:/` for chat against GPT-5.2, Claude 4.x, Gemini 3, DeepSeek, and dozens more without juggling per-provider keys. Embedding catalog includes OpenAI, Google, Qwen, BGE-M3. - **Enterprise compliance**: Azure OpenAI (data residency + private endpoints) or self-hosted via llama-server / Ollama. - **China region**: DashScope (Alibaba) or Zhipu (BigModel). DashScope's international endpoint at `dashscope-intl.aliyuncs.com`; override `provider_base_urls.dashscope` for the China endpoint. - **OSS local, full control**: llama-server (`llama.cpp`) for any GGUF model; Ollama for the curated catalog. @@ -60,6 +62,20 @@ Set `GOOGLE_GENERATIVE_AI_API_KEY` (the AI Studio public API key). Model: `gemin For GCP service-account / Vertex AI auth (production deployments), see the v0.32.x follow-up — Vertex ADC is on the roadmap. +### OpenRouter + +Single OpenAI-compatible API for fan-out to OpenAI, Anthropic, Google, DeepSeek, Meta Llama, Qwen, and dozens of other hosted providers. One key, many models. Set `OPENROUTER_API_KEY` and use `openrouter:/` (e.g. `openrouter:openai/gpt-5.2`, `openrouter:anthropic/claude-sonnet-4.6`). + +**Embedding**: `openai/text-embedding-3-small` (1536d default, Matryoshka shrink to 512/768/1024). OR's embedding catalog also includes `text-embedding-3-large`, `google/gemini-embedding-2-preview`, `qwen/qwen3-embedding-8b`, `bge-m3` — opt in via `--embedding-model openrouter:`. Pricing matches the upstream provider (OR adds a small markup). + +**Chat**: every chat model OR proxies works through `/v1/chat/completions`. The recipe lists 8 curated entry points (GPT-5.2 family, Claude 4.5/4.6/4.7, Gemini 3 Flash Preview, DeepSeek); any other OR catalog ID also works. Tool-calling envelope is supported by the OR endpoint, but per-model capability varies — check https://openrouter.ai/models before counting on tools for a specific slug. + +**Optional env**: +- `OPENROUTER_BASE_URL` — point at a self-hosted OR-compatible proxy. +- `OPENROUTER_REFERER` (default `https://gbrain.ai`) and `OPENROUTER_TITLE` (default `gbrain`) — attribution headers for OR's leaderboard. Forks running gbrain inside a different agent stack (OpenClaw deployments etc.) should set these so their traffic gets attributed to them, not gbrain. + +**Subagent loops**: gbrain's subagent infrastructure hard-pins to Anthropic-direct (stable `tool_use_id` across crashes/replays). OR-routed Anthropic is rejected at submit time regardless of the recipe flag. If you want the price/availability story OR offers for tool-calling, use it for chat only and keep an Anthropic key for subagent work. + ### Azure OpenAI Enterprise OpenAI behind Azure tenancy. Required env: `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT` (e.g. `https://my-resource.openai.azure.com`), `AZURE_OPENAI_DEPLOYMENT` (the deployment name from your Azure portal). Optional: `AZURE_OPENAI_API_VERSION` (defaults to `2024-10-21`). @@ -113,11 +129,12 @@ For most users: **stay at 1024 or 1536**. Bigger isn't better below the noise fl ## My provider isn't listed -Three options: +Four options: -1. **Use LiteLLM proxy** (above) — the universal escape hatch. Works for 100+ providers. -2. **Open a feature request** at [github.com/garrytan/gbrain/issues](https://github.com/garrytan/gbrain/issues) with the provider's API docs URL and a setup snippet. Recipes are ~30-40 lines of TypeScript. -3. **Submit a recipe**: clone, copy `src/core/ai/recipes/voyage.ts` as the gold-standard openai-compat template, register in `src/core/ai/recipes/index.ts`, add a per-recipe smoke test under `test/ai/recipe-.test.ts`. The recipe contract test (`test/ai/recipes-contract.test.ts`) and IRON RULE regression test pin the structural invariants. +1. **Use OpenRouter** when the provider/model is available through OR's OpenAI-compatible API (covers most hosted chat models + a growing embedding catalog). +2. **Use LiteLLM proxy** (above) — the universal escape hatch. Works for 100+ providers. +3. **Open a feature request** at [github.com/garrytan/gbrain/issues](https://github.com/garrytan/gbrain/issues) with the provider's API docs URL and a setup snippet. Recipes are ~30-40 lines of TypeScript. +4. **Submit a recipe**: clone, copy `src/core/ai/recipes/voyage.ts` as the gold-standard openai-compat template, register in `src/core/ai/recipes/index.ts`, add a per-recipe smoke test under `test/ai/recipe-.test.ts`. The recipe contract test (`test/ai/recipes-contract.test.ts`) and IRON RULE regression test pin the structural invariants. ## Switching providers on an existing brain diff --git a/llms-full.txt b/llms-full.txt index 70a2ff0b0..0de7cedc1 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -233,9 +233,10 @@ strict behavior when unset. - `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/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. **v0.37.6.0 (#1210):** new `Recipe.default_headers?: Record` (static) and `Recipe.resolveDefaultHeaders?(env)` (env-templated) seam for per-recipe headers that ride 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) are rejected at `applyResolveAuth` call time so defaults cannot accidentally shadow auth. Used by OpenRouter for the `HTTP-Referer` + `X-OpenRouter-Title` + `X-Title` attribution triple; usable by any future recipe (Together/Groq) that wants attribution. - `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.36.3.0:** `embedQuery(text, opts?)` and `isAvailable(touchpoint, modelOverride?)` accept a model override so the resolved-column path can embed via the column's provider (Voyage / ZeroEntropy / OpenAI) instead of the global default. The hybrid path passes `{embeddingModel: resolved.provider, dimensions: resolved.dimensions}`; the gateway resolves the matching recipe and routes through its `instantiateEmbedding()` branch. `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 — fixes the CDX-4 bug where a healthy Voyage column would skip vector retrieval because OpenAI happened to be unconfigured. **v0.35.0.0:** ZeroEntropy support lands. New `zeroEntropyCompatFetch` shim (sibling to `voyageCompatFetch`) handles ZE's non-OpenAI-compatible wire shape — rewrites the request URL from `/embeddings` to `/models/embed`, injects `input_type` (default `'document'`; `'query'` when threaded via `providerOptions.openaiCompatible.input_type`) and explicit `encoding_format: 'float'`, and rewrites the response from `{results: [{embedding}], usage: {total_bytes, total_tokens}}` to `{data: [{embedding, index}], usage: {prompt_tokens, total_tokens}}` so the SDK's openai-compatible Zod schema validates (Voyage's shim hit the same `prompt_tokens` requirement at `:655`). Layer 1 (Content-Length) + Layer 2 (per-embedding) OOM caps via a new tagged `ZeroEntropyResponseTooLargeError` class (kept separate from `VoyageResponseTooLargeError` because `test/voyage-response-cap.test.ts` does structural source-text greps pinning the Voyage name — class unification is a deferred cleanup). Wired in `instantiateEmbedding()` via the same `recipe.id === 'zeroentropyai'` branch pattern Voyage uses. New `gateway.rerank()` native HTTP path (no AI-SDK reranking abstraction): resolves the configured reranker model via `getRerankerModel()`, posts to `${recipe.base_url}/models/rerank` with bearer auth, returns `RerankResult[]` sorted by relevance score. `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'` so callers can fail-open without an HTTP call. `_rerankTransport` test seam mirrors `_embedTransport`. New `gateway.embedQuery(text)` companion threads `inputType: 'query'` through `dimsProviderOptions()` (now 4-arg). `getRerankerModel()` accessor + `isAvailable('reranker')` branch added. `configureGateway` + `reconfigureGatewayWithEngine` thread `reranker_model` through the same path as embedding/expansion/chat. `applyResolveAuth` + `defaultResolveAuth` widen touchpoint param to include `'reranker'`. **v0.34.1.0 (#875):** new `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 pre-existing Voyage `/multimodalembeddings` path is unchanged; the 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` config — no more cryptic `vector dimension mismatch` at INSERT time. Pinned by 11 cases in `test/openai-compat-multimodal.test.ts`. **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/zeroentropyai.ts` (v0.35.0.0) — 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'` (NOT the misspelled `'openai-compat'` the original plan draft had — pinned by F1 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 F2 regression). `chars_per_token: 1` + `safety_factor: 0.5` match Voyage's dense-content hedge. +- `src/core/ai/recipes/openrouter.ts` (v0.37.6.0, #1210) — 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]` (native breakpoints from Weaviate's MRL analysis); `max_batch_tokens: 300_000` = OpenAI's aggregate-per-request token cap (NOT per-input — Codex caught the semantic in pre-merge review). 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. The recipe declares `resolveDefaultHeaders(env)` (the env-templated variant of the new `default_headers` seam) returning OR's three attribution headers: `HTTP-Referer` (required for OR to create an app-attribution entry), `X-OpenRouter-Title` (current preferred name per OR docs), `X-Title` (documented back-compat alias). Defaults to `https://gbrain.ai` / `gbrain`; forks (downstream agent deployments) override via `OPENROUTER_REFERER` / `OPENROUTER_TITLE` env vars so their traffic gets their attribution on OR's leaderboard. Smoke-tested by `test/ai/recipe-openrouter.test.ts` (11 cases including the D5 shape-test regression guard: every model in the chat list matches `^[a-z0-9-]+\/[a-z0-9._-]+$`, catching typos and malformed IDs without pinning the catalog's churn). - `src/core/rerank-audit.ts` (v0.35.0.0) — failure-only JSONL audit at `~/.gbrain/audit/rerank-failures-YYYY-Www.jsonl` (ISO-week rotation, mirrors `src/core/audit-slug-fallback.ts`). Exports `logRerankFailure({reason, model, query_hash, doc_count, error_summary})` + `readRecentRerankFailures(days)`. **Deliberately no `logRerankSuccess`** (CDX2-F22 in plan review): writing once per tokenmax search is hot-path I/O churn AND success events leak query volume + timing into a local audit file. `gbrain doctor`'s `reranker_health` check reads `search.reranker.enabled` first so "no events in window" is interpreted correctly (disabled → ok; enabled → ok). Query text is SHA-256-prefix-hashed (8 hex chars) for privacy. `GBRAIN_AUDIT_DIR` env override honored via the shared `resolveAuditDir()`. - `src/core/search/embedding-column.ts` (v0.36.3.0) — single source of truth for "which `content_chunks.*` column does this query rank against?" Pure functions, no engine I/O: `loadRegistry(cfg)` walks the `embedding_columns` config (DB plane, JSON map keyed by column name with `{provider, dimensions, type}` entries), seeds the OpenAI `embedding` builtin when unset, validates everything before it lands (column-name regex, type ∈ `vector | halfvec`, dims in [1, 8192], provider format) using `Object.create(null)` + `Object.hasOwn` so a key like `constructor` rejects instead of resolving to `Object.prototype.constructor`. `resolveColumn(registry, override?, cfg)` is the boundary call: returns a `ResolvedColumn` descriptor (frozen `{name, provider, dimensions, type}`) honoring per-call override → `search_embedding_column` config → `'embedding'` default. Throws `UnknownEmbeddingColumnError` with the list of registered names on miss. `isCacheSafe(resolved, cfg)` compares the full embedding SPACE (provider + dimensions + name) against cfg's default — a user who repointed the `embedding` builtin at Voyage doesn't accidentally serve OpenAI-shaped cache rows. `validateResolvedColumn(descriptor)` re-validates hand-rolled descriptors that bypass the registry (internal-SDK passthrough path) so the SQL-injection escape hatch through the descriptor field is closed. Consumed by `hybridSearch` (resolves once at the boundary, threads a `ResolvedColumn` into per-engine `searchVector` instead of a raw string), `gateway.embedQuery(text, {embeddingModel, dimensions})` (resolved column's provider drives the query-time embed call), `cosineReScore` (engine pulls vectors from the active column, not the hardcoded `embedding`), and the `query` MCP op (per-call `embedding_column` param). 538 lines of pure resolver, 511 unit cases in `test/search/embedding-column.test.ts` covering the 16 codex-flagged corners (prototype-pollution, descriptor passthrough, env-only Postgres install, empty-brain coverage gate, cache-space comparison). - `src/core/search/rerank.ts` (v0.35.0.0) — the call-site abstraction. `applyReranker(query, results, opts)` slots between `dedupResults()` and `enforceTokenBudget()` in `src/core/search/hybrid.ts`. Slices `opts.topNIn` (default 30) by current RRF order, sends to `gateway.rerank()`, reorders by `relevanceScore` desc, and appends the un-reranked tail unchanged (recall protection). **Fail-open on every `RerankError.reason`**: any error logs via `logRerankFailure` and returns the input array unchanged. Stamps `rerank_score` onto reordered items so downstream telemetry sees the new ordering signal. `topNOut: null` is the explicit "don't truncate" signal — semantically distinct from `undefined` which means "fall through to mode bundle" (CDX2-F16). Test seam: `opts.rerankerFn` lets tests stub `gateway.rerank` without touching the network. @@ -2523,7 +2524,7 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h - **Voice**: Phone calls create brain pages via Twilio + OpenAI Realtime (or DIY STT+LLM+TTS). Setup recipe: [`recipes/twilio-voice-brain.md`](recipes/twilio-voice-brain.md). - **Email + calendar**: webhook handlers that route to brain signals. [`docs/integrations/meeting-webhooks.md`](docs/integrations/meeting-webhooks.md). -- **Embedding providers**: 14 recipes covering OpenAI (default fallback), Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md). +- **Embedding providers**: 16 recipes covering OpenAI (default fallback), OpenRouter, Voyage, ZeroEntropy (default), Google Gemini, Azure OpenAI, MiniMax, Alibaba DashScope, Zhipu, Ollama (local), llama.cpp llama-server (local), LiteLLM proxy. Pricing matrix + decision tree in [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md). - **Credential gateway**: vault-aware secret distribution. [`docs/integrations/credential-gateway.md`](docs/integrations/credential-gateway.md). - **MCP clients**: every major MCP client is supported. [`docs/mcp/`](docs/mcp/) per-client setup. diff --git a/package.json b/package.json index b460f30ba..21e07b25c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.37.5.0", + "version": "0.37.6.0", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/src/cli.ts b/src/cli.ts index 454cd0a56..97d8f10ea 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1393,12 +1393,14 @@ async function handleCliOnly(command: string, args: string[]) { } } -// Build the AIGatewayConfig payload from a GBrainConfig. File-local; not -// exported. Both configureGateway sites in connectEngine() pass through this -// helper so adding a new field touches one place. Adding a field to one site -// but not the other previously required remembering to mirror the change; -// the helper makes that structural. -function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig { +// Build the AIGatewayConfig payload from a GBrainConfig. Both configureGateway +// sites in connectEngine() pass through this helper so adding a new field +// touches one place. Adding a field to one site but not the other previously +// required remembering to mirror the change; the helper makes that structural. +// v0.37.6.0: exported so `test/ai/build-gateway-config.test.ts` can pin the +// env-baseURL passthrough contract for every `_BASE_URL` env var the CLI +// reads (LLAMA_SERVER, OLLAMA, LMSTUDIO, LITELLM, OPENROUTER). +export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig { // v0.32 (#121 reworked): when ~/.gbrain/config.json declares // openai_api_key / anthropic_api_key, fold them into the gateway env so // recipes that read OPENAI_API_KEY / ANTHROPIC_API_KEY find them. Process @@ -1419,6 +1421,7 @@ function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig { if (process.env.OLLAMA_BASE_URL) envBaseUrls['ollama'] = process.env.OLLAMA_BASE_URL; if (process.env.LMSTUDIO_BASE_URL) envBaseUrls['lmstudio'] = process.env.LMSTUDIO_BASE_URL; if (process.env.LITELLM_BASE_URL) envBaseUrls['litellm'] = process.env.LITELLM_BASE_URL; + if (process.env.OPENROUTER_BASE_URL) envBaseUrls['openrouter'] = process.env.OPENROUTER_BASE_URL; return { embedding_model: c.embedding_model, diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 54a525775..451519029 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -262,19 +262,52 @@ export function applyResolveAuth( ? recipe.resolveAuth(cfg.env) : defaultResolveAuth(recipe, cfg.env, touchpoint); + // v0.37.6.0 — resolve default_headers (static or env-templated). Mutually + // exclusive; declaring both is a config error. + if (recipe.default_headers && recipe.resolveDefaultHeaders) { + throw new AIConfigError( + `Recipe "${recipe.id}" declares both default_headers and resolveDefaultHeaders. Pick one.`, + recipe.setup_hint, + ); + } + const defaults = recipe.resolveDefaultHeaders + ? recipe.resolveDefaultHeaders(cfg.env) + : recipe.default_headers; + + // v0.37.6.0 — defaults MUST NOT shadow the resolved auth header. SDK applies + // headers after apiKey, so an `Authorization` entry in defaults would replace + // the Bearer the SDK adds. Custom-header recipes (Azure: api-key) are + // protected the same way. + if (defaults) { + const lcResolved = resolved.headerName.toLowerCase(); + for (const k of Object.keys(defaults)) { + const lc = k.toLowerCase(); + if (lc === 'authorization' || lc === lcResolved) { + throw new AIConfigError( + `Recipe "${recipe.id}" default_headers contains "${k}" which would shadow the auth header. Remove it.`, + recipe.setup_hint, + ); + } + } + } + // Bearer-via-Authorization: use the SDK's native apiKey path (which sets // Authorization: Bearer internally). Strip the 'Bearer ' prefix the - // resolver returned. + // resolver returned. Default headers ride alongside if declared. if ( resolved.headerName === 'Authorization' && resolved.token.startsWith('Bearer ') ) { - return { apiKey: resolved.token.slice('Bearer '.length) }; + return defaults + ? { apiKey: resolved.token.slice('Bearer '.length), headers: { ...defaults } } + : { apiKey: resolved.token.slice('Bearer '.length) }; } // Custom header (Azure: api-key). Use headers; do NOT pass apiKey, or the // SDK will also set Authorization and the server may reject double-auth. - return { headers: { [resolved.headerName]: resolved.token } }; + // Defaults merge in first, resolver wins on key conflict (the shadow guard + // above already rejects conflicts, so this is defense-in-depth). + return { headers: { ...(defaults ?? {}), [resolved.headerName]: resolved.token } }; } /** @@ -2240,14 +2273,15 @@ export async function rerank(input: RerankInput): Promise { const url = `${compat.baseURL.replace(/\/$/, '')}/models/rerank`; const auth = applyResolveAuth(recipe, cfg, 'reranker'); // applyResolveAuth returns { apiKey } for Bearer-style auth (SDK's native - // path) or { headers } for custom-header providers (Azure). gateway.rerank - // builds the HTTP request directly (no SDK adapter), so we materialize - // both shapes into a Headers map. - const authHeaders: Record = auth.headers - ? { ...auth.headers } - : auth.apiKey - ? { Authorization: `Bearer ${auth.apiKey}` } - : {}; + // path) or { headers } for custom-header providers (Azure). v0.37.6.0: + // recipes can ALSO declare default_headers (attribution etc.) which flow + // through `auth.headers` alongside Bearer-style apiKey. The merge below + // materializes both shapes so static-default-headers ride on the reranker + // wire path the same way they ride the SDK paths. + const authHeaders: Record = { + ...(auth.apiKey ? { Authorization: `Bearer ${auth.apiKey}` } : {}), + ...(auth.headers ?? {}), + }; const body = JSON.stringify({ model: parsed.modelId, query: input.query, diff --git a/src/core/ai/recipes/index.ts b/src/core/ai/recipes/index.ts index 5915c9a92..244f00e83 100644 --- a/src/core/ai/recipes/index.ts +++ b/src/core/ai/recipes/index.ts @@ -10,6 +10,7 @@ import { openai } from './openai.ts'; import { google } from './google.ts'; import { anthropic } from './anthropic.ts'; import { ollama } from './ollama.ts'; +import { openrouter } from './openrouter.ts'; import { voyage } from './voyage.ts'; import { litellmProxy } from './litellm-proxy.ts'; import { deepseek } from './deepseek.ts'; @@ -27,6 +28,7 @@ const ALL: Recipe[] = [ google, anthropic, ollama, + openrouter, voyage, litellmProxy, deepseek, diff --git a/src/core/ai/recipes/openrouter.ts b/src/core/ai/recipes/openrouter.ts new file mode 100644 index 000000000..055848ac8 --- /dev/null +++ b/src/core/ai/recipes/openrouter.ts @@ -0,0 +1,105 @@ +import type { Recipe } from '../types.ts'; + +/** + * OpenRouter — single-key fan-out to OpenAI, Anthropic, Google, DeepSeek, and + * dozens of other providers via a single OpenAI-compatible endpoint at + * https://openrouter.ai/api/v1. + * + * One key, many models. Use `openrouter:/` strings: + * openrouter:openai/gpt-5.2 + * openrouter:anthropic/claude-sonnet-4.6 + * openrouter:google/gemini-3-flash-preview + * + * Embeddings: OpenRouter exposes `/v1/embeddings` proxying OpenAI's + * text-embedding-3-small (1536 dims) plus Matryoshka shrink via the SDK's + * `dimensions` field. Catalog also includes text-embedding-3-large, + * google/gemini-embedding-2-preview, qwen3-embedding-8b, and bge-m3 — users + * opt in via `--embedding-model openrouter:` (openai-compat tier accepts + * arbitrary IDs at the gateway; recipe lists are advisory, not enforcing). + * + * Chat: `/v1/chat/completions` proxies every chat model OpenRouter routes, + * with tool-calling per-model. The chat models list below is a curated entry + * point — `supports_tools: true` reflects the OR endpoint's tool-call + * envelope, not every individual model's capability. When in doubt about a + * specific model, check https://openrouter.ai/models. + * + * Attribution: OpenRouter recommends `HTTP-Referer` (required for app + * attribution) + `X-OpenRouter-Title` (preferred; `X-Title` kept as + * back-compat alias per OR docs). Defaults to `https://gbrain.ai` / `gbrain`; + * forks override via `OPENROUTER_REFERER` / `OPENROUTER_TITLE` env vars so + * downstream agent stacks (OpenClaw deployments, etc.) get their own + * attribution on OR's leaderboard instead of polluting gbrain's. + * + * Subagent loops: `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 (stable tool_use_id + * across crashes/replays). OR-proxied Anthropic is rejected at submit time + * regardless of this flag — relaxing the gate is a deeper architectural + * change tracked in TODOS.md. + */ +export const openrouter: Recipe = { + id: 'openrouter', + name: 'OpenRouter', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://openrouter.ai/api/v1', + auth_env: { + required: ['OPENROUTER_API_KEY'], + optional: ['OPENROUTER_BASE_URL', 'OPENROUTER_REFERER', 'OPENROUTER_TITLE'], + setup_url: 'https://openrouter.ai/settings/keys', + }, + resolveDefaultHeaders(env) { + const referer = env.OPENROUTER_REFERER ?? 'https://gbrain.ai'; + const title = env.OPENROUTER_TITLE ?? 'gbrain'; + return { + // Required by OR for app-attribution. Without HTTP-Referer no leaderboard + // entry is ever created (per https://openrouter.ai/docs/app-attribution). + 'HTTP-Referer': referer, + // Current preferred name per OR docs (2026). + 'X-OpenRouter-Title': title, + // Back-compat alias documented as still-supported. + 'X-Title': title, + }; + }, + touchpoints: { + embedding: { + models: ['openai/text-embedding-3-small'], + default_dims: 1536, + // text-embedding-3-small was trained at MRL breakpoints 512/1024/1536 + // (Weaviate analysis); 768 is a practical intermediate. Users opt into + // a smaller dim via `gbrain config set embedding_dimensions `. + dims_options: [512, 768, 1024, 1536], + cost_per_1m_tokens_usd: 0.02, + price_last_verified: '2026-05-20', + // OpenAI's published per-request aggregate is ~300K tokens for embeddings + // (per-input cap is 8192). This is the AGGREGATE budget the gateway uses + // to pre-split batches, NOT per-input. Per-input is enforced upstream. + max_batch_tokens: 300_000, + }, + chat: { + // Curated entry points (verified against OR's catalog 2026-05-20). The + // openai-compat tier does NOT enforce this list at runtime — users can + // pass any model ID OR routes. Refresh quarterly; see TODOS.md. + models: [ + 'openai/gpt-5.2', + 'openai/gpt-5.2-chat', + 'openai/gpt-5.5', + 'anthropic/claude-haiku-4.5', + 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-opus-4.7', + 'google/gemini-3-flash-preview', + 'deepseek/deepseek-chat', + ], + supports_tools: true, + // Informational only — real gate is isAnthropicProvider() upstream. + supports_subagent_loop: false, + supports_prompt_cache: false, + // No max_context_tokens: catalog spans 128K to 1M+; a single recipe-wide + // value is either unsafe for smaller models or wasteful for larger ones. + // Let upstream errors surface per-model. + price_last_verified: '2026-05-20', + }, + }, + setup_hint: + 'Get an API key at https://openrouter.ai/settings/keys, then `export OPENROUTER_API_KEY=...` and use `openrouter:/`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).', +}; diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts index ad6ab0279..bb8fe76b4 100644 --- a/src/core/ai/types.ts +++ b/src/core/ai/types.ts @@ -260,6 +260,29 @@ export interface Recipe { headerName: string; token: string; }; + /** + * v0.37.6.0: static request headers applied to every openai-compatible + * touchpoint (embedding, expansion, chat, reranker). Use for static-per-recipe + * attribution headers (OpenRouter's HTTP-Referer + X-OpenRouter-Title). + * Merged into the SDK call site after `applyResolveAuth` resolves auth. + * + * Mutually exclusive with `resolveDefaultHeaders` — declaring both throws + * `AIConfigError` at gateway-configure time. Keys conflicting with the + * resolved auth header (Authorization, the resolver's custom header) are + * rejected at `applyResolveAuth` call time so defaults cannot accidentally + * shadow auth. + */ + default_headers?: Record; + /** + * v0.37.6.0: env-templated equivalent of `default_headers`. Same merge + * semantics and same key-conflict guards. Used by recipes whose attribution + * headers vary by deployment (forks override referer/title via env). When + * declared, `default_headers` MUST be omitted. + * + * Runs at gateway-configure time on the `cfg.env` snapshot, never + * `process.env`. + */ + resolveDefaultHeaders?(env: Record): Record; /** * v0.32: templated openai-compatible config for recipes whose URL shape * doesn't fit a static `base_url_default`. Returns the resolved baseURL diff --git a/test/ai/build-gateway-config.test.ts b/test/ai/build-gateway-config.test.ts new file mode 100644 index 000000000..72bd146d1 --- /dev/null +++ b/test/ai/build-gateway-config.test.ts @@ -0,0 +1,87 @@ +/** + * buildGatewayConfig env-baseURL passthrough sweep (v0.37.2.0). + * + * Mops up pre-existing untested drift: every `_BASE_URL` env var the CLI + * reads (LLAMA_SERVER, OLLAMA, LMSTUDIO, LITELLM, OPENROUTER) was previously + * uncovered by unit tests. The helper was file-local so the test surface + * didn't exist; v0.37.2.0 exports it for the OR passthrough plus the four + * legacy passthroughs by parameterized sweep. + * + * Behavior contract: + * - When the env var is set, buildGatewayConfig(c).base_urls[recipeId] === envValue. + * - When the env var is unset, base_urls[recipeId] is undefined (no spurious key). + * - Caller-provided cfg.provider_base_urls overrides the env value. + * + * Env-mutation discipline: every env mutation routes through `withEnv()` from + * `test/helpers/with-env.ts`. Process-global env mutations would leak across + * files in the same shard. `withEnv` save/restore via try/finally is the + * canonical pattern (enforced by scripts/check-test-isolation.sh). + */ + +import { describe, expect, test } from 'bun:test'; +import { buildGatewayConfig } from '../../src/cli.ts'; +import type { GBrainConfig } from '../../src/core/config.ts'; +import { withEnv } from '../helpers/with-env.ts'; + +const PASSTHROUGHS: Array<{ envVar: string; recipeId: string }> = [ + { envVar: 'LLAMA_SERVER_BASE_URL', recipeId: 'llama-server' }, + { envVar: 'OLLAMA_BASE_URL', recipeId: 'ollama' }, + { envVar: 'LMSTUDIO_BASE_URL', recipeId: 'lmstudio' }, + { envVar: 'LITELLM_BASE_URL', recipeId: 'litellm' }, + { envVar: 'OPENROUTER_BASE_URL', recipeId: 'openrouter' }, +]; + +const TEST_VALUE = 'http://proxy.example.test/v1'; + +const baseConfig: GBrainConfig = {} as unknown as GBrainConfig; + +/** + * Build an env-override object that clears every passthrough and sets one. + * Other tests in the same shard may have set these; clearing all first ensures + * the test asserts on a clean slate without manual saveEnv/restoreEnv bookkeeping. + */ +function envFor(target: { envVar: string } | null): Record { + const overrides: Record = {}; + for (const { envVar } of PASSTHROUGHS) { + overrides[envVar] = target?.envVar === envVar ? TEST_VALUE : undefined; + } + return overrides; +} + +describe('buildGatewayConfig env-baseURL passthrough', () => { + for (const passthrough of PASSTHROUGHS) { + test(`${passthrough.envVar} flows through to base_urls.${passthrough.recipeId}`, async () => { + await withEnv(envFor(passthrough), async () => { + const cfg = buildGatewayConfig(baseConfig); + expect( + cfg.base_urls?.[passthrough.recipeId], + `${passthrough.envVar} → base_urls.${passthrough.recipeId}`, + ).toBe(TEST_VALUE); + }); + }); + } + + test('unset env vars do NOT populate base_urls keys', async () => { + await withEnv(envFor(null), async () => { + const cfg = buildGatewayConfig(baseConfig); + for (const { recipeId } of PASSTHROUGHS) { + expect( + cfg.base_urls?.[recipeId], + `${recipeId} key should be absent when env unset`, + ).toBeUndefined(); + } + }); + }); + + test('caller-provided provider_base_urls override env (config wins)', async () => { + await withEnv( + { ...envFor(null), OPENROUTER_BASE_URL: 'http://env.example/v1' }, + async () => { + const cfg = buildGatewayConfig({ + provider_base_urls: { openrouter: 'http://config.example/v1' }, + } as unknown as GBrainConfig); + expect(cfg.base_urls?.openrouter).toBe('http://config.example/v1'); + }, + ); + }); +}); diff --git a/test/ai/header-transport.test.ts b/test/ai/header-transport.test.ts new file mode 100644 index 000000000..e874602ae --- /dev/null +++ b/test/ai/header-transport.test.ts @@ -0,0 +1,265 @@ +/** + * Transport-level header sweep (v0.37.2.0). + * + * Codex correctly noted that the IRON RULE contract tests only prove the + * return shape of `applyResolveAuth` — they DO NOT prove that the AI SDK + * (createOpenAICompatible) actually applies our default_headers on outgoing + * requests. This file closes that gap by injecting a custom fetch wrapper + * via `resolveOpenAICompatConfig` and asserting every assembled header + * (Authorization + default_headers) reaches the wire. + * + * Three cases: + * 1. embed — SDK textEmbeddingModel path through createOpenAICompatible + * 2. chat — SDK languageModel path through createOpenAICompatible + * 3. rerank — manual HTTP path (no SDK adapter) — uses __setRerankTransportForTests + * + * Synthetic recipes are registered in RECIPES at beforeAll, removed at + * afterAll — same Map-mutation pattern any future recipe-shape test can reuse. + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + configureGateway, + resetGateway, + embed, + chat, + rerank, + __setRerankTransportForTests, +} from '../../src/core/ai/gateway.ts'; +import { RECIPES } from '../../src/core/ai/recipes/index.ts'; +import type { Recipe } from '../../src/core/ai/types.ts'; + +// --- Synthetic embed recipe --------------------------------------------------- +// Bearer auth + default_headers (HTTP-Referer + X-OpenRouter-Title + X-Title). +// resolveOpenAICompatConfig injects a fetch wrapper that captures the outgoing +// request's headers + body for assertions. + +let lastEmbedRequest: { url: string; headers: Record; body: string | null } | null = null; +const fakeEmbedFetch: (input: any, init?: any) => Promise = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : (input as URL).toString(); + const headers: Record = {}; + const h = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + h.forEach((v, k) => { headers[k.toLowerCase()] = v; }); + const body = init?.body + ? (typeof init.body === 'string' ? init.body : null) + : (input instanceof Request ? await input.text() : null); + lastEmbedRequest = { url, headers, body }; + // OpenAI-compatible /embeddings response shape — one embedding for "hello". + const json = { + object: 'list', + data: [{ object: 'embedding', index: 0, embedding: Array.from({ length: 8 }, () => 0.1) }], + model: 'fake-embed-model', + usage: { prompt_tokens: 1, total_tokens: 1 }, + }; + return new Response(JSON.stringify(json), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const SYNTHETIC_EMBED_RECIPE: Recipe = { + id: 'syntethic-embed-headers', + name: 'Synthetic Embed Headers', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://synthetic.test/v1', + auth_env: { required: ['SYNTHETIC_EMBED_KEY'] }, + touchpoints: { + embedding: { + models: ['fake-embed-model'], + default_dims: 8, + max_batch_tokens: 8192, + }, + }, + default_headers: { + 'HTTP-Referer': 'https://gbrain.ai', + 'X-OpenRouter-Title': 'gbrain', + 'X-Title': 'gbrain', + }, + resolveOpenAICompatConfig() { + return { + baseURL: 'https://synthetic.test/v1', + fetch: fakeEmbedFetch as unknown as typeof fetch, + }; + }, +}; + +// --- Synthetic chat recipe --------------------------------------------------- + +let lastChatRequest: { url: string; headers: Record; body: string | null } | null = null; +const fakeChatFetch: (input: any, init?: any) => Promise = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : (input as URL).toString(); + const headers: Record = {}; + const h = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + h.forEach((v, k) => { headers[k.toLowerCase()] = v; }); + const body = init?.body + ? (typeof init.body === 'string' ? init.body : null) + : (input instanceof Request ? await input.text() : null); + lastChatRequest = { url, headers, body }; + // OpenAI-compatible /chat/completions response shape — one assistant message. + const json = { + id: 'fake-chat-1', + object: 'chat.completion', + created: 0, + model: 'fake-chat-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; + return new Response(JSON.stringify(json), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const SYNTHETIC_CHAT_RECIPE: Recipe = { + id: 'syntethic-chat-headers', + name: 'Synthetic Chat Headers', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://synthetic.test/v1', + auth_env: { required: ['SYNTHETIC_CHAT_KEY'] }, + touchpoints: { + chat: { + models: ['fake-chat-model'], + supports_tools: false, + supports_subagent_loop: false, + }, + }, + default_headers: { + 'HTTP-Referer': 'https://gbrain.ai', + 'X-OpenRouter-Title': 'gbrain', + 'X-Title': 'gbrain', + }, + resolveOpenAICompatConfig() { + return { + baseURL: 'https://synthetic.test/v1', + fetch: fakeChatFetch as unknown as typeof fetch, + }; + }, +}; + +// --- Synthetic reranker recipe ----------------------------------------------- + +const SYNTHETIC_RERANK_RECIPE: Recipe = { + id: 'syntethic-rerank-headers', + name: 'Synthetic Rerank Headers', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://synthetic.test/v1', + auth_env: { required: ['SYNTHETIC_RERANK_KEY'] }, + touchpoints: { + reranker: { + models: ['fake-rerank-model'], + default_model: 'fake-rerank-model', + max_payload_bytes: 5_000_000, + }, + }, + default_headers: { + 'HTTP-Referer': 'https://gbrain.ai', + 'X-OpenRouter-Title': 'gbrain', + 'X-Title': 'gbrain', + }, +}; + +beforeAll(() => { + // Register synthetic recipes for the lifetime of this test file. RECIPES is + // a Map; .set/.delete is the natural test seam. No production-code changes + // are required to enable test-only recipe registration. + RECIPES.set(SYNTHETIC_EMBED_RECIPE.id, SYNTHETIC_EMBED_RECIPE); + RECIPES.set(SYNTHETIC_CHAT_RECIPE.id, SYNTHETIC_CHAT_RECIPE); + RECIPES.set(SYNTHETIC_RERANK_RECIPE.id, SYNTHETIC_RERANK_RECIPE); +}); + +afterAll(() => { + RECIPES.delete(SYNTHETIC_EMBED_RECIPE.id); + RECIPES.delete(SYNTHETIC_CHAT_RECIPE.id); + RECIPES.delete(SYNTHETIC_RERANK_RECIPE.id); + __setRerankTransportForTests(null); + resetGateway(); +}); + +describe('transport-level header sweep (v0.37.2.0)', () => { + test('1. embed — SDK applies Authorization + default_headers on every request', async () => { + lastEmbedRequest = null; + configureGateway({ + embedding_model: `${SYNTHETIC_EMBED_RECIPE.id}:fake-embed-model`, + embedding_dimensions: 8, + env: { SYNTHETIC_EMBED_KEY: 'sk-embed-fake' }, + }); + + const result = await embed(['hello']); + expect(result.length).toBe(1); + expect(lastEmbedRequest, 'fakeEmbedFetch should have been invoked').not.toBeNull(); + + const h = lastEmbedRequest!.headers; + expect(h['authorization'], 'Authorization Bearer must be present').toBe('Bearer sk-embed-fake'); + expect(h['http-referer'], 'HTTP-Referer must reach the wire').toBe('https://gbrain.ai'); + expect(h['x-openrouter-title'], 'X-OpenRouter-Title must reach the wire').toBe('gbrain'); + expect(h['x-title'], 'X-Title (back-compat) must reach the wire').toBe('gbrain'); + }); + + test('2. chat — SDK applies Authorization + default_headers on every request', async () => { + lastChatRequest = null; + configureGateway({ + chat_model: `${SYNTHETIC_CHAT_RECIPE.id}:fake-chat-model`, + env: { SYNTHETIC_CHAT_KEY: 'sk-chat-fake' }, + }); + + const result = await chat({ + model: `${SYNTHETIC_CHAT_RECIPE.id}:fake-chat-model`, + messages: [{ role: 'user', content: 'hello' }], + }); + expect(result.text).toBe('ok'); + expect(lastChatRequest, 'fakeChatFetch should have been invoked').not.toBeNull(); + + const h = lastChatRequest!.headers; + expect(h['authorization']).toBe('Bearer sk-chat-fake'); + expect(h['http-referer']).toBe('https://gbrain.ai'); + expect(h['x-openrouter-title']).toBe('gbrain'); + expect(h['x-title']).toBe('gbrain'); + }); + + test('3. rerank — manual HTTP path applies Authorization + default_headers', async () => { + let capturedHeaders: Record | null = null; + __setRerankTransportForTests(async (_url, init) => { + const hdrs: Record = {}; + new Headers(init.headers).forEach((v, k) => { hdrs[k.toLowerCase()] = v; }); + capturedHeaders = hdrs; + return new Response( + JSON.stringify({ + results: [ + { index: 0, relevance_score: 0.9 }, + { index: 1, relevance_score: 0.7 }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + + configureGateway({ + reranker_model: `${SYNTHETIC_RERANK_RECIPE.id}:fake-rerank-model`, + env: { SYNTHETIC_RERANK_KEY: 'sk-rerank-fake' }, + }); + + const results = await rerank({ + query: 'find relevant docs', + documents: ['doc a', 'doc b'], + model: `${SYNTHETIC_RERANK_RECIPE.id}:fake-rerank-model`, + }); + expect(results.length).toBe(2); + expect(capturedHeaders, 'rerank transport stub should have been invoked').not.toBeNull(); + + const h = capturedHeaders!; + expect(h['authorization'], 'rerank: Authorization Bearer must be present').toBe('Bearer sk-rerank-fake'); + expect(h['http-referer'], 'rerank: HTTP-Referer must reach the wire').toBe('https://gbrain.ai'); + expect(h['x-openrouter-title']).toBe('gbrain'); + expect(h['x-title']).toBe('gbrain'); + expect(h['content-type']).toBe('application/json'); + }); +}); diff --git a/test/ai/recipe-openrouter.test.ts b/test/ai/recipe-openrouter.test.ts new file mode 100644 index 000000000..b2e40faff --- /dev/null +++ b/test/ai/recipe-openrouter.test.ts @@ -0,0 +1,138 @@ +/** + * OpenRouter recipe smoke + shape regression (v0.37.2.0). + * + * Replaces the PR #1210 5-case smoke with a wider sweep: + * 1-5 recipe shape + auth (PR baseline) + * 6-7 arbitrary-ID acceptance + chat/embedding model-shape regression (D5 + * codex correction — never pin specific slugs) + * 8-10 resolveDefaultHeaders default + env-override paths (D4) + * 11 setup_hint references the required + optional env vars + */ + +import { describe, expect, test } from 'bun:test'; +import { getRecipe } from '../../src/core/ai/recipes/index.ts'; +import { defaultResolveAuth } from '../../src/core/ai/gateway.ts'; +import { assertTouchpoint } from '../../src/core/ai/model-resolver.ts'; +import { AIConfigError } from '../../src/core/ai/errors.ts'; + +// D5 shape regex: provider/model slug, allowing letters, digits, dots, hyphens, +// underscores in the model portion. Matches real OR catalog IDs like +// `openai/gpt-5.2-chat`, `anthropic/claude-haiku-4.5`, `deepseek/deepseek-chat`. +const MODEL_SHAPE = /^[a-z0-9-]+\/[a-z0-9._-]+$/i; + +describe('recipe: openrouter', () => { + test('1. registered with expected shape', () => { + const r = getRecipe('openrouter'); + expect(r).toBeDefined(); + expect(r!.id).toBe('openrouter'); + expect(r!.tier).toBe('openai-compat'); + expect(r!.implementation).toBe('openai-compatible'); + expect(r!.base_url_default).toBe('https://openrouter.ai/api/v1'); + expect(r!.auth_env?.required).toEqual(['OPENROUTER_API_KEY']); + expect(r!.auth_env?.optional).toContain('OPENROUTER_BASE_URL'); + expect(r!.auth_env?.optional).toContain('OPENROUTER_REFERER'); + expect(r!.auth_env?.optional).toContain('OPENROUTER_TITLE'); + }); + + test('2. embedding touchpoint declares Matryoshka dims + 300K aggregate budget', () => { + const r = getRecipe('openrouter')!; + expect(r.touchpoints.embedding).toBeDefined(); + const e = r.touchpoints.embedding!; + expect(e.models[0]).toBe('openai/text-embedding-3-small'); + expect(e.default_dims).toBe(1536); + expect(e.dims_options).toEqual([512, 768, 1024, 1536]); + expect(e.max_batch_tokens).toBe(300_000); + }); + + test('3. chat touchpoint accepts arbitrary provider/model IDs (openai-compat tier)', () => { + const r = getRecipe('openrouter')!; + expect(r.touchpoints.chat).toBeDefined(); + expect(r.touchpoints.chat!.supports_tools).toBe(true); + // supports_subagent_loop is informational; isAnthropicProvider() is the + // real gate. Field stays false per the recipe docstring. + expect(r.touchpoints.chat!.supports_subagent_loop).toBe(false); + expect(() => + assertTouchpoint(r, 'chat', 'some/provider-model'), + ).not.toThrow(); + expect(() => + assertTouchpoint(r, 'chat', 'meta-llama/llama-future-2030'), + ).not.toThrow(); + }); + + test('4. chat models list — every entry matches provider/model shape (D5 regression)', () => { + // Codex correction: pinning specific slugs creates false confidence (the + // list is advisory; OR's catalog churns). The shape test catches the + // failure modes that matter — typos, malformed IDs, dropped slashes, + // uppercase pollution — without locking us into the catalog's churn rate. + const r = getRecipe('openrouter')!; + const models = r.touchpoints.chat!.models; + expect(models.length).toBeGreaterThanOrEqual(6); + for (const m of models) { + expect(m, `chat model "${m}" must match provider/model shape`).toMatch( + MODEL_SHAPE, + ); + } + }); + + test('5. embedding models list — every entry matches provider/model shape', () => { + const r = getRecipe('openrouter')!; + const models = r.touchpoints.embedding!.models; + expect(models.length).toBeGreaterThanOrEqual(1); + for (const m of models) { + expect(m, `embedding model "${m}" must match provider/model shape`).toMatch( + MODEL_SHAPE, + ); + } + }); + + test('6. no max_context_tokens declared (mixed catalog, per-model varies)', () => { + const r = getRecipe('openrouter')!; + expect(r.touchpoints.chat!.max_context_tokens).toBeUndefined(); + }); + + test('7. defaultResolveAuth with OPENROUTER_API_KEY returns Bearer header', () => { + const r = getRecipe('openrouter')!; + const auth = defaultResolveAuth( + r, + { OPENROUTER_API_KEY: 'sk-or-fake' }, + 'embedding', + ); + expect(auth.headerName).toBe('Authorization'); + expect(auth.token).toBe('Bearer sk-or-fake'); + }); + + test('8. missing OPENROUTER_API_KEY throws AIConfigError', () => { + const r = getRecipe('openrouter')!; + expect(() => defaultResolveAuth(r, {}, 'embedding')).toThrow(AIConfigError); + }); + + test('9. resolveDefaultHeaders with no env returns gbrain defaults', () => { + const r = getRecipe('openrouter')!; + expect(r.resolveDefaultHeaders).toBeDefined(); + const h = r.resolveDefaultHeaders!({}); + expect(h['HTTP-Referer']).toBe('https://gbrain.ai'); + expect(h['X-OpenRouter-Title']).toBe('gbrain'); + // Back-compat alias documented as still-supported. + expect(h['X-Title']).toBe('gbrain'); + }); + + test('10. resolveDefaultHeaders honors OPENROUTER_REFERER + OPENROUTER_TITLE (fork override path)', () => { + const r = getRecipe('openrouter')!; + const h = r.resolveDefaultHeaders!({ + OPENROUTER_REFERER: 'https://agent-fork.example', + OPENROUTER_TITLE: 'agent-fork', + }); + expect(h['HTTP-Referer']).toBe('https://agent-fork.example'); + expect(h['X-OpenRouter-Title']).toBe('agent-fork'); + expect(h['X-Title']).toBe('agent-fork'); + }); + + test('11. setup_hint references required + optional env vars', () => { + const r = getRecipe('openrouter')!; + expect(r.setup_hint).toBeDefined(); + expect(r.setup_hint).toContain('OPENROUTER_API_KEY'); + expect(r.setup_hint).toContain('OPENROUTER_BASE_URL'); + expect(r.setup_hint).toContain('OPENROUTER_REFERER'); + expect(r.setup_hint).toContain('OPENROUTER_TITLE'); + }); +}); diff --git a/test/ai/recipes-existing-regression.test.ts b/test/ai/recipes-existing-regression.test.ts index 567c1c94e..300a7cda9 100644 --- a/test/ai/recipes-existing-regression.test.ts +++ b/test/ai/recipes-existing-regression.test.ts @@ -194,3 +194,145 @@ describe('IRON RULE: existing 9 recipes survive the v0.32 resolveAuth refactor', expect(overrides.map(r => r.id).sort()).toEqual(['azure-openai']); }); }); + +/** + * v0.37.2.0 — default_headers / resolveDefaultHeaders contract. Six cases pin + * the merge semantics + the two safety guards (mutual-exclusion + auth-shadow). + * + * Codex caught the auth-shadow class during plan review: AI SDK applies + * `headers` AFTER `apiKey`, so an `Authorization` entry in a recipe's defaults + * would replace the Bearer the SDK adds. The guard fires at applyResolveAuth + * call time so the failure is loud at gateway configure, not silent on the + * wire. + */ +describe('default_headers / resolveDefaultHeaders contract (v0.37.2.0)', () => { + const baseCfg = { env: {} } as any; + + test('Bearer auth + default_headers returns both apiKey AND headers', () => { + const synthetic: Recipe = { + id: 'fake-bearer-defaults', + name: 'Fake Bearer Defaults', + tier: 'openai-compat', + implementation: 'openai-compatible', + auth_env: { required: ['FAKE_KEY'] }, + touchpoints: {}, + default_headers: { + 'HTTP-Referer': 'https://example.test', + 'X-App-Title': 'fake-app', + }, + }; + const env = { FAKE_KEY: 'sk-fake' }; + const auth = applyResolveAuth(synthetic, { env } as any, 'embedding'); + expect(auth.apiKey).toBe('sk-fake'); + expect(auth.headers).toEqual({ + 'HTTP-Referer': 'https://example.test', + 'X-App-Title': 'fake-app', + }); + }); + + test('resolveDefaultHeaders is preferred over static default_headers when only resolveDefaultHeaders is set', () => { + const synthetic: Recipe = { + id: 'fake-resolver', + name: 'Fake Resolver', + tier: 'openai-compat', + implementation: 'openai-compatible', + auth_env: { required: ['FAKE_KEY'] }, + touchpoints: {}, + resolveDefaultHeaders(env) { + return { + 'HTTP-Referer': env.OVERRIDE_REFERER ?? 'https://default.test', + 'X-Title': env.OVERRIDE_TITLE ?? 'default-title', + }; + }, + }; + const env = { FAKE_KEY: 'sk-fake', OVERRIDE_REFERER: 'https://forked.test', OVERRIDE_TITLE: 'Forked' }; + const auth = applyResolveAuth(synthetic, { env } as any, 'chat'); + expect(auth.headers).toEqual({ + 'HTTP-Referer': 'https://forked.test', + 'X-Title': 'Forked', + }); + }); + + test('declaring BOTH default_headers AND resolveDefaultHeaders throws AIConfigError', () => { + const synthetic: Recipe = { + id: 'fake-conflict', + name: 'Fake Conflict', + tier: 'openai-compat', + implementation: 'openai-compatible', + auth_env: { required: ['FAKE_KEY'] }, + touchpoints: {}, + default_headers: { 'X-One': '1' }, + resolveDefaultHeaders: () => ({ 'X-Two': '2' }), + }; + const env = { FAKE_KEY: 'sk-fake' }; + expect(() => + applyResolveAuth(synthetic, { env } as any, 'embedding'), + ).toThrow(AIConfigError); + }); + + test('default_headers containing Authorization throws AIConfigError (auth-shadow guard)', () => { + const synthetic: Recipe = { + id: 'fake-shadow-auth', + name: 'Fake Shadow Auth', + tier: 'openai-compat', + implementation: 'openai-compatible', + auth_env: { required: ['FAKE_KEY'] }, + touchpoints: {}, + // Any casing of Authorization should be caught — SDK header keys are + // case-insensitive on the wire, so 'authorization', 'Authorization', and + // 'AUTHORIZATION' all reach the same final header. + default_headers: { authorization: 'Bearer attacker-token' }, + }; + const env = { FAKE_KEY: 'sk-fake' }; + expect(() => + applyResolveAuth(synthetic, { env } as any, 'embedding'), + ).toThrow(AIConfigError); + }); + + test('default_headers shadowing a custom-header resolveAuth (Azure-style) throws AIConfigError', () => { + const synthetic: Recipe = { + id: 'fake-shadow-custom', + name: 'Fake Shadow Custom', + tier: 'openai-compat', + implementation: 'openai-compatible', + auth_env: { required: ['FAKE_AZ_KEY'] }, + touchpoints: {}, + resolveAuth(env) { + const k = env.FAKE_AZ_KEY; + if (!k) throw new AIConfigError('missing key'); + return { headerName: 'api-key', token: k }; + }, + // 'api-key' shadows the resolver's header — must throw. + default_headers: { 'api-key': 'attacker-value' }, + }; + const env = { FAKE_AZ_KEY: 'real-key' }; + expect(() => + applyResolveAuth(synthetic, { env } as any, 'embedding'), + ).toThrow(AIConfigError); + }); + + test('all four touchpoints produce identical default_headers for the same recipe + env', () => { + // Critical regression: defaults must apply identically across + // embedding/expansion/chat/reranker; the recipe declares them once and the + // gateway must thread them through every site. + const synthetic: Recipe = { + id: 'fake-all-touchpoints', + name: 'Fake All Touchpoints', + tier: 'openai-compat', + implementation: 'openai-compatible', + auth_env: { required: ['FAKE_KEY'] }, + touchpoints: {}, + default_headers: { 'X-Static': 'static-val' }, + }; + const env = { FAKE_KEY: 'sk-fake' }; + const e = applyResolveAuth(synthetic, { env } as any, 'embedding'); + const x = applyResolveAuth(synthetic, { env } as any, 'expansion'); + const c = applyResolveAuth(synthetic, { env } as any, 'chat'); + const r = applyResolveAuth(synthetic, { env } as any, 'reranker'); + expect(e).toEqual(x); + expect(x).toEqual(c); + expect(c).toEqual(r); + expect(e.headers).toEqual({ 'X-Static': 'static-val' }); + }); +}); +