Files
gbrain/test/e2e/cross-modal-eval.test.ts
T
Garry TanandClaude Opus 5 398b17aa30 fix(ai): fold dashscope + google keys into gateway env, drop the retired Gemini default (#3500, #3510)
Part A (#3500): dashscope_api_key and google_api_key are now GBrainConfig
file-plane fields folded into the gateway env by buildGatewayConfig
(DASHSCOPE_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY), with process-env
GEMINI_API_KEY accepted as an alias for the canonical Google name.
Honors the deferral note in brain-score-recommendations.ts by adding the
matching HOSTED_EMBED_KEY_CONFIG entries in the same change. A new sweep
guard asserts every KNOWN_CONFIG_KEYS *_api_key field reaches the
gateway env (the #121/#2662/#3500 recurring bug class).

Part B (#3510): google:gemini-1.5-pro (retired by Google) removed from
the google recipe chat allowlist first — so recipe-vs-default guard
tests catch every dead default — then replaced at the default sites:
takes-quality DEFAULT_MODEL_PANEL and grade-takes ensemble docstring use
google:gemini-2.0-flash (and openai:gpt-5.2 for the equally-dead
gpt-4o), the takes-quality pricing allowlist swaps in gemini-2.0-flash +
gpt-5.2, and cross-modal slot C moves to deepseek:deepseek-v4-pro (same
replacement as PR #3501). New test/default-model-panels.test.ts pins the
takes-quality panel to recipe chat lists, canonical pricing, the budget
allowlist, and three distinct providers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:11:24 -07:00

199 lines
6.6 KiB
TypeScript

/**
* E2E for `gbrain eval cross-modal` runner via mocked gateway chat().
*
* Lives under test/e2e/ so the test-isolation lint (R2 — mock.module quarantine)
* does not require a *.serial.test.ts rename: test/e2e/* is exempt from the
* lint, and `scripts/run-e2e.sh` already runs one file per Bun process so
* `mock.module` leaks are contained.
*
* Verifies the verdict / exit-code contract end-to-end:
* PASS (verdict='pass') when every dim mean >=7 and no model <5
* FAIL (verdict='fail') when any dim breaches mean OR floor
* INCONCLUSIVE (verdict='inconclusive') when <2/3 model calls succeed
*/
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { configureGateway } from '../../src/core/ai/gateway.ts';
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'gbrain-cme-e2e-'));
// Configure the gateway so our mock can pretend providers are available.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
expansion_model: 'anthropic:claude-haiku-4-5-20251001',
chat_model: 'anthropic:claude-sonnet-4-6',
base_urls: undefined,
env: {
OPENAI_API_KEY: 'sk-test',
ANTHROPIC_API_KEY: 'sk-ant-test',
GOOGLE_GENERATIVE_AI_API_KEY: 'sk-google-test',
},
});
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
function makeChatStub(scoresBySlot: Record<string, number[]>) {
let callIdx = 0;
const order = ['openai:gpt-5.2', 'anthropic:claude-opus-4-7', 'deepseek:deepseek-v4-pro'];
return mock(async (opts: { model?: string }) => {
const model = opts.model ?? '';
callIdx++;
const slotIdx = order.indexOf(model);
const scores = scoresBySlot[model];
if (!scores) {
throw new Error(`mock: no scores configured for model ${model}`);
}
const goal = scores[0]!;
const depth = scores[1]!;
return {
text: JSON.stringify({
scores: { goal: { score: goal }, depth: { score: depth } },
overall: (goal + depth) / 2,
improvements: [`${slotIdx + 1}. tighten the intro`],
}),
blocks: [],
stopReason: 'end',
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
model,
providerId: model.split(':')[0]!,
};
});
}
describe('gbrain eval cross-modal — runner verdict contract', () => {
test('PASS: 3 happy responses, all dims >=7', async () => {
const chatStub = makeChatStub({
'openai:gpt-5.2': [9, 8],
'anthropic:claude-opus-4-7': [8, 7],
'deepseek:deepseek-v4-pro': [8, 8],
});
mock.module('../../src/core/ai/gateway.ts', () => ({
chat: chatStub,
configureGateway,
isAvailable: () => true,
}));
const { runEval } = await import('../../src/core/cross-modal-eval/runner.ts');
const result = await runEval({
task: 'sample task',
output: 'sample output content',
slug: 'demo',
receiptDir: tempDir,
cycles: 1,
});
expect(result.finalAggregate.verdict).toBe('pass');
expect(result.cycles).toHaveLength(1);
const files = readdirSync(tempDir);
expect(files.length).toBeGreaterThan(0);
expect(files[0]!.startsWith('demo-')).toBe(true);
const receipt = JSON.parse(readFileSync(join(tempDir, files[0]!), 'utf-8'));
expect(receipt.schema_version).toBe(1);
expect(receipt.aggregate.verdict).toBe('pass');
});
test('FAIL: one dim mean below 7', async () => {
const chatStub = makeChatStub({
'openai:gpt-5.2': [9, 6],
'anthropic:claude-opus-4-7': [8, 6],
'deepseek:deepseek-v4-pro': [8, 6],
});
mock.module('../../src/core/ai/gateway.ts', () => ({
chat: chatStub,
configureGateway,
isAvailable: () => true,
}));
const { runEval } = await import('../../src/core/cross-modal-eval/runner.ts');
const result = await runEval({
task: 'sample task',
output: 'sample output content',
slug: 'demo',
receiptDir: tempDir,
cycles: 1,
});
expect(result.finalAggregate.verdict).toBe('fail');
expect(result.finalAggregate.dimensions.depth!.failReason).toBe('mean_below_7');
});
test('FAIL: min-score floor caught when one model scores <5 (Q2)', async () => {
const chatStub = makeChatStub({
'openai:gpt-5.2': [9, 8],
'anthropic:claude-opus-4-7': [8, 8],
'deepseek:deepseek-v4-pro': [4, 8], // goal=4 trips the floor
});
mock.module('../../src/core/ai/gateway.ts', () => ({
chat: chatStub,
configureGateway,
isAvailable: () => true,
}));
const { runEval } = await import('../../src/core/cross-modal-eval/runner.ts');
const result = await runEval({
task: 'sample task',
output: 'sample output content',
slug: 'demo',
receiptDir: tempDir,
cycles: 1,
});
expect(result.finalAggregate.verdict).toBe('fail');
expect(result.finalAggregate.dimensions.goal!.failReason).toBe('min_below_5');
});
test('INCONCLUSIVE: 2 of 3 mock 5xx -> exit 2 contract (Q3)', async () => {
const chatStub = mock(async (opts: { model?: string }) => {
if (opts.model === 'openai:gpt-5.2') {
return {
text: JSON.stringify({
scores: { goal: { score: 8 } },
improvements: ['1. ok'],
}),
blocks: [],
stopReason: 'end',
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: opts.model,
providerId: 'openai',
};
}
throw new Error(`mock: forced 5xx for ${opts.model}`);
});
mock.module('../../src/core/ai/gateway.ts', () => ({
chat: chatStub,
configureGateway,
isAvailable: () => true,
}));
const { runEval } = await import('../../src/core/cross-modal-eval/runner.ts');
const result = await runEval({
task: 'sample task',
output: 'sample output content',
slug: 'demo',
receiptDir: tempDir,
cycles: 1,
});
expect(result.finalAggregate.verdict).toBe('inconclusive');
expect(result.finalAggregate.successes).toBe(1);
expect(result.finalAggregate.failures).toBe(2);
// Receipt is still written even on INCONCLUSIVE — forensics path.
const files = readdirSync(tempDir);
expect(files.length).toBe(1);
const receipt = JSON.parse(readFileSync(join(tempDir, files[0]!), 'utf-8'));
expect(receipt.aggregate.verdict).toBe('inconclusive');
expect(receipt.aggregate.errors).toHaveLength(2);
});
});