mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
feat(recipes): declare Gemini embedding batch-token budget (#3651)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar. The google embedding recipe declared no batch caps, so it rode the no-cap fast path with error backstops shaped for Voyage and OpenAI. Caps verified by behavioral probe — 40 texts split into 3 sub-batches matching the declared math. Sequenced after #3531, which touched the same recipe file. Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN with 22/22 checks on the current base after batches 1-3 landed. Known gap, recorded rather than hidden: Gemini's actual 20k limit was taken from vendor docs rather than a live call; being wrong in either direction is bounded by the cap itself.
This commit is contained in:
@@ -16,6 +16,17 @@ export const google: Recipe = {
|
||||
dims_options: [768, 1536, 3072],
|
||||
cost_per_1m_tokens_usd: 0.15,
|
||||
price_last_verified: '2026-04-20',
|
||||
// Gemini's embedding endpoint has a low per-request cap relative to
|
||||
// Voyage. Declaring max_batch_tokens makes the gateway pre-split bulk
|
||||
// batches proactively (splitByTokenBudget) instead of relying solely on
|
||||
// the recursive-halving retry on a token-limit rejection. Conservative
|
||||
// value: each gemini-embedding-001 input tops out at 2048 tokens, so a
|
||||
// 20k budget × 0.8 safety keeps a batch well within request limits while
|
||||
// staying efficient. chars_per_token ~4 matches Gemini's SentencePiece
|
||||
// density on English. Tunable; recursion stays the backstop.
|
||||
max_batch_tokens: 20_000,
|
||||
chars_per_token: 4,
|
||||
safety_factor: 0.8,
|
||||
},
|
||||
expansion: {
|
||||
models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite'],
|
||||
|
||||
@@ -59,10 +59,24 @@ const ALL: Recipe[] = [
|
||||
/** Map from `provider:id` key to recipe. */
|
||||
export const RECIPES: Map<string, Recipe> = new Map(ALL.map(r => [r.id, r]));
|
||||
|
||||
/**
|
||||
* Test-only seam. Synthetic recipes appended to the registry so tests can
|
||||
* exercise registry-walking logic — notably gateway.ts's missing-batch-cap
|
||||
* startup warning — against a recipe that intentionally omits a field,
|
||||
* without editing the shipped `ALL` array. Every real embedding recipe now
|
||||
* declares a cap (token budget, `no_batch_cap`, or item cap), so a synthetic
|
||||
* cap-less recipe is the only way to cover the warn-fires path. Empty in
|
||||
* production (nothing in `src/` calls the setter); pass `[]` to reset.
|
||||
*/
|
||||
let _testRecipes: Recipe[] = [];
|
||||
export function __setTestRecipesForTests(recipes: Recipe[]): void {
|
||||
_testRecipes = recipes;
|
||||
}
|
||||
|
||||
export function getRecipe(id: string): Recipe | undefined {
|
||||
return RECIPES.get(id);
|
||||
return RECIPES.get(id) ?? _testRecipes.find(r => r.id === id);
|
||||
}
|
||||
|
||||
export function listRecipes(): Recipe[] {
|
||||
return [...ALL];
|
||||
return _testRecipes.length > 0 ? [...ALL, ..._testRecipes] : [...ALL];
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
__getShrinkStateForTests,
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
import { AIConfigError, AITransientError } from '../../src/core/ai/errors.ts';
|
||||
import { __setTestRecipesForTests } from '../../src/core/ai/recipes/index.ts';
|
||||
import type { Recipe } from '../../src/core/ai/types.ts';
|
||||
|
||||
// The last test in this file leaves the gateway configured with a remote
|
||||
// provider + fake key and a REAL embed transport. Without a final reset,
|
||||
@@ -94,6 +96,31 @@ function configureGoogle(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// A recipe that declares an embedding touchpoint but omits every batch cap.
|
||||
// Every shipped recipe now declares one (google gained max_batch_tokens), so
|
||||
// the startup warning is exercised against this synthetic cap-less recipe —
|
||||
// injected into the registry only for the duration of the test that needs it.
|
||||
const CAPLESS_RECIPE: Recipe = {
|
||||
id: 'synthetic-capless',
|
||||
name: 'Synthetic cap-less (test fixture)',
|
||||
tier: 'openai-compat',
|
||||
implementation: 'openai-compatible',
|
||||
touchpoints: {
|
||||
embedding: {
|
||||
models: ['synthetic-embed-1'],
|
||||
default_dims: 768,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function configureCapless(): void {
|
||||
configureGateway({
|
||||
embedding_model: 'synthetic-capless:synthetic-embed-1',
|
||||
embedding_dimensions: 768,
|
||||
env: {},
|
||||
});
|
||||
}
|
||||
|
||||
// --------- 1. Pure helpers ---------
|
||||
|
||||
describe('splitByTokenBudget (pure helper)', () => {
|
||||
@@ -429,20 +456,22 @@ describe('startup warning for recipes missing max_batch_tokens', () => {
|
||||
beforeEach(() => resetGateway());
|
||||
|
||||
test('configured missing-cap recipe warns once; unrelated recipes stay quiet', () => {
|
||||
__setTestRecipesForTests([CAPLESS_RECIPE]);
|
||||
const warnings: string[] = [];
|
||||
const original = console.warn;
|
||||
console.warn = (msg: string) => warnings.push(String(msg));
|
||||
try {
|
||||
configureOpenAI();
|
||||
expect(warnings.length).toBe(0);
|
||||
configureGoogle();
|
||||
configureCapless();
|
||||
const firstCallCount = warnings.length;
|
||||
// Reconfigure: the warning should NOT re-fire for the same recipes
|
||||
// Reconfigure: the warning should NOT re-fire for the same recipe
|
||||
// within one process (we already told the operator).
|
||||
configureGoogle();
|
||||
configureCapless();
|
||||
expect(warnings.length).toBe(firstCallCount);
|
||||
} finally {
|
||||
console.warn = original;
|
||||
__setTestRecipesForTests([]);
|
||||
}
|
||||
|
||||
// The warning text should match the documented contract.
|
||||
@@ -451,11 +480,12 @@ describe('startup warning for recipes missing max_batch_tokens', () => {
|
||||
);
|
||||
expect(contractMatch.length).toBe(1);
|
||||
|
||||
// Voyage declares max_batch_tokens → suppressed. OpenAI is the
|
||||
// canonical fast-path recipe → also suppressed by id. Both must be
|
||||
// absent from the warnings.
|
||||
// Voyage + google declare max_batch_tokens → suppressed. OpenAI is the
|
||||
// canonical fast-path recipe → also suppressed by id. Only the synthetic
|
||||
// cap-less recipe warns.
|
||||
expect(warnings.find(w => w.includes('"voyage"'))).toBeUndefined();
|
||||
expect(warnings.find(w => w.includes('"openai"'))).toBeUndefined();
|
||||
expect(warnings.find(w => w.includes('"google"'))).toBeDefined();
|
||||
expect(warnings.find(w => w.includes('"google"'))).toBeUndefined();
|
||||
expect(warnings.find(w => w.includes('"synthetic-capless"'))).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,28 @@
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test';
|
||||
import { capBatchItems, configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
|
||||
import { listRecipes, getRecipe } from '../../src/core/ai/recipes/index.ts';
|
||||
import { listRecipes, getRecipe, __setTestRecipesForTests } from '../../src/core/ai/recipes/index.ts';
|
||||
import type { Recipe } from '../../src/core/ai/types.ts';
|
||||
|
||||
/**
|
||||
* A recipe that declares an embedding touchpoint but omits every batch cap
|
||||
* (no max_batch_tokens, no no_batch_cap, no max_batch_items). This is the
|
||||
* exact shape a future provider PR might forget — the case the startup
|
||||
* warning exists to catch. Kept synthetic because every shipped recipe now
|
||||
* declares a cap, so no real recipe can play this role anymore.
|
||||
*/
|
||||
const CAPLESS_RECIPE: Recipe = {
|
||||
id: 'synthetic-capless',
|
||||
name: 'Synthetic cap-less (test fixture)',
|
||||
tier: 'openai-compat',
|
||||
implementation: 'openai-compatible',
|
||||
touchpoints: {
|
||||
embedding: {
|
||||
models: ['synthetic-embed-1'],
|
||||
default_dims: 768,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warning', () => {
|
||||
let warnSpy: ReturnType<typeof mock>;
|
||||
@@ -75,7 +96,12 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
|
||||
}
|
||||
});
|
||||
|
||||
test('configureGateway warns for google only when google embedding is configured', () => {
|
||||
test('google no longer warns — it now declares max_batch_tokens', () => {
|
||||
// google's gemini-embedding endpoint ships a declared batch-token budget,
|
||||
// so configuring it must NOT trip the missing-cap warning.
|
||||
const r = getRecipe('google');
|
||||
expect(r?.touchpoints.embedding?.max_batch_tokens).toBeGreaterThan(0);
|
||||
|
||||
warnSpy.mockClear();
|
||||
resetGateway();
|
||||
configureGateway({ env: {} });
|
||||
@@ -95,8 +121,33 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
|
||||
messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
|
||||
expect(
|
||||
messages.some(m => m.includes('"google"') && m.includes('without max_batch_tokens')),
|
||||
'google should warn when configured because it has fixed-cap models',
|
||||
).toBe(true);
|
||||
'google now declares a cap and must stay quiet even when configured',
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('a configured recipe that omits every batch cap still warns', () => {
|
||||
// Regression guard the google fixture used to provide. Every shipped
|
||||
// embedding recipe now declares a cap, so the warn-fires path is exercised
|
||||
// with a synthetic cap-less recipe injected into the registry.
|
||||
__setTestRecipesForTests([CAPLESS_RECIPE]);
|
||||
try {
|
||||
warnSpy.mockClear();
|
||||
resetGateway();
|
||||
configureGateway({
|
||||
embedding_model: 'synthetic-capless:synthetic-embed-1',
|
||||
embedding_dimensions: 768,
|
||||
env: {},
|
||||
});
|
||||
const messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
|
||||
expect(
|
||||
messages.some(
|
||||
m => m.includes('"synthetic-capless"') && m.includes('without max_batch_tokens'),
|
||||
),
|
||||
'a configured recipe missing every batch cap must warn',
|
||||
).toBe(true);
|
||||
} finally {
|
||||
__setTestRecipesForTests([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('every recipe with empty models[] declares user_provided_models OR has openai-fast-path', () => {
|
||||
|
||||
Reference in New Issue
Block a user