diff --git a/apps/web/src/lib/ai-gateway/auto-model/index.ts b/apps/web/src/lib/ai-gateway/auto-model/index.ts index a34c852eb1..f5d03a0d07 100644 --- a/apps/web/src/lib/ai-gateway/auto-model/index.ts +++ b/apps/web/src/lib/ai-gateway/auto-model/index.ts @@ -149,17 +149,23 @@ export const KILO_AUTO_BALANCED_MODEL: AutoModel = { opencode_settings: undefined, }; +// INVARIANT: kilo-auto/small metadata must stay the lowest common denominator +// of every model it can resolve to (google/gemma-4-26b-a4b-it with balance, +// otherwise the kilo-auto/free rotation). ling-3.0-flash and laguna-s-2.1 are +// text-only, so supports_images must stay false; max_completion_tokens is +// bounded by gemma's 16384. Re-check all resolution targets before raising +// any of these values. export const KILO_AUTO_SMALL_MODEL: AutoModel = { id: 'kilo-auto/small', name: 'Auto Small', description: 'Automatically routes your request to a small model.', context_length: 262144, - max_completion_tokens: 32768, + max_completion_tokens: 16384, prompt_price: '0.00000005', completion_price: '0.0000004', input_cache_read_price: '0.000000005', input_cache_write_price: undefined, - supports_images: true, + supports_images: false, supports_pdf: false, opencode_settings: undefined, }; diff --git a/apps/web/src/lib/ai-gateway/auto-model/resolution.test.ts b/apps/web/src/lib/ai-gateway/auto-model/resolution.test.ts index 96062319e8..30d988f463 100644 --- a/apps/web/src/lib/ai-gateway/auto-model/resolution.test.ts +++ b/apps/web/src/lib/ai-gateway/auto-model/resolution.test.ts @@ -13,8 +13,12 @@ import { BALANCED_QWEN_MODEL, FRONTIER_MODE_TO_MODEL, KILO_AUTO_EFFICIENT_MODEL, + KILO_AUTO_FREE_MODEL, + KILO_AUTO_SMALL_MODEL, ORG_AUTO_MODEL, } from '@/lib/ai-gateway/auto-model'; +import { GEMMA_4_26B_A4B_IT_ID } from '@/lib/ai-gateway/providers/google'; +import { stepfun_37_flash_free_model } from '@/lib/ai-gateway/providers/stepfun'; import type { AutoRoutingDecision } from '@kilocode/auto-routing-contracts'; const baseParams = { @@ -386,6 +390,44 @@ describe('resolveAutoModel — kilo-auto/efficient branch', () => { }); }); +describe('resolveAutoModel — kilo-auto/small branch', () => { + const smallParams = { + ...baseParams, + model: KILO_AUTO_SMALL_MODEL.id, + apiKind: 'chat_completions' as const, + }; + + it('resolves to the paid Gemma model when the user has balance', async () => { + const result = await resolveAutoModel(smallParams, nullUserPromise, Promise.resolve(100)); + + expect(result).toEqual({ kind: 'ok', resolved: { model: GEMMA_4_26B_A4B_IT_ID } }); + }); + + it('falls back to the kilo-auto/free rotation when the user has no balance', async () => { + const params = { ...smallParams, sessionId: 'session-1' }; + const smallResult = await resolveAutoModel(params, nullUserPromise, zeroBalancePromise); + const freeResult = await resolveAutoModel( + { ...params, model: KILO_AUTO_FREE_MODEL.id }, + nullUserPromise, + zeroBalancePromise + ); + + expect(smallResult).toEqual(freeResult); + expect(smallResult.kind).toBe('ok'); + }); + + it('resolves to a free candidate when the user has no balance', async () => { + // The Redis mock returns no OpenRouter models, so the only candidate is + // the public Kilo-exclusive free model. + const result = await resolveAutoModel(smallParams, nullUserPromise, zeroBalancePromise); + + expect(result).toEqual({ + kind: 'ok', + resolved: { model: stepfun_37_flash_free_model.public_id }, + }); + }); +}); + describe('resolveAutoModel — Organization Auto branch', () => { it('uses exact built-in alias routes before canonical fallback routes', async () => { const result = await resolveAutoModel( diff --git a/apps/web/src/lib/ai-gateway/auto-model/resolution.ts b/apps/web/src/lib/ai-gateway/auto-model/resolution.ts index c82a69ccb7..bc1b4f7871 100644 --- a/apps/web/src/lib/ai-gateway/auto-model/resolution.ts +++ b/apps/web/src/lib/ai-gateway/auto-model/resolution.ts @@ -1,8 +1,5 @@ import type { FeatureValue } from '@/lib/feature-detection'; -import { - gemma_4_26b_a4b_it_free_model, - GEMMA_4_26B_A4B_IT_ID, -} from '@/lib/ai-gateway/providers/google'; +import { GEMMA_4_26B_A4B_IT_ID } from '@/lib/ai-gateway/providers/google'; import type { GatewayRequest, OpenRouterChatCompletionRequest, @@ -142,6 +139,23 @@ export type ResolveAutoModelResult = | { kind: 'no_free_models_available' } | { kind: 'organization_auto_configuration_error'; message: string }; +async function resolveFreeRotation( + apiKind: GatewayRequest['kind'] | null, + sessionId: string | null, + clientIp: string | null, + userPromise: Promise +): Promise { + const candidates = await getAutoFreeCandidates(apiKind); + if (candidates.length === 0) { + return { kind: 'no_free_models_available' }; + } + const randomNumber = getRandomNumber( + 'free_routing_' + (sessionId ?? (await userPromise)?.id ?? clientIp), + candidates.length + ); + return { kind: 'ok', resolved: { model: candidates[randomNumber] } }; +} + async function resolveOrganizationAutoModel( params: ResolveAutoModelParams, userPromise: Promise, @@ -282,26 +296,13 @@ export async function resolveAutoModel( return await resolveOrganizationAutoModel(params, userPromise, balancePromise); } if (model === KILO_AUTO_FREE_MODEL.id) { - const candidates = await getAutoFreeCandidates(apiKind); - if (candidates.length === 0) { - return { kind: 'no_free_models_available' }; - } - const randomNumber = getRandomNumber( - 'free_routing_' + (sessionId ?? (await userPromise)?.id ?? clientIp), - candidates.length - ); - return { kind: 'ok', resolved: { model: candidates[randomNumber] } }; + return resolveFreeRotation(apiKind, sessionId, clientIp, userPromise); } if (model === KILO_AUTO_SMALL_MODEL.id) { - return { - kind: 'ok', - resolved: { - model: - (await balancePromise) > 0 - ? GEMMA_4_26B_A4B_IT_ID - : gemma_4_26b_a4b_it_free_model.public_id, - }, - }; + if ((await balancePromise) > 0) { + return { kind: 'ok', resolved: { model: GEMMA_4_26B_A4B_IT_ID } }; + } + return resolveFreeRotation(apiKind, sessionId, clientIp, userPromise); } if (model === KILO_AUTO_EFFICIENT_MODEL.id) { const decision = params.efficientDecision ? await params.efficientDecision() : null; diff --git a/apps/web/src/lib/ai-gateway/context-overflow.test.ts b/apps/web/src/lib/ai-gateway/context-overflow.test.ts index fefde2f1f0..27076436ae 100644 --- a/apps/web/src/lib/ai-gateway/context-overflow.test.ts +++ b/apps/web/src/lib/ai-gateway/context-overflow.test.ts @@ -4,7 +4,7 @@ import type { GatewayRequest, OpenRouterChatCompletionRequest, } from '@/lib/ai-gateway/providers/openrouter/types'; -import { gemma_4_26b_a4b_it_free_model } from '@/lib/ai-gateway/providers/google'; +import { stepfun_37_flash_free_model } from '@/lib/ai-gateway/providers/stepfun'; import { ProxyErrorType } from '@/lib/proxy-error-types'; function chatRequest(body: OpenRouterChatCompletionRequest): GatewayRequest { @@ -158,16 +158,16 @@ describe('detectContextOverflow', () => { }); it('triggers on a generic 500 when our estimate exceeds the window', async () => { - // Gemma 4 has context_length 262_144 and max_completion_tokens 32_768. + // Step 3.7 Flash has context_length 262_144. // This request estimates to more than 282_000 tokens, exceeding the context window. const hugeRequest = chatRequest({ - model: gemma_4_26b_a4b_it_free_model.public_id, + model: stepfun_37_flash_free_model.public_id, messages: [{ role: 'user', content: 'x'.repeat(1_000_000) }], max_tokens: 32_768, }); const result = await detectContextOverflow({ - requestedModel: gemma_4_26b_a4b_it_free_model.public_id, + requestedModel: stepfun_37_flash_free_model.public_id, request: hugeRequest, response: new Response('Internal Server Error', { status: 500 }), }); @@ -180,12 +180,12 @@ describe('detectContextOverflow', () => { it('does not trigger on a 500 when the estimate fits the window', async () => { const smallRequest = chatRequest({ - model: gemma_4_26b_a4b_it_free_model.public_id, + model: stepfun_37_flash_free_model.public_id, messages: [{ role: 'user', content: 'hi' }], }); const result = await detectContextOverflow({ - requestedModel: gemma_4_26b_a4b_it_free_model.public_id, + requestedModel: stepfun_37_flash_free_model.public_id, request: smallRequest, response: new Response('Internal Server Error', { status: 500 }), }); diff --git a/apps/web/src/lib/ai-gateway/forbidden-free-models.ts b/apps/web/src/lib/ai-gateway/forbidden-free-models.ts index 43d57cedcb..5eab343937 100644 --- a/apps/web/src/lib/ai-gateway/forbidden-free-models.ts +++ b/apps/web/src/lib/ai-gateway/forbidden-free-models.ts @@ -19,7 +19,7 @@ const forbiddenFreeModelIds: ReadonlySet = new Set([ 'google/gemma-3-4b-it:free', 'google/gemma-3n-e2b-it:free', 'google/gemma-3n-e4b-it:free', - 'google/gemma-4-26b-a4b-it:free', // usable through kilo-auto + 'google/gemma-4-26b-a4b-it:free', 'google/gemma-4-31b-it:free', 'kilo/auto-free', // discontinued variant of kilo-auto/free 'kwaipilot/kat-coder-pro-v2.5:free', diff --git a/apps/web/src/lib/ai-gateway/models.ts b/apps/web/src/lib/ai-gateway/models.ts index 8183d813cc..1b97e6a2c0 100644 --- a/apps/web/src/lib/ai-gateway/models.ts +++ b/apps/web/src/lib/ai-gateway/models.ts @@ -21,7 +21,7 @@ import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusi import { isMuseModel } from '@/lib/ai-gateway/providers/meta'; import { MINIMAX_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/minimax'; import { KIMI_CURRENT_MODEL_ID } from '@/lib/ai-gateway/providers/moonshotai'; -import { gemma_4_26b_a4b_it_free_model, isGeminiModel } from '@/lib/ai-gateway/providers/google'; +import { isGeminiModel } from '@/lib/ai-gateway/providers/google'; import { QWEN37_PLUS_MODEL_ID, qwen36_plus_stealth_model } from '@/lib/ai-gateway/providers/qwen'; import { stepfun_37_flash_free_model } from '@/lib/ai-gateway/providers/stepfun'; import { isGrokModel } from '@/lib/ai-gateway/providers/xai'; @@ -85,7 +85,6 @@ export function isKiloExclusiveModel(model: string): boolean { } export const kiloExclusiveModels = [ - gemma_4_26b_a4b_it_free_model, ...deepseekDiscountedModels, qwen36_plus_stealth_model, gpt_5_6_sol_stealth_model, diff --git a/apps/web/src/lib/ai-gateway/providers/google.ts b/apps/web/src/lib/ai-gateway/providers/google.ts index 2f64042f94..0331b279aa 100644 --- a/apps/web/src/lib/ai-gateway/providers/google.ts +++ b/apps/web/src/lib/ai-gateway/providers/google.ts @@ -1,26 +1,9 @@ -import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; - export function isGemmaModel(model: string) { return model.includes('gemma'); } export const GEMMA_4_26B_A4B_IT_ID = 'google/gemma-4-26b-a4b-it'; -export const gemma_4_26b_a4b_it_free_model: KiloExclusiveModel = { - public_id: 'google/gemma-4-26b-a4b-it:free', - display_name: 'Google: Gemma 4 26B A4B (free)', - description: - 'Gemma 4 26B A4B IT is an instruction-tuned Mixture-of-Experts (MoE) model from Google DeepMind. Despite 25.2B total parameters, only 3.8B activate per token during inference — delivering near-31B quality at a fraction of the compute cost.', - context_length: 262144, - max_completion_tokens: 32768, - status: 'hidden', // usable through kilo-auto - flags: ['vision', 'vercel-routing'], - gateway: 'openrouter', - internal_id: GEMMA_4_26B_A4B_IT_ID, - pricing: null, - inference_provider_restriction: [], -}; - export function isGeminiModel(model: string) { return model.includes('gemini'); } diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts index 51e41180c5..097931df5c 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/index.test.ts @@ -8,7 +8,6 @@ import { import { createMockResponse, mockOpenRouterModels } from '@/tests/helpers/openrouter-models.helper'; import type { OpenRouterModel } from '@/lib/organizations/organization-types'; import { qwen36_plus_stealth_model } from '@/lib/ai-gateway/providers/qwen'; -import { gemma_4_26b_a4b_it_free_model } from '@/lib/ai-gateway/providers/google'; import { findKiloExclusiveModel, isDeadFreeModel, @@ -42,6 +41,15 @@ const disabledFreeModel = { pricing: null, } satisfies KiloExclusiveModel; +const hiddenFreeModel = { + ...qwen36_plus_stealth_model, + public_id: 'vendor/hidden-free-model', + internal_id: 'vendor/hidden-free-model-internal', + display_name: 'Hidden Free Kilo Model', + status: 'hidden', + pricing: null, +} satisfies KiloExclusiveModel; + function buildModel(overrides: Partial = {}): OpenRouterModel { return { id: 'vendor/model', @@ -192,8 +200,8 @@ describe('shouldSuppressOpenRouterModel', () => { }); it('suppresses hidden Kilo-exclusive models from OpenRouter', () => { - expect(gemma_4_26b_a4b_it_free_model.status).toBe('hidden'); - expect(shouldSuppressOpenRouterModel(gemma_4_26b_a4b_it_free_model)).toBe(true); + expect(hiddenFreeModel.status).toBe('hidden'); + expect(shouldSuppressOpenRouterModel(hiddenFreeModel)).toBe(true); }); }); diff --git a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts index 94f5bf194c..31546008b4 100644 --- a/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/vercel/mapModelIdToVercel.test.ts @@ -104,11 +104,9 @@ describe('mapModelIdToVercel', () => { describe('kilo-exclusive models', () => { it('maps an exclusive flagged with vercel-routing to its internal id', () => { - // google/gemma-4-26b-a4b-it:free is registered in kiloExclusiveModels - // with the 'vercel-routing' flag and internal_id 'google/gemma-4-26b-a4b-it'. - expect(mapModelIdToVercel('google/gemma-4-26b-a4b-it:free')).toBe( - 'google/gemma-4-26b-a4b-it' - ); + // stepfun/step-3.7-flash:free is registered in kiloExclusiveModels + // with the 'vercel-routing' flag and internal_id 'stepfun/step-3.7-flash'. + expect(mapModelIdToVercel('stepfun/step-3.7-flash:free')).toBe('stepfun/step-3.7-flash'); }); it('does not use internal_id for exclusives that are not vercel-routed', () => { diff --git a/apps/web/src/lib/rewriteModelResponse.test.ts b/apps/web/src/lib/rewriteModelResponse.test.ts index 64826224e1..fee2244e8d 100644 --- a/apps/web/src/lib/rewriteModelResponse.test.ts +++ b/apps/web/src/lib/rewriteModelResponse.test.ts @@ -8,6 +8,7 @@ import { } from './rewriteModelResponse'; import { isDynamicallyOptedIntoRequestLogging } from '@/lib/ai-gateway/request-logging-opt-ins'; import { QWEN37_PLUS_MODEL_ID } from '@/lib/ai-gateway/custom-pricing'; +import { stepfun_37_flash_free_model } from '@/lib/ai-gateway/providers/stepfun'; import { KILO_ORGANIZATION_ID } from '@/lib/organizations/constants'; import { logExceptInTest } from '@/lib/utils.server'; @@ -815,10 +816,10 @@ describe('rewriteModelResponse', () => { test('continues stripping cost for free models outside the Kilo organization', async () => { const result = await rewriteModelResponse( jsonResponse({ - model: 'google/gemma-4-26b-a4b-it:free', + model: stepfun_37_flash_free_model.public_id, usage: { cost: 0, is_byok: false }, }), - 'google/gemma-4-26b-a4b-it:free', + stepfun_37_flash_free_model.public_id, 'openrouter', 'chat_completions', makeLogging() @@ -826,7 +827,7 @@ describe('rewriteModelResponse', () => { expect(result).not.toBeNull(); expect(await result?.json()).toEqual({ - model: 'google/gemma-4-26b-a4b-it:free', + model: stepfun_37_flash_free_model.public_id, usage: {}, }); });