diff --git a/src/core/ai/recipes/google.ts b/src/core/ai/recipes/google.ts index fb9ef8ece..4f66fa9de 100644 --- a/src/core/ai/recipes/google.ts +++ b/src/core/ai/recipes/google.ts @@ -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'], diff --git a/src/core/ai/recipes/index.ts b/src/core/ai/recipes/index.ts index 49175f2ca..ccdf487d7 100644 --- a/src/core/ai/recipes/index.ts +++ b/src/core/ai/recipes/index.ts @@ -59,10 +59,24 @@ const ALL: Recipe[] = [ /** Map from `provider:id` key to recipe. */ export const RECIPES: Map = 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]; } diff --git a/test/ai/adaptive-embed-batch.test.ts b/test/ai/adaptive-embed-batch.test.ts index 144668eca..d5074759c 100644 --- a/test/ai/adaptive-embed-batch.test.ts +++ b/test/ai/adaptive-embed-batch.test.ts @@ -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(); }); }); diff --git a/test/ai/no-batch-cap-suppression.serial.test.ts b/test/ai/no-batch-cap-suppression.serial.test.ts index fe52f4d52..f530aac20 100644 --- a/test/ai/no-batch-cap-suppression.serial.test.ts +++ b/test/ai/no-batch-cap-suppression.serial.test.ts @@ -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; @@ -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', () => {