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>
139 lines
5.8 KiB
TypeScript
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');
|
|
});
|
|
});
|