Files
gbrain/test/ai/build-gateway-config.test.ts
T
430d784a76 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>
2026-05-21 08:15:14 -07:00

88 lines
3.5 KiB
TypeScript

/**
* 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');
},
);
});
});