Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions apps/web/src/lib/ai-gateway/auto-model/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ import {
ORG_AUTO_MODEL,
} from '@/lib/ai-gateway/auto-model';
import { userIsWithinFirstKiloClawInstanceWindow } from '@/lib/kiloclaw/setup-promo';
import { getRandomNumber } from '@/lib/ai-gateway/getRandomNumber';
import {
autoFreeModels,
findKiloExclusiveModel,
isKiloExclusiveFreeModel,
selectAutoFreeModel,
} from '@/lib/ai-gateway/models';
import { getOpenRouterModelsFromRedis } from '@/lib/ai-gateway/providers/gateway-models-cache';
import PROVIDERS from '@/lib/ai-gateway/providers/provider-definitions';
Expand Down Expand Up @@ -84,7 +84,7 @@ export async function getAutoFreeCandidates(
): Promise<ReadonlyArray<string>> {
const openRouterModels = await getOpenRouterModelsFromRedis();
const candidates = new Set<string>();
for (const model of autoFreeModels) {
for (const { model } of autoFreeModels) {
if (isKiloExclusiveFreeModel(model)) {
const kiloModel = findKiloExclusiveModel(model);
if (kiloModel && gatewaySupportsApiKind(kiloModel.gateway, apiKind)) {
Expand Down Expand Up @@ -286,11 +286,16 @@ export async function resolveAutoModel(
if (candidates.length === 0) {
return { kind: 'no_free_models_available' };
}
const randomNumber = getRandomNumber(
'free_routing_' + (sessionId ?? (await userPromise)?.id ?? clientIp),
candidates.length
const candidateIds = new Set(candidates);
const selectedModel = selectAutoFreeModel(
autoFreeModels
.filter(candidate => candidateIds.has(candidate.model))
.toSorted((a, b) => a.model.localeCompare(b.model)),
'free_routing_' + (sessionId ?? (await userPromise)?.id ?? clientIp)
);
return { kind: 'ok', resolved: { model: candidates[randomNumber] } };
return selectedModel
? { kind: 'ok', resolved: { model: selectedModel } }
: { kind: 'no_free_models_available' };
}
if (model === KILO_AUTO_SMALL_MODEL.id) {
return {
Expand Down
32 changes: 29 additions & 3 deletions apps/web/src/lib/ai-gateway/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
findKiloExclusiveModel,
isKiloExclusiveRateLimitedModel,
kiloExclusiveModels,
selectAutoFreeModel,
} from './models';
import { hasBestEffortGuessDataCollectionRequirement, isFreeModel } from './is-free-model';
import { getInferenceProvider } from './providers/kilo-exclusive-model';
Expand All @@ -16,6 +17,7 @@ import {
import { gpt_5_6_sol_stealth_model } from './providers/openai-exclusive';
import { tencent_hy3_free_model } from './providers/tencent';
import { gemma_4_26b_a4b_it_free_model } from './providers/google';
import { getRandomNumber } from './getRandomNumber';

describe('rate-limited Kilo-exclusive models', () => {
test('only includes free Gemma', () => {
Expand Down Expand Up @@ -77,7 +79,9 @@ describe('isFreeModel', () => {
expect(findKiloExclusiveModel('tencent/hy3:free')).toBe(tencent_hy3_free_model);
expect(tencent_hy3_free_model.internal_id).toBe('tencent/hy3');
expect(tencent_hy3_free_model.inference_provider_restriction).toEqual(['tencent']);
expect(autoFreeModels).not.toContain(tencent_hy3_free_model.public_id);
expect(autoFreeModels.map(({ model }) => model)).not.toContain(
tencent_hy3_free_model.public_id
);
});

test('routes the discounted Claude Opus offering through the stealth provider identity', () => {
Expand Down Expand Up @@ -144,14 +148,36 @@ describe('isFreeModel', () => {

test('all autoFreeModels should pass isFreeModel', async () => {
expect(autoFreeModels.length).toBeGreaterThan(0);
for (const model of autoFreeModels) {
for (const { model } of autoFreeModels) {
expect(await isFreeModel(model)).toBe(true);
}
});

test('all autoFreeModels should have positive integer weights', () => {
for (const { weight } of autoFreeModels) {
expect(Number.isInteger(weight)).toBe(true);
expect(weight).toBeGreaterThan(0);
}
});

test('uses autoFreeModels weights when selecting a model', () => {
const candidates = [
{ model: 'preferred/model', weight: 3 },
{ model: 'other/model', weight: 1 },
];
const randomSeed = Array.from({ length: 100 }, (_, index) => `weight-test-${index}`).find(
seed => getRandomNumber(seed, 4) === 1
);
expect(randomSeed).toBeDefined();
if (!randomSeed) return;

expect(getRandomNumber(randomSeed, 4)).toBe(1);
expect(selectAutoFreeModel(candidates, randomSeed)).toBe('preferred/model');
});

test('all autoFreeModels should use the same AI SDK provider', () => {
expect(autoFreeModels.length).toBeGreaterThan(0);
const providers = new Set(autoFreeModels.map(model => getAiSdkProvider(model, null)));
const providers = new Set(autoFreeModels.map(({ model }) => getAiSdkProvider(model, null)));
expect(providers.size).toBe(1);
});

Expand Down
28 changes: 23 additions & 5 deletions apps/web/src/lib/ai-gateway/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,40 @@ import {
deepseekDiscountedModels,
} from '@/lib/ai-gateway/providers/deepseek';
import { type ProviderId } from '@/lib/ai-gateway/providers/types';
import { getRandomNumber } from '@/lib/ai-gateway/getRandomNumber';

export const PRIMARY_DEFAULT_MODEL = CLAUDE_SONNET_CURRENT_MODEL_ID;

export type AutoFreeModel = { model: string; weight: number };

export const autoFreeModels = [
stepfun_37_flash_free_model.status === 'public' ? stepfun_37_flash_free_model.public_id : null,
'inclusionai/ling-3.0-flash:free',
'poolside/laguna-s-2.1:free',
].filter(m => m !== null);
...(stepfun_37_flash_free_model.status === 'public'
? [{ model: stepfun_37_flash_free_model.public_id, weight: 1 }]
: []),
{ model: 'inclusionai/ling-3.0-flash:free', weight: 1 },
{ model: 'poolside/laguna-s-2.1:free', weight: 1 },
] satisfies ReadonlyArray<AutoFreeModel>;

export function selectAutoFreeModel(candidates: ReadonlyArray<AutoFreeModel>, randomSeed: string) {
const totalWeight = candidates.reduce((total, candidate) => total + candidate.weight, 0);
if (totalWeight === 0) return null;

const bucket = getRandomNumber(randomSeed, totalWeight);
let cumulativeWeight = 0;
for (const candidate of candidates) {
cumulativeWeight += candidate.weight;
if (bucket < cumulativeWeight) return candidate.model;
}
return null;
}

export const preferredModels = [
KILO_AUTO_FRONTIER_MODEL.id,
KILO_AUTO_BALANCED_MODEL.id,
KILO_AUTO_EFFICIENT_MODEL.id,
KILO_AUTO_FREE_MODEL.id,

...autoFreeModels,
...autoFreeModels.map(({ model }) => model),
...(tencent_hy3_free_model.status === 'public' ? [tencent_hy3_free_model.public_id] : []),

CLAUDE_SONNET_CURRENT_MODEL_ID,
Expand Down