Files
gbrain/test/ai/recipe-openrouter.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

139 lines
5.8 KiB
TypeScript

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