v0.37.6.0 feat(ai): OpenRouter recipe + generic default_headers seam (cherry-pick #1210) (#1246)

* 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) <noreply@anthropic.com>

* feat(ai): add OpenRouter provider recipe

One key, many hosted models. Configures openrouter:<provider>/<model> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-21 08:15:14 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 1580c6d1ca
commit 430d784a76
17 changed files with 926 additions and 28 deletions
+69
View File
@@ -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:<provider>/<model>` 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 <key>` 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.**
+2 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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.
+11
View File
@@ -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 `<PROVIDER>_REFERER` / `<PROVIDER>_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.
+1 -1
View File
@@ -1 +1 @@
0.37.5.0
0.37.6.0
+22 -5
View File
@@ -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:<provider>/<model>` 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:<provider>/<model>` (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:<id>`. 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-<name>.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-<name>.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
+3 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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",
+9 -6
View File
@@ -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,
+45 -11
View File
@@ -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 <key> 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<RerankResult[]> {
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<string, string> = 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<string, string> = {
...(auth.apiKey ? { Authorization: `Bearer ${auth.apiKey}` } : {}),
...(auth.headers ?? {}),
};
const body = JSON.stringify({
model: parsed.modelId,
query: input.query,
+2
View File
@@ -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,
+105
View File
@@ -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:<provider>/<model>` 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:<id>` (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 <N>`.
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:<provider>/<model>`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).',
};
+23
View File
@@ -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<string, string>;
/**
* 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<string, string | undefined>): Record<string, string>;
/**
* v0.32: templated openai-compatible config for recipes whose URL shape
* doesn't fit a static `base_url_default`. Returns the resolved baseURL
+87
View File
@@ -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<string, string | undefined> {
const overrides: Record<string, string | undefined> = {};
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');
},
);
});
});
+265
View File
@@ -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<string, string>; body: string | null } | null = null;
const fakeEmbedFetch: (input: any, init?: any) => Promise<Response> = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : (input as URL).toString();
const headers: Record<string, string> = {};
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<string, string>; body: string | null } | null = null;
const fakeChatFetch: (input: any, init?: any) => Promise<Response> = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : (input as URL).toString();
const headers: Record<string, string> = {};
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<string, string> | null = null;
__setRerankTransportForTests(async (_url, init) => {
const hdrs: Record<string, string> = {};
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');
});
});
+138
View File
@@ -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');
});
});
+142
View File
@@ -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' });
});
});