mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6920744dd8
commit
398b17aa30
@@ -20,7 +20,7 @@
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGatewayConfig } from '../../src/cli.ts';
|
||||
import type { GBrainConfig } from '../../src/core/config.ts';
|
||||
import { KNOWN_CONFIG_KEYS, type GBrainConfig } from '../../src/core/config.ts';
|
||||
import { withEnv } from '../helpers/with-env.ts';
|
||||
|
||||
const PASSTHROUGHS: Array<{ envVar: string; recipeId: string }> = [
|
||||
@@ -139,6 +139,105 @@ describe('buildGatewayConfig config-plane API-key folding', () => {
|
||||
expect(cfg.env.VOYAGE_API_KEY).toBe('pa-env-plane');
|
||||
});
|
||||
});
|
||||
|
||||
// #3500: dashscope_api_key was accepted at the file plane but never folded,
|
||||
// so the dashscope/dashscope-rerank recipes (required: DASHSCOPE_API_KEY)
|
||||
// could only be keyed via a process-env export.
|
||||
test('dashscope_api_key folds into gateway env as DASHSCOPE_API_KEY', async () => {
|
||||
await withEnv({ DASHSCOPE_API_KEY: undefined }, async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
dashscope_api_key: 'sk-ds-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.DASHSCOPE_API_KEY).toBe('sk-ds-config-plane');
|
||||
});
|
||||
});
|
||||
|
||||
test('a real DASHSCOPE_API_KEY process.env value wins over the config-plane fallback', async () => {
|
||||
await withEnv({ DASHSCOPE_API_KEY: 'sk-ds-env-plane' }, async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
dashscope_api_key: 'sk-ds-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.DASHSCOPE_API_KEY).toBe('sk-ds-env-plane');
|
||||
});
|
||||
});
|
||||
|
||||
// #3500: the google recipe reads GOOGLE_GENERATIVE_AI_API_KEY; before this
|
||||
// fold the ONLY configuration route was exporting that exact env name.
|
||||
test('google_api_key folds into gateway env as GOOGLE_GENERATIVE_AI_API_KEY', async () => {
|
||||
await withEnv(
|
||||
{ GOOGLE_GENERATIVE_AI_API_KEY: undefined, GEMINI_API_KEY: undefined },
|
||||
async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
google_api_key: 'AIza-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.GOOGLE_GENERATIVE_AI_API_KEY).toBe('AIza-config-plane');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Recurring-class guard: EVERY *_api_key field declared in
|
||||
// KNOWN_CONFIG_KEYS must reach the gateway env dict. Adding a new
|
||||
// provider key field to GBrainConfig without folding it in
|
||||
// buildGatewayConfig fails here — the #121/#2662/#3500 bug class.
|
||||
test('every KNOWN_CONFIG_KEYS *_api_key field reaches the gateway env', async () => {
|
||||
const keyFields = KNOWN_CONFIG_KEYS.filter((k) => k.endsWith('_api_key'));
|
||||
expect(keyFields.length).toBeGreaterThanOrEqual(7);
|
||||
for (const field of keyFields) {
|
||||
const sentinel = `sentinel-${field}`;
|
||||
// Clear the two env names the field could map to so config must win.
|
||||
await withEnv(
|
||||
{
|
||||
[field.replace(/_api_key$/, '').toUpperCase() + '_API_KEY']: undefined,
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: undefined,
|
||||
GEMINI_API_KEY: undefined,
|
||||
},
|
||||
async () => {
|
||||
const cfg = buildGatewayConfig({ [field]: sentinel } as unknown as GBrainConfig);
|
||||
expect(
|
||||
Object.values(cfg.env).includes(sentinel),
|
||||
`config field "${field}" never reaches the gateway env — add a fold in buildGatewayConfig`,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGatewayConfig GEMINI_API_KEY alias (#3500)', () => {
|
||||
// GEMINI_API_KEY is the env name Google's own docs and SDKs use; the
|
||||
// recipe/gateway read GOOGLE_GENERATIVE_AI_API_KEY. Precedence:
|
||||
// env GOOGLE_GENERATIVE_AI_API_KEY > env GEMINI_API_KEY > config google_api_key.
|
||||
test('GEMINI_API_KEY aliases to GOOGLE_GENERATIVE_AI_API_KEY', async () => {
|
||||
await withEnv(
|
||||
{ GOOGLE_GENERATIVE_AI_API_KEY: undefined, GEMINI_API_KEY: 'AIza-gemini-env' },
|
||||
async () => {
|
||||
const cfg = buildGatewayConfig(baseConfig);
|
||||
expect(cfg.env.GOOGLE_GENERATIVE_AI_API_KEY).toBe('AIza-gemini-env');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('canonical GOOGLE_GENERATIVE_AI_API_KEY env wins over the GEMINI_API_KEY alias', async () => {
|
||||
await withEnv(
|
||||
{ GOOGLE_GENERATIVE_AI_API_KEY: 'AIza-canonical', GEMINI_API_KEY: 'AIza-alias' },
|
||||
async () => {
|
||||
const cfg = buildGatewayConfig(baseConfig);
|
||||
expect(cfg.env.GOOGLE_GENERATIVE_AI_API_KEY).toBe('AIza-canonical');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('GEMINI_API_KEY (process env) wins over the config-plane google_api_key', async () => {
|
||||
await withEnv(
|
||||
{ GOOGLE_GENERATIVE_AI_API_KEY: undefined, GEMINI_API_KEY: 'AIza-gemini-env' },
|
||||
async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
google_api_key: 'AIza-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.GOOGLE_GENERATIVE_AI_API_KEY).toBe('AIza-gemini-env');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGatewayConfig env empty-string clobber guard (#1249)', () => {
|
||||
|
||||
@@ -63,9 +63,11 @@ describe('embeddingProviderConfigured (recipe-aware helper)', () => {
|
||||
// #2662: buildGatewayConfig now folds voyage_api_key → VOYAGE_API_KEY,
|
||||
// so this producer-facing map must recognize it as gateway-propagated.
|
||||
expect(HOSTED_EMBED_KEY_CONFIG.VOYAGE_API_KEY).toBe('voyage_api_key');
|
||||
// Not propagated to the gateway today → must NOT be backed by a config field
|
||||
// (producer closures fall through to process.env only for this one).
|
||||
expect(HOSTED_EMBED_KEY_CONFIG.GOOGLE_GENERATIVE_AI_API_KEY).toBeUndefined();
|
||||
// #3500: buildGatewayConfig now folds google_api_key and
|
||||
// dashscope_api_key, so both are gateway-propagated and must be mapped
|
||||
// (a config-plane key is genuinely usable by the gateway).
|
||||
expect(HOSTED_EMBED_KEY_CONFIG.GOOGLE_GENERATIVE_AI_API_KEY).toBe('google_api_key');
|
||||
expect(HOSTED_EMBED_KEY_CONFIG.DASHSCOPE_API_KEY).toBe('dashscope_api_key');
|
||||
});
|
||||
|
||||
// #2662: end-to-end regression through the REAL file-plane loader
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Consistency guard for the takes-quality DEFAULT_MODEL_PANEL — the sibling
|
||||
* of test/cross-modal-default-slots.test.ts (#3510).
|
||||
*
|
||||
* `google:gemini-1.5-pro` sat in the panel after Google retired it, and
|
||||
* `openai:gpt-4o` after the OpenAI recipe dropped it from its chat list —
|
||||
* either way the gateway rejects the slot on every default run. The guard
|
||||
* only works if recipes list LIVE models: removing a dead model from its
|
||||
* recipe makes every hardcoded default that still names it fail here at
|
||||
* once. Do not re-add retired models to a recipe to quiet this test.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { DEFAULT_MODEL_PANEL } from '../src/core/takes-quality-eval/runner.ts';
|
||||
import { getPricing } from '../src/core/takes-quality-eval/pricing.ts';
|
||||
import { getRecipe } from '../src/core/ai/recipes/index.ts';
|
||||
import { splitProviderModelId } from '../src/core/model-id.ts';
|
||||
import { canonicalLookup } from '../src/core/model-pricing.ts';
|
||||
|
||||
describe('takes-quality DEFAULT_MODEL_PANEL ↔ recipe consistency', () => {
|
||||
test('every default panel model is listed in its recipe chat touchpoint', () => {
|
||||
for (const id of DEFAULT_MODEL_PANEL) {
|
||||
const { provider, model } = splitProviderModelId(id);
|
||||
expect(provider).not.toBeNull();
|
||||
const recipe = getRecipe(provider!);
|
||||
expect(recipe, `unknown recipe "${provider}"`).toBeDefined();
|
||||
expect(
|
||||
recipe!.touchpoints.chat?.models ?? [],
|
||||
`"${model}" not listed for ${provider} chat — the default panel can never run`,
|
||||
).toContain(model);
|
||||
}
|
||||
});
|
||||
|
||||
test('every default panel model prices via canonical AND the takes-quality allowlist', () => {
|
||||
for (const id of DEFAULT_MODEL_PANEL) {
|
||||
expect(canonicalLookup(id), `"${id}" missing from CANONICAL_PRICING`).toBeDefined();
|
||||
// getPricing throws PricingNotFoundError if the model is missing from
|
||||
// SUPPORTED_MODELS — a default that can't be budget-gated aborts every
|
||||
// `--budget-usd` run before the first call.
|
||||
expect(getPricing(id)).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('panel spans three distinct providers (uncorrelated blind spots)', () => {
|
||||
const providers = new Set(DEFAULT_MODEL_PANEL.map((id) => splitProviderModelId(id).provider));
|
||||
expect(providers.size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ afterEach(() => {
|
||||
|
||||
function makeChatStub(scoresBySlot: Record<string, number[]>) {
|
||||
let callIdx = 0;
|
||||
const order = ['openai:gpt-4o', 'anthropic:claude-opus-4-7', 'google:gemini-1.5-pro'];
|
||||
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++;
|
||||
@@ -74,9 +74,9 @@ function makeChatStub(scoresBySlot: Record<string, number[]>) {
|
||||
describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
test('PASS: 3 happy responses, all dims >=7', async () => {
|
||||
const chatStub = makeChatStub({
|
||||
'openai:gpt-4o': [9, 8],
|
||||
'openai:gpt-5.2': [9, 8],
|
||||
'anthropic:claude-opus-4-7': [8, 7],
|
||||
'google:gemini-1.5-pro': [8, 8],
|
||||
'deepseek:deepseek-v4-pro': [8, 8],
|
||||
});
|
||||
mock.module('../../src/core/ai/gateway.ts', () => ({
|
||||
chat: chatStub,
|
||||
@@ -105,9 +105,9 @@ describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
|
||||
test('FAIL: one dim mean below 7', async () => {
|
||||
const chatStub = makeChatStub({
|
||||
'openai:gpt-4o': [9, 6],
|
||||
'openai:gpt-5.2': [9, 6],
|
||||
'anthropic:claude-opus-4-7': [8, 6],
|
||||
'google:gemini-1.5-pro': [8, 6],
|
||||
'deepseek:deepseek-v4-pro': [8, 6],
|
||||
});
|
||||
mock.module('../../src/core/ai/gateway.ts', () => ({
|
||||
chat: chatStub,
|
||||
@@ -130,9 +130,9 @@ describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
|
||||
test('FAIL: min-score floor caught when one model scores <5 (Q2)', async () => {
|
||||
const chatStub = makeChatStub({
|
||||
'openai:gpt-4o': [9, 8],
|
||||
'openai:gpt-5.2': [9, 8],
|
||||
'anthropic:claude-opus-4-7': [8, 8],
|
||||
'google:gemini-1.5-pro': [4, 8], // goal=4 trips the floor
|
||||
'deepseek:deepseek-v4-pro': [4, 8], // goal=4 trips the floor
|
||||
});
|
||||
mock.module('../../src/core/ai/gateway.ts', () => ({
|
||||
chat: chatStub,
|
||||
@@ -155,7 +155,7 @@ describe('gbrain eval cross-modal — runner verdict contract', () => {
|
||||
|
||||
test('INCONCLUSIVE: 2 of 3 mock 5xx -> exit 2 contract (Q3)', async () => {
|
||||
const chatStub = mock(async (opts: { model?: string }) => {
|
||||
if (opts.model === 'openai:gpt-4o') {
|
||||
if (opts.model === 'openai:gpt-5.2') {
|
||||
return {
|
||||
text: JSON.stringify({
|
||||
scores: { goal: { score: 8 } },
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
|
||||
describe('getPricing — fail-closed contract', () => {
|
||||
test('returns pricing for the default 3-model panel', () => {
|
||||
expect(getPricing('openai:gpt-4o')).toBeDefined();
|
||||
expect(getPricing('openai:gpt-5.2')).toBeDefined();
|
||||
expect(getPricing('anthropic:claude-opus-4-7')).toBeDefined();
|
||||
expect(getPricing('google:gemini-1.5-pro')).toBeDefined();
|
||||
expect(getPricing('google:gemini-2.0-flash')).toBeDefined();
|
||||
});
|
||||
|
||||
test('retired google:gemini-1.5-pro is no longer in the allowlist (#3510)', () => {
|
||||
expect(() => getPricing('google:gemini-1.5-pro')).toThrow(PricingNotFoundError);
|
||||
});
|
||||
|
||||
test('throws PricingNotFoundError on unknown model', () => {
|
||||
|
||||
@@ -176,8 +176,10 @@ describe('runner — budget cap (codex review #4)', () => {
|
||||
const r = await runEval(engine, {
|
||||
limit: 5,
|
||||
cycles: 3,
|
||||
models: ['openai:gpt-4o', 'anthropic:claude-opus-4-7', 'google:gemini-1.5-pro'],
|
||||
budgetUsd: 0.05, // tighter than projected per-cycle cost
|
||||
// gemini-2.0-flash (not the retired 1.5-pro) — the budget path
|
||||
// pre-flights getPricing, which is allowlist-gated (#3510).
|
||||
models: ['openai:gpt-4o', 'anthropic:claude-opus-4-7', 'google:gemini-2.0-flash'],
|
||||
budgetUsd: 0.05, // tighter than projected per-cycle cost (~$0.109)
|
||||
});
|
||||
// No cycle ever ran successfully because pre-flight aborted cycle 1.
|
||||
expect(r.budgetAborted).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user