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:
Garry Tan
2026-07-28 13:11:24 -07:00
co-authored by Claude Opus 5
parent 6920744dd8
commit 398b17aa30
19 changed files with 263 additions and 44 deletions
+100 -1
View File
@@ -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)', () => {
+5 -3
View File
@@ -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
+48
View File
@@ -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);
});
});
+8 -8
View File
@@ -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 } },
+6 -2
View File
@@ -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);