mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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:
co-authored by
Claude Opus 4.7
parent
1580c6d1ca
commit
430d784a76
@@ -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.**
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user