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
2 changes: 1 addition & 1 deletion apps/web/src/app/api/fim/completions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export async function POST(request: NextRequest) {
// slight replication lag, and provides lower latency for US users
const { balance, settings, plan } = await getBalanceAndOrgSettings(organizationId, user, readDb);

if (balance <= 0 && !isFreeModel(requestBody.model) && !userByok) {
if (balance <= 0 && !(await isFreeModel(requestBody.model)) && !userByok) {
return NextResponse.json(
{
error: { message: 'Insufficient credits' },
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno

if (authFailedResponse) {
// No valid auth
if (!isFreeModel(originalModelIdLowerCased)) {
if (!(await isFreeModel(originalModelIdLowerCased))) {
// Paid model requires authentication
return NextResponse.json(
{
Expand Down Expand Up @@ -469,7 +469,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
if (!isAnonymousContext(user) && !bypassAccessCheck) {
const { balance, settings, plan } = await balanceAndSettingsPromise;

if (balance <= 0 && !isFreeModel(originalModelIdLowerCased) && !userByok) {
if (balance <= 0 && !(await isFreeModel(originalModelIdLowerCased)) && !userByok) {
return await usageLimitExceededResponse(user, balance);
}

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/openrouter/embeddings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
const tokenSource: string | undefined = authTokenSource;

if (authFailedResponse) {
if (!isFreeModel(requestedModelLowerCased)) {
if (!(await isFreeModel(requestedModelLowerCased))) {
return NextResponse.json(
{
error: {
Expand Down Expand Up @@ -199,7 +199,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
if (!isAnonymousContext(user)) {
const { balance, settings, plan } = await getBalanceAndOrgSettings(organizationId, user);

if (balance <= 0 && !isFreeModel(requestedModelLowerCased) && !userByok) {
if (balance <= 0 && !(await isFreeModel(requestedModelLowerCased)) && !userByok) {
return await usageLimitExceededResponse(user, balance);
}

Expand Down
110 changes: 55 additions & 55 deletions apps/web/src/lib/ai-gateway/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,25 @@ import { getInferenceProvider } from './providers/kilo-exclusive-model';

describe('isFreeModel', () => {
describe('free models', () => {
test('should return true for models ending with :free', () => {
expect(isFreeModel('gpt-4:free')).toBe(true);
expect(isFreeModel('claude-3:free')).toBe(true);
expect(isFreeModel('some-model:free')).toBe(true);
expect(isFreeModel(':free')).toBe(true);
test('should return true for models ending with :free', async () => {
expect(await isFreeModel('gpt-4:free')).toBe(true);
expect(await isFreeModel('claude-3:free')).toBe(true);
expect(await isFreeModel('some-model:free')).toBe(true);
expect(await isFreeModel(':free')).toBe(true);
});

test('should return true for openrouter/free', () => {
expect(isFreeModel('openrouter/free')).toBe(true);
test('should return true for openrouter/free', async () => {
expect(await isFreeModel('openrouter/free')).toBe(true);
});

test('should return true for OpenRouter stealth models (alpha/beta)', () => {
expect(isFreeModel('openrouter/model-alpha')).toBe(true);
expect(isFreeModel('openrouter/model-beta')).toBe(true);
expect(isFreeModel('openrouter/sonoma-dusk-alpha')).toBe(true);
expect(isFreeModel('openrouter/sonoma-sky-beta')).toBe(true);
test('should return true for OpenRouter stealth models (alpha/beta)', async () => {
expect(await isFreeModel('openrouter/model-alpha')).toBe(true);
expect(await isFreeModel('openrouter/model-beta')).toBe(true);
expect(await isFreeModel('openrouter/sonoma-dusk-alpha')).toBe(true);
expect(await isFreeModel('openrouter/sonoma-sky-beta')).toBe(true);
});

test('should return true for enabled Kilo exclusive models with no pricing', () => {
test('should return true for enabled Kilo exclusive models with no pricing', async () => {
// Test with known Kilo exclusive models that are enabled and have no pricing (free)
const enabledFreeModels = kiloExclusiveModels.filter(
m => m.status === 'public' && !m.pricing
Expand All @@ -33,16 +33,16 @@ describe('isFreeModel', () => {

// All enabled free models should be detected as free
for (const model of enabledFreeModels) {
expect(isFreeModel(model.public_id)).toBe(true);
expect(await isFreeModel(model.public_id)).toBe(true);
}
});

test('should return false for enabled Kilo exclusive models with pricing', () => {
test('should return false for enabled Kilo exclusive models with pricing', async () => {
// Models with pricing should NOT be free
const pricedModels = kiloExclusiveModels.filter(m => m.status !== 'disabled' && !!m.pricing);

for (const model of pricedModels) {
expect(isFreeModel(model.public_id)).toBe(false);
expect(await isFreeModel(model.public_id)).toBe(false);
}
});

Expand All @@ -64,87 +64,87 @@ describe('isFreeModel', () => {
}
});

test('should return false for disabled Kilo exclusive models that do not end with :free', () => {
test('should return false for disabled Kilo exclusive models that do not end with :free', async () => {
const disabledModels = kiloExclusiveModels.filter(
m => m.status === 'disabled' && !m.public_id.endsWith(':free')
);

// Disabled models without :free suffix should NOT be detected as free
for (const model of disabledModels) {
expect(isFreeModel(model.public_id)).toBe(false);
expect(await isFreeModel(model.public_id)).toBe(false);
}
});

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

test('should return true for disabled Kilo exclusive models that end with :free', () => {
test('should return true for disabled Kilo exclusive models that end with :free', async () => {
const disabledModelsWithFreeSuffix = kiloExclusiveModels.filter(
m => m.status === 'disabled' && m.public_id.endsWith(':free')
);

// Disabled models with :free suffix are still considered free due to the :free suffix rule
// This is the current behavior - the :free suffix takes precedence over the enabled state
for (const model of disabledModelsWithFreeSuffix) {
expect(isFreeModel(model.public_id)).toBe(true);
expect(await isFreeModel(model.public_id)).toBe(true);
}
});
});

describe('non-free models', () => {
test('should return false for regular model names', () => {
expect(isFreeModel('gpt-4')).toBe(false);
expect(isFreeModel('claude-3.7-sonnet')).toBe(false);
expect(isFreeModel('anthropic/claude-sonnet-4')).toBe(false);
expect(isFreeModel('google/gemini-2.5-pro')).toBe(false);
test('should return false for regular model names', async () => {
expect(await isFreeModel('gpt-4')).toBe(false);
expect(await isFreeModel('claude-3.7-sonnet')).toBe(false);
expect(await isFreeModel('anthropic/claude-sonnet-4')).toBe(false);
expect(await isFreeModel('google/gemini-2.5-pro')).toBe(false);
});

test('should return false for models with "free" in the middle', () => {
expect(isFreeModel('free-model')).toBe(false);
expect(isFreeModel('model-free-version')).toBe(false);
expect(isFreeModel('freemium')).toBe(false);
test('should return false for models with "free" in the middle', async () => {
expect(await isFreeModel('free-model')).toBe(false);
expect(await isFreeModel('model-free-version')).toBe(false);
expect(await isFreeModel('freemium')).toBe(false);
});

test('should return false for OpenRouter models that do not end with -alpha or -beta', () => {
expect(isFreeModel('openrouter/model')).toBe(false);
expect(isFreeModel('openrouter/model-gamma')).toBe(false);
expect(isFreeModel('openrouter/model-stable')).toBe(false);
test('should return false for OpenRouter models that do not end with -alpha or -beta', async () => {
expect(await isFreeModel('openrouter/model')).toBe(false);
expect(await isFreeModel('openrouter/model-gamma')).toBe(false);
expect(await isFreeModel('openrouter/model-stable')).toBe(false);
});

test('should return false for non-OpenRouter models ending with -alpha or -beta', () => {
expect(isFreeModel('anthropic/model-alpha')).toBe(false);
expect(isFreeModel('google/model-beta')).toBe(false);
expect(isFreeModel('model-alpha')).toBe(false);
test('should return false for non-OpenRouter models ending with -alpha or -beta', async () => {
expect(await isFreeModel('anthropic/model-alpha')).toBe(false);
expect(await isFreeModel('google/model-beta')).toBe(false);
expect(await isFreeModel('model-alpha')).toBe(false);
});
});

describe('edge cases', () => {
test('should return false for empty string', () => {
expect(isFreeModel('')).toBe(false);
test('should return false for empty string', async () => {
expect(await isFreeModel('')).toBe(false);
});

test('should return false for null/undefined', () => {
expect(isFreeModel(null as unknown as string)).toBe(false);
expect(isFreeModel(undefined as unknown as string)).toBe(false);
test('should return false for null/undefined', async () => {
expect(await isFreeModel(null as unknown as string)).toBe(false);
expect(await isFreeModel(undefined as unknown as string)).toBe(false);
});

test('should be case-sensitive', () => {
expect(isFreeModel('model:FREE')).toBe(false);
expect(isFreeModel('model:Free')).toBe(false);
expect(isFreeModel('OPENROUTER/FREE')).toBe(false);
expect(isFreeModel('openrouter/model-ALPHA')).toBe(false);
test('should be case-sensitive', async () => {
expect(await isFreeModel('model:FREE')).toBe(false);
expect(await isFreeModel('model:Free')).toBe(false);
expect(await isFreeModel('OPENROUTER/FREE')).toBe(false);
expect(await isFreeModel('openrouter/model-ALPHA')).toBe(false);
});

test('should handle whitespace correctly', () => {
expect(isFreeModel('model:free ')).toBe(false);
expect(isFreeModel(' model:free')).toBe(true);
expect(isFreeModel(' openrouter/free')).toBe(false);
expect(isFreeModel('openrouter/free ')).toBe(false);
expect(isFreeModel('openrouter/model-alpha ')).toBe(false);
test('should handle whitespace correctly', async () => {
expect(await isFreeModel('model:free ')).toBe(false);
expect(await isFreeModel(' model:free')).toBe(true);
expect(await isFreeModel(' openrouter/free')).toBe(false);
expect(await isFreeModel('openrouter/free ')).toBe(false);
expect(await isFreeModel('openrouter/model-alpha ')).toBe(false);
});
});
});
2 changes: 1 addition & 1 deletion apps/web/src/lib/ai-gateway/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const preferredModels = [
GLM_CURRENT_MODEL_ID,
];

export function isFreeModel(model: string): boolean {
export async function isFreeModel(model: string): Promise<boolean> {
return (
isKiloExclusiveFreeModel(model) ||
model === KILO_AUTO_FREE_MODEL.id ||
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/lib/ai-gateway/processUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,7 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => {
...overrides,
}) satisfies MicrodollarUsageContext;

test('strips NUL bytes from body- and upstream-sourced string fields', () => {
test('strips NUL bytes from body- and upstream-sourced string fields', async () => {
// Fields whose values cross the CTE insert as individual SQL parameters.
// A NUL byte in any of them crashes the insert with Postgres 22021 --
// see KILOCODE-WEB-1G3Z.
Expand All @@ -973,7 +973,7 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => {
},
});

const { core, metadata } = toInsertableDbUsageRecord(
const { core, metadata } = await toInsertableDbUsageRecord(
usageStats,
extractUsageContextInfo(usageContext)
);
Expand All @@ -999,7 +999,7 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => {
}
});

test('strips NUL bytes from a directly-constructed fraudHeaders object', () => {
test('strips NUL bytes from a directly-constructed fraudHeaders object', async () => {
// Bypass Node's Headers validation to exercise the defensive coverage of
// HTTP-header-sourced fields. This documents that IF a NUL byte ever
// reaches these fields (e.g. through a future upstream change), the
Expand All @@ -1016,7 +1016,7 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => {
},
});

const { metadata } = toInsertableDbUsageRecord(
const { metadata } = await toInsertableDbUsageRecord(
baseUsageStats,
extractUsageContextInfo(usageContext)
);
Expand All @@ -1028,8 +1028,8 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => {
expect(metadata.http_x_vercel_ja4_digest).toBe('abc');
});

test('is a no-op on records without NUL bytes', () => {
const { core, metadata } = toInsertableDbUsageRecord(
test('is a no-op on records without NUL bytes', async () => {
const { core, metadata } = await toInsertableDbUsageRecord(
baseUsageStats,
extractUsageContextInfo(makeUsageContext())
);
Expand All @@ -1045,8 +1045,8 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => {
expect(metadata.message_id).toBe('msg-id');
});

test('stores audio transcription api kind metadata', () => {
const { metadata } = toInsertableDbUsageRecord(
test('stores audio transcription api kind metadata', async () => {
const { metadata } = await toInsertableDbUsageRecord(
baseUsageStats,
extractUsageContextInfo(makeUsageContext({ api_kind: 'audio_transcriptions' }))
);
Expand Down
18 changes: 9 additions & 9 deletions apps/web/src/lib/ai-gateway/processUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,10 @@ export function stripNulBytesInPlace(obj: Record<string, unknown>, dirtyFields:
}
}

export function toInsertableDbUsageRecord(
export async function toInsertableDbUsageRecord(
usageStats: MicrodollarUsageStats,
usageContextInfo: UsageContextInfo
): CoreUsageWithMetaData {
): Promise<CoreUsageWithMetaData> {
const id = randomUUID();
const created_at = new Date().toISOString();

Expand Down Expand Up @@ -214,7 +214,7 @@ export function toInsertableDbUsageRecord(
streamed: usageStats.streamed,
cancelled: usageStats.cancelled,
market_cost: usageStats.market_cost ?? null,
is_free: isFreeModel(usageContextInfo.requested_model),
is_free: await isFreeModel(usageContextInfo.requested_model),
};

// Legacy heuristic classification removed - abuse_classification is now handled
Expand Down Expand Up @@ -254,7 +254,7 @@ export async function logMicrodollarUsage(
): Promise<{ usageId: string; createdAt: string } | null> {
usageContext.status_code = usageStats.status_code;
const contextInfo = extractUsageContextInfo(usageContext);
const { core, metadata } = toInsertableDbUsageRecord(usageStats, contextInfo);
const { core, metadata } = await toInsertableDbUsageRecord(usageStats, contextInfo);

const inserted = await saveUsageRelatedData(
core,
Expand Down Expand Up @@ -967,7 +967,7 @@ export async function processTokenData(
const provider = Object.values(PROVIDERS).find(p => p.id === usageContext.provider);
const generation =
provider &&
useGenerationLookup(usageStats, usageContext) &&
(await useGenerationLookup(usageStats, usageContext)) &&
usageStats.messageId &&
(await fetchGeneration(usageStats.messageId, provider));
if (usageStats.messageId) {
Expand Down Expand Up @@ -1026,7 +1026,7 @@ export async function processTokenData(
// Preserve the real cost before zeroing for free/BYOK
usageStats.market_cost = usageStats.cost_mUsd;

if (isFreeModel(usageContext.requested_model) || usageContext.user_byok) {
if ((await isFreeModel(usageContext.requested_model)) || usageContext.user_byok) {
usageStats.cost_mUsd = 0;
usageStats.cacheDiscount_mUsd = 0;
}
Expand All @@ -1038,16 +1038,16 @@ function useAnthropicStyleTokenCounting(requestedModel: string, provider: Provid
return provider === 'vercel' && (isClaudeModel(requestedModel) || isMinimaxModel(requestedModel));
}

function useGenerationLookup(
async function useGenerationLookup(
usageStats: MicrodollarUsageStats | null,
usageContext: MicrodollarUsageContext
) {
): Promise<boolean> {
const isGatewayProvider =
usageContext.provider === 'openrouter' || usageContext.provider === 'vercel';
const isSuccessStatusCode = (usageStats?.status_code ?? 200) < 400;
const hasOutputTokens = (usageStats?.outputTokens ?? 0) > 0;
const hasCostWhenPaid =
isFreeModel(usageContext.requested_model) ||
(await isFreeModel(usageContext.requested_model)) ||
usageContext.user_byok ||
(usageStats?.cost_mUsd ?? 0) > 0;
return isGatewayProvider && isSuccessStatusCode && (!hasOutputTokens || !hasCostWhenPaid);
Expand Down
Loading