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>
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
/**
|
|
* IRON RULE regression test (D2/D12=A): the v0.32 resolveAuth refactor
|
|
* MUST NOT change auth behavior for any of the 9 existing recipes
|
|
* (openai, anthropic, google, deepseek, groq, ollama, litellm-proxy,
|
|
* together, voyage).
|
|
*
|
|
* Pre-v0.32, openai-compatible auth was duplicated 3 times in gateway.ts
|
|
* with subtle drift; D12=A unified all three through Recipe.resolveAuth?
|
|
* with a default that covers existing recipes unchanged. This test pins
|
|
* the contract so the next refactor can't silently regress it.
|
|
*
|
|
* Coverage:
|
|
* - defaultResolveAuth returns Authorization Bearer <key> when required[0] is set
|
|
* - throws AIConfigError when required env is missing (with recipe name + touchpoint in message)
|
|
* - falls back to first present optional env when required is empty (Ollama-style)
|
|
* - falls back to 'unauthenticated' when neither required nor optional present
|
|
* - applyResolveAuth converts Authorization Bearer to {apiKey} (SDK native)
|
|
* - applyResolveAuth converts custom headers to {headers} WITHOUT apiKey (no double-auth)
|
|
* - all 3 touchpoints (embedding, expansion, chat) produce identical auth shape for the same recipe+env
|
|
* - native recipes (openai, anthropic, google) are not consulted via resolveAuth (they use their AI-SDK adapters directly)
|
|
*/
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { defaultResolveAuth, applyResolveAuth } from '../../src/core/ai/gateway.ts';
|
|
import { listRecipes, getRecipe } from '../../src/core/ai/recipes/index.ts';
|
|
import { AIConfigError } from '../../src/core/ai/errors.ts';
|
|
import type { Recipe } from '../../src/core/ai/types.ts';
|
|
|
|
const TOUCHPOINTS: Array<'embedding' | 'expansion' | 'chat'> = ['embedding', 'expansion', 'chat'];
|
|
|
|
describe('IRON RULE: existing 9 recipes survive the v0.32 resolveAuth refactor', () => {
|
|
test('all 9 baseline recipes are still registered (subset, allows post-v0.32 additions)', () => {
|
|
const ids = new Set(listRecipes().map(r => r.id));
|
|
for (const baseline of [
|
|
'anthropic',
|
|
'deepseek',
|
|
'google',
|
|
'groq',
|
|
'litellm',
|
|
'ollama',
|
|
'openai',
|
|
'together',
|
|
'voyage',
|
|
]) {
|
|
expect(ids.has(baseline), `baseline recipe ${baseline} missing post-refactor`).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('every recipe with a non-empty required[] returns Authorization Bearer <key>', () => {
|
|
for (const r of listRecipes()) {
|
|
const required = r.auth_env?.required ?? [];
|
|
if (required.length === 0) continue;
|
|
const env = { [required[0]]: `fake-${r.id}-key` };
|
|
const auth = defaultResolveAuth(r, env, 'embedding');
|
|
expect(auth.headerName).toBe('Authorization');
|
|
expect(auth.token).toBe(`Bearer fake-${r.id}-key`);
|
|
}
|
|
});
|
|
|
|
test('missing required env throws AIConfigError naming the recipe + touchpoint', () => {
|
|
const recipesWithRequired = listRecipes().filter(r => (r.auth_env?.required ?? []).length > 0);
|
|
expect(recipesWithRequired.length).toBeGreaterThan(0);
|
|
for (const r of recipesWithRequired) {
|
|
for (const tp of TOUCHPOINTS) {
|
|
let caught: unknown;
|
|
try {
|
|
defaultResolveAuth(r, {}, tp);
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(caught, `${r.id} ${tp} should throw on missing env`).toBeInstanceOf(AIConfigError);
|
|
const msg = (caught as Error).message;
|
|
expect(msg).toContain(r.name);
|
|
expect(msg).toContain(tp);
|
|
expect(msg).toContain(r.auth_env!.required[0]);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Ollama (empty required, OLLAMA_API_KEY set) reads it as the Bearer token', () => {
|
|
const ollama = getRecipe('ollama');
|
|
expect(ollama).toBeDefined();
|
|
expect(ollama!.auth_env?.required ?? []).toEqual([]);
|
|
const optional = ollama!.auth_env?.optional ?? [];
|
|
expect(optional).toContain('OLLAMA_API_KEY');
|
|
// OLLAMA_API_KEY (a non-URL-shaped optional) becomes the Bearer.
|
|
const auth = defaultResolveAuth(ollama!, { OLLAMA_API_KEY: 'fake-token' }, 'embedding');
|
|
expect(auth.headerName).toBe('Authorization');
|
|
expect(auth.token).toBe('Bearer fake-token');
|
|
});
|
|
|
|
test('Ollama (no env at all) falls back to "Bearer unauthenticated"', () => {
|
|
const ollama = getRecipe('ollama');
|
|
const auth = defaultResolveAuth(ollama!, {}, 'embedding');
|
|
expect(auth.headerName).toBe('Authorization');
|
|
expect(auth.token).toBe('Bearer unauthenticated');
|
|
});
|
|
|
|
test('URL-shaped optional env (OLLAMA_BASE_URL, LLAMA_SERVER_BASE_URL) does NOT become the Bearer token', () => {
|
|
// Regression for the v0.32 default-fallback design: optional entries
|
|
// ending in _URL or _BASE_URL are config (cfg.base_urls), not auth.
|
|
// The fallback must skip them and consult the next optional API-key entry.
|
|
const ollama = getRecipe('ollama');
|
|
const auth1 = defaultResolveAuth(
|
|
ollama!,
|
|
{ OLLAMA_BASE_URL: 'http://my-ollama/v1' },
|
|
'embedding',
|
|
);
|
|
expect(auth1.token, 'OLLAMA_BASE_URL must not become Bearer token').toBe('Bearer unauthenticated');
|
|
|
|
// When BOTH BASE_URL and API_KEY are set, the API_KEY wins.
|
|
const auth2 = defaultResolveAuth(
|
|
ollama!,
|
|
{ OLLAMA_BASE_URL: 'http://my-ollama/v1', OLLAMA_API_KEY: 'real-key' },
|
|
'embedding',
|
|
);
|
|
expect(auth2.token).toBe('Bearer real-key');
|
|
});
|
|
|
|
test('all 3 touchpoints produce identical auth for the same recipe + env', () => {
|
|
// Critical regression: pre-v0.32, embedding had a fallback to
|
|
// ${recipe.id.toUpperCase()}_API_KEY that expansion and chat lacked.
|
|
// Post-D12=A unification, all 3 touchpoints go through the same
|
|
// resolver, so the auth shape MUST match.
|
|
for (const r of listRecipes()) {
|
|
if (r.implementation !== 'openai-compatible') continue;
|
|
const required = r.auth_env?.required ?? [];
|
|
const env: Record<string, string> = {};
|
|
if (required.length > 0) env[required[0]] = `fake-${r.id}-key`;
|
|
|
|
const embeddingAuth = applyResolveAuth(r, { env } as any, 'embedding');
|
|
const expansionAuth = applyResolveAuth(r, { env } as any, 'expansion');
|
|
const chatAuth = applyResolveAuth(r, { env } as any, 'chat');
|
|
|
|
expect(embeddingAuth, `${r.id} embed=expand`).toEqual(expansionAuth);
|
|
expect(expansionAuth, `${r.id} expand=chat`).toEqual(chatAuth);
|
|
}
|
|
});
|
|
|
|
test('applyResolveAuth converts Authorization Bearer to {apiKey} (SDK-native path)', () => {
|
|
const voyage = getRecipe('voyage')!;
|
|
const env = { VOYAGE_API_KEY: 'fake-voyage-key' };
|
|
const auth = applyResolveAuth(voyage, { env } as any, 'embedding');
|
|
expect(auth.apiKey).toBe('fake-voyage-key');
|
|
expect(auth.headers).toBeUndefined();
|
|
});
|
|
|
|
test('applyResolveAuth respects a recipe.resolveAuth override that returns a custom header', () => {
|
|
// Synthetic recipe with a custom-header resolveAuth (Azure-style preview;
|
|
// the actual Azure recipe lands in commit 8). Ensures the seam works.
|
|
const fakeAzure: Recipe = {
|
|
id: 'fake-azure',
|
|
name: 'Fake Azure',
|
|
tier: 'openai-compat',
|
|
implementation: 'openai-compatible',
|
|
auth_env: { required: ['FAKE_AZURE_API_KEY'] },
|
|
touchpoints: {},
|
|
resolveAuth(env) {
|
|
const k = env.FAKE_AZURE_API_KEY;
|
|
if (!k) throw new AIConfigError('Fake Azure requires FAKE_AZURE_API_KEY.');
|
|
return { headerName: 'api-key', token: k };
|
|
},
|
|
};
|
|
const env = { FAKE_AZURE_API_KEY: 'fake-key' };
|
|
const auth = applyResolveAuth(fakeAzure, { env } as any, 'embedding');
|
|
expect(auth.apiKey, 'custom-header path must NOT set apiKey').toBeUndefined();
|
|
expect(auth.headers).toEqual({ 'api-key': 'fake-key' });
|
|
});
|
|
|
|
test('native-* recipes have no resolveAuth declared; they take native SDK paths', () => {
|
|
// Confirms the architectural invariant: resolveAuth is only consulted by
|
|
// the openai-compatible branches in instantiate{Embedding,Expansion,Chat}.
|
|
// Native recipes (openai, anthropic, google) use createOpenAI /
|
|
// createAnthropic / createGoogleGenerativeAI directly with the SDK's
|
|
// own apiKey field. This test pins that resolveAuth is intentionally
|
|
// absent on the native recipes — a future drift that adds it without
|
|
// wiring it through the native branches would silently fail this assert.
|
|
for (const id of ['openai', 'anthropic', 'google']) {
|
|
const r = getRecipe(id);
|
|
expect(r, `recipe ${id} missing`).toBeDefined();
|
|
expect(r!.tier).toBe('native');
|
|
expect(r!.resolveAuth, `${id} should NOT declare resolveAuth in v0.32`).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test('only Azure overrides resolveAuth in v0.32 (default applies elsewhere)', () => {
|
|
// The default resolver covers every openai-compatible recipe except
|
|
// Azure, which uses the api-key custom-header path. The IRON RULE
|
|
// contract: any new override beyond Azure must be reviewed for
|
|
// double-auth + back-compat regression.
|
|
const overrides = listRecipes().filter(
|
|
r => r.implementation === 'openai-compatible' && r.resolveAuth,
|
|
);
|
|
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' });
|
|
});
|
|
});
|
|
|