From 8ad9111862cd40cffc8c9606906f6220bf0957bd Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 21 May 2026 15:33:28 +0200 Subject: [PATCH 1/2] refactor(gateway): make isFreeModel async Convert isFreeModel to Promise so future implementations can consult external state (e.g. a Redis-backed membership set) without another caller-churn refactor. Body still resolves synchronously today; this commit only moves callers, transitive sync helpers, and tests to await. --- apps/web/src/app/api/fim/completions/route.ts | 2 +- .../src/app/api/openrouter/[...path]/route.ts | 4 +- .../app/api/openrouter/embeddings/route.ts | 4 +- apps/web/src/lib/ai-gateway/models.test.ts | 110 +++++++++--------- apps/web/src/lib/ai-gateway/models.ts | 11 +- .../src/lib/ai-gateway/processUsage.test.ts | 16 +-- apps/web/src/lib/ai-gateway/processUsage.ts | 18 +-- .../ai-gateway/providers/openrouter/index.ts | 67 ++++++----- apps/web/src/lib/bot/agent-runner.ts | 6 +- .../organizations/organization-usage.test.ts | 64 +++++----- apps/web/src/lib/usageDeduction.test.ts | 8 +- .../tests/helpers/microdollar-usage.helper.ts | 12 +- 12 files changed, 167 insertions(+), 155 deletions(-) diff --git a/apps/web/src/app/api/fim/completions/route.ts b/apps/web/src/app/api/fim/completions/route.ts index acf61721130..c0f0ccef14c 100644 --- a/apps/web/src/app/api/fim/completions/route.ts +++ b/apps/web/src/app/api/fim/completions/route.ts @@ -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' }, diff --git a/apps/web/src/app/api/openrouter/[...path]/route.ts b/apps/web/src/app/api/openrouter/[...path]/route.ts index 51105b290db..0bf31dfa2b1 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.ts @@ -314,7 +314,7 @@ export async function POST(request: NextRequest): Promise { 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 @@ -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); } }); @@ -64,25 +64,25 @@ 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') ); @@ -90,61 +90,61 @@ describe('isFreeModel', () => { // 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); }); }); }); diff --git a/apps/web/src/lib/ai-gateway/models.ts b/apps/web/src/lib/ai-gateway/models.ts index 25669c48ff3..e185340973a 100644 --- a/apps/web/src/lib/ai-gateway/models.ts +++ b/apps/web/src/lib/ai-gateway/models.ts @@ -58,7 +58,16 @@ export const preferredModels = [ GLM_CURRENT_MODEL_ID, ]; -export function isFreeModel(model: string): boolean { +/** + * "Is this model free for the requesting user this request?" + * + * Async because future implementations need to consult external state + * (e.g. a Redis-backed membership set) to answer for models that are + * conditionally provider-funded. The current body resolves synchronously; + * `Promise` is the contract we want every caller to commit to so + * follow-ups can extend the function without a new round of caller churn. + */ +export async function isFreeModel(model: string): Promise { return ( isKiloExclusiveFreeModel(model) || model === KILO_AUTO_FREE_MODEL.id || diff --git a/apps/web/src/lib/ai-gateway/processUsage.test.ts b/apps/web/src/lib/ai-gateway/processUsage.test.ts index 51ec7bcdc7c..96215edfd83 100644 --- a/apps/web/src/lib/ai-gateway/processUsage.test.ts +++ b/apps/web/src/lib/ai-gateway/processUsage.test.ts @@ -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. @@ -973,7 +973,7 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => { }, }); - const { core, metadata } = toInsertableDbUsageRecord( + const { core, metadata } = await toInsertableDbUsageRecord( usageStats, extractUsageContextInfo(usageContext) ); @@ -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 @@ -1016,7 +1016,7 @@ describe('toInsertableDbUsageRecord NUL-byte sanitization', () => { }, }); - const { metadata } = toInsertableDbUsageRecord( + const { metadata } = await toInsertableDbUsageRecord( baseUsageStats, extractUsageContextInfo(usageContext) ); @@ -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()) ); @@ -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' })) ); diff --git a/apps/web/src/lib/ai-gateway/processUsage.ts b/apps/web/src/lib/ai-gateway/processUsage.ts index d06591ef6b7..ea96a9ff4bb 100644 --- a/apps/web/src/lib/ai-gateway/processUsage.ts +++ b/apps/web/src/lib/ai-gateway/processUsage.ts @@ -170,10 +170,10 @@ export function stripNulBytesInPlace(obj: Record, dirtyFields: } } -export function toInsertableDbUsageRecord( +export async function toInsertableDbUsageRecord( usageStats: MicrodollarUsageStats, usageContextInfo: UsageContextInfo -): CoreUsageWithMetaData { +): Promise { const id = randomUUID(); const created_at = new Date().toISOString(); @@ -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 @@ -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, @@ -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) { @@ -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; } @@ -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 { 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); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts index 5385b5d9ad1..629994f1b60 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/index.ts @@ -82,38 +82,41 @@ function formatName(model: OpenRouterModel, preferredIndex: number) { return model.name; } -function enhancedModelList(models: OpenRouterModel[]) { +async function enhancedModelList(models: OpenRouterModel[]) { const autoModels = buildAutoModels(); - const enhancedModels = models - .filter( - (model: OpenRouterModel) => - !kiloExclusiveModels.some(m => m.public_id === model.id) && !isForbiddenFreeModel(model.id) - ) - .concat( - kiloExclusiveModels - .filter(m => m.status === 'public') - .map(model => convertFromKiloExclusiveModel(model)) - ) - .concat(autoModels) - .map((model: OpenRouterModel) => { - const preferredIndex = preferredModels.indexOf(model.id); - const addPdf = - isPdfSupportingModel(model.id) && !model.architecture.input_modalities.includes('pdf'); - return { - ...model, - name: formatName(model, preferredIndex), - preferredIndex: preferredIndex >= 0 ? preferredIndex : undefined, - isFree: isFreeModel(model.id), - opencode: model.opencode ?? getOpenCodeSettings(model.id), - openclaw: model.openclaw ?? getOpenClawSettings(model.id), - architecture: addPdf - ? { - ...model.architecture, - input_modalities: model.architecture.input_modalities.concat(['pdf']), - } - : model.architecture, - }; - }); + const enhancedModels = await Promise.all( + models + .filter( + (model: OpenRouterModel) => + !kiloExclusiveModels.some(m => m.public_id === model.id) && + !isForbiddenFreeModel(model.id) + ) + .concat( + kiloExclusiveModels + .filter(m => m.status === 'public') + .map(model => convertFromKiloExclusiveModel(model)) + ) + .concat(autoModels) + .map(async (model: OpenRouterModel) => { + const preferredIndex = preferredModels.indexOf(model.id); + const addPdf = + isPdfSupportingModel(model.id) && !model.architecture.input_modalities.includes('pdf'); + return { + ...model, + name: formatName(model, preferredIndex), + preferredIndex: preferredIndex >= 0 ? preferredIndex : undefined, + isFree: await isFreeModel(model.id), + opencode: model.opencode ?? getOpenCodeSettings(model.id), + openclaw: model.openclaw ?? getOpenClawSettings(model.id), + architecture: addPdf + ? { + ...model.architecture, + input_modalities: model.architecture.input_modalities.concat(['pdf']), + } + : model.architecture, + }; + }) + ); const sortedModels = enhancedModels.sort((a, b) => { // Sort by preferredIndex (undefined values last) if (a.preferredIndex !== undefined && b.preferredIndex === undefined) return -1; @@ -189,7 +192,7 @@ export async function getEnhancedOpenRouterModels(): Promise { + return (await isFreeModel(modelSlug)) ? modelSlug : SUMMARY_MODEL; } async function summarizePrompt( @@ -146,7 +146,7 @@ async function summarizePrompt( prompt: string ): Promise { const result = await generateText({ - model: provider.chatModel(pickSummaryModel(modelSlug)), + model: provider.chatModel(await pickSummaryModel(modelSlug)), prompt: `Summarize the following task in at most 10 words. Output only the summary, nothing else.\n\n${prompt}`, }); return result.text.trim(); diff --git a/apps/web/src/lib/organizations/organization-usage.test.ts b/apps/web/src/lib/organizations/organization-usage.test.ts index c6bafb9c5d5..334929d7070 100644 --- a/apps/web/src/lib/organizations/organization-usage.test.ts +++ b/apps/web/src/lib/organizations/organization-usage.test.ts @@ -147,7 +147,7 @@ describe('Organization Usage Functions', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 50000); - const usage = createOrganizationUsage(5000, user.id, organization.id); + const usage = await createOrganizationUsage(5000, user.id, organization.id); await ingestOrganizationTokenUsage(usage); // Verify balance was reduced @@ -159,7 +159,7 @@ describe('Organization Usage Functions', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 40000); - const usage = createOrganizationUsage(0, user.id, organization.id); + const usage = await createOrganizationUsage(0, user.id, organization.id); await ingestOrganizationTokenUsage(usage); // Verify balance unchanged @@ -171,7 +171,7 @@ describe('Organization Usage Functions', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 1000000); - const usage = createOrganizationUsage(500000, user.id, organization.id); + const usage = await createOrganizationUsage(500000, user.id, organization.id); await ingestOrganizationTokenUsage(usage); // Verify balance was reduced by large amount @@ -183,7 +183,7 @@ describe('Organization Usage Functions', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 5000); - const usage = createOrganizationUsage(10000, user.id, organization.id); + const usage = await createOrganizationUsage(10000, user.id, organization.id); await ingestOrganizationTokenUsage(usage); // Verify balance went negative @@ -195,9 +195,9 @@ describe('Organization Usage Functions', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 100000); - const usage1Record = createOrganizationUsage(10000, user.id, organization.id); - const usage2Record = createOrganizationUsage(15000, user.id, organization.id); - const usage3Record = createOrganizationUsage(5000, user.id, organization.id); + const usage1Record = await createOrganizationUsage(10000, user.id, organization.id); + const usage2Record = await createOrganizationUsage(15000, user.id, organization.id); + const usage3Record = await createOrganizationUsage(5000, user.id, organization.id); await ingestOrganizationTokenUsage(usage1Record); await ingestOrganizationTokenUsage(usage2Record); await ingestOrganizationTokenUsage(usage3Record); @@ -216,11 +216,11 @@ describe('Organization Usage Functions', () => { await addUserToOrganization(organization.id, member1.id, 'member'); await addUserToOrganization(organization.id, member2.id, 'owner'); - const ownerUsageRecord = createOrganizationUsage(10000, owner.id, organization.id); + const ownerUsageRecord = await createOrganizationUsage(10000, owner.id, organization.id); await ingestOrganizationTokenUsage(ownerUsageRecord); - const member1UsageRecord = createOrganizationUsage(12000, member1.id, organization.id); + const member1UsageRecord = await createOrganizationUsage(12000, member1.id, organization.id); await ingestOrganizationTokenUsage(member1UsageRecord); - const member2UsageRecord = createOrganizationUsage(8000, member2.id, organization.id); + const member2UsageRecord = await createOrganizationUsage(8000, member2.id, organization.id); await ingestOrganizationTokenUsage(member2UsageRecord); // Verify total deduction from all members @@ -234,7 +234,7 @@ describe('Organization Usage Functions', () => { const org2 = await createTestOrganization('Org 2', user.id, 60000); // Ingest usage for org1 only - const usage = createOrganizationUsage(10000, user.id, org1.id); + const usage = await createOrganizationUsage(10000, user.id, org1.id); await ingestOrganizationTokenUsage(usage); // Verify org1 balance was reduced @@ -251,7 +251,7 @@ describe('Organization Usage Functions', () => { const nonExistentOrgId = '00000000-0000-0000-0000-000000000000'; // Should complete without error even for non-existent organization (new behavior) - const usage = createOrganizationUsage(5000, user.id, nonExistentOrgId); + const usage = await createOrganizationUsage(5000, user.id, nonExistentOrgId); await ingestOrganizationTokenUsage(usage); }); @@ -268,7 +268,7 @@ describe('Organization Usage Functions', () => { // Wait a bit to ensure timestamp difference await new Promise(resolve => setTimeout(resolve, 10)); - const usage = createOrganizationUsage(5000, user.id, organization.id); + const usage = await createOrganizationUsage(5000, user.id, organization.id); await ingestOrganizationTokenUsage(usage); // Get updated updated_at @@ -297,8 +297,8 @@ describe('Organization Usage Functions', () => { const numberOfUsages = 20; const expectedTotalCost = costPerUsage * numberOfUsages; // 100k total - const usagePromises = Array.from({ length: numberOfUsages }, () => { - const usage = createOrganizationUsage(costPerUsage, user.id, organization.id); + const usagePromises = Array.from({ length: numberOfUsages }, async () => { + const usage = await createOrganizationUsage(costPerUsage, user.id, organization.id); return ingestOrganizationTokenUsage(usage); }); @@ -329,7 +329,7 @@ describe('Organization Usage Functions', () => { expect(memberBalance.balance).toBe(0.1); // Same organization balance // Owner uses tokens - const ownerUsageRecord = createOrganizationUsage(20000, owner.id, organization.id); + const ownerUsageRecord = await createOrganizationUsage(20000, owner.id, organization.id); await ingestOrganizationTokenUsage(ownerUsageRecord); // Verify balance after owner usage @@ -339,7 +339,7 @@ describe('Organization Usage Functions', () => { expect(memberBalance.balance).toBe(0.08); // Same organization balance // Member uses tokens - const memberUsageRecord = createOrganizationUsage(15000, member.id, organization.id); + const memberUsageRecord = await createOrganizationUsage(15000, member.id, organization.id); await ingestOrganizationTokenUsage(memberUsageRecord); // Verify final balance @@ -869,7 +869,7 @@ describe('microdollars_used tracking', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 50000); - const usage = createOrganizationUsage(5000, user.id, organization.id); + const usage = await createOrganizationUsage(5000, user.id, organization.id); await ingestOrganizationTokenUsage(usage); const [org] = await db @@ -884,9 +884,9 @@ describe('microdollars_used tracking', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 100000); - const usage1 = createOrganizationUsage(10000, user.id, organization.id); - const usage2 = createOrganizationUsage(15000, user.id, organization.id); - const usage3 = createOrganizationUsage(5000, user.id, organization.id); + const usage1 = await createOrganizationUsage(10000, user.id, organization.id); + const usage2 = await createOrganizationUsage(15000, user.id, organization.id); + const usage3 = await createOrganizationUsage(5000, user.id, organization.id); await ingestOrganizationTokenUsage(usage1); await ingestOrganizationTokenUsage(usage2); @@ -909,9 +909,9 @@ describe('microdollars_used tracking', () => { await addUserToOrganization(organization.id, member1.id, 'member'); await addUserToOrganization(organization.id, member2.id, 'owner'); - const ownerUsage = createOrganizationUsage(10000, owner.id, organization.id); - const member1Usage = createOrganizationUsage(12000, member1.id, organization.id); - const member2Usage = createOrganizationUsage(8000, member2.id, organization.id); + const ownerUsage = await createOrganizationUsage(10000, owner.id, organization.id); + const member1Usage = await createOrganizationUsage(12000, member1.id, organization.id); + const member2Usage = await createOrganizationUsage(8000, member2.id, organization.id); await ingestOrganizationTokenUsage(ownerUsage); await ingestOrganizationTokenUsage(member1Usage); @@ -930,8 +930,8 @@ describe('microdollars_used tracking', () => { const org1 = await createTestOrganization('Org 1', user.id, 50000); const org2 = await createTestOrganization('Org 2', user.id, 60000); - const org1Usage = createOrganizationUsage(10000, user.id, org1.id); - const org2Usage = createOrganizationUsage(15000, user.id, org2.id); + const org1Usage = await createOrganizationUsage(10000, user.id, org1.id); + const org2Usage = await createOrganizationUsage(15000, user.id, org2.id); await ingestOrganizationTokenUsage(org1Usage); await ingestOrganizationTokenUsage(org2Usage); @@ -954,7 +954,7 @@ describe('microdollars_used tracking', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 40000); - const usage = createOrganizationUsage(0, user.id, organization.id); + const usage = await createOrganizationUsage(0, user.id, organization.id); await ingestOrganizationTokenUsage(usage); const [org] = await db @@ -969,7 +969,7 @@ describe('microdollars_used tracking', () => { const user = await insertTestUser(); const organization = await createTestOrganization('Test Org', user.id, 1000000); - const usage = createOrganizationUsage(500000, user.id, organization.id); + const usage = await createOrganizationUsage(500000, user.id, organization.id); await ingestOrganizationTokenUsage(usage); const [org] = await db @@ -986,7 +986,7 @@ describe('microdollars_used tracking', () => { const organization = await createTestOrganization('Test Org', user.id, initialBalance); const usageCost = 25000; - const usage = createOrganizationUsage(usageCost, user.id, organization.id); + const usage = await createOrganizationUsage(usageCost, user.id, organization.id); await ingestOrganizationTokenUsage(usage); const [org] = await db @@ -1017,8 +1017,8 @@ describe('microdollars_used tracking', () => { const numberOfUsages = 20; const expectedTotalUsed = costPerUsage * numberOfUsages; // 100k total - const usagePromises = Array.from({ length: numberOfUsages }, () => { - const usage = createOrganizationUsage(costPerUsage, user.id, organization.id); + const usagePromises = Array.from({ length: numberOfUsages }, async () => { + const usage = await createOrganizationUsage(costPerUsage, user.id, organization.id); return ingestOrganizationTokenUsage(usage); }); @@ -1045,7 +1045,7 @@ describe('microdollars_used tracking', () => { const nonExistentOrgId = '00000000-0000-0000-0000-000000000000'; // Should complete without error even for non-existent organization - const usage = createOrganizationUsage(5000, user.id, nonExistentOrgId); + const usage = await createOrganizationUsage(5000, user.id, nonExistentOrgId); await expect(ingestOrganizationTokenUsage(usage)).resolves.not.toThrow(); // Verify that trying to query the non-existent organization returns nothing diff --git a/apps/web/src/lib/usageDeduction.test.ts b/apps/web/src/lib/usageDeduction.test.ts index 495b0a85d86..9ff396ceb2f 100644 --- a/apps/web/src/lib/usageDeduction.test.ts +++ b/apps/web/src/lib/usageDeduction.test.ts @@ -52,7 +52,7 @@ describe('Usage deduction for migrated users', () => { ); const contextInfo = extractUsageContextInfo(usageContext); - const { core, metadata } = toInsertableDbUsageRecord(usageStats, contextInfo); + const { core, metadata } = await toInsertableDbUsageRecord(usageStats, contextInfo); const result = await insertUsageRecord(core, metadata); @@ -77,7 +77,7 @@ describe('Usage deduction for migrated users', () => { const usageContext = createMockUsageContext(user.id, user.google_user_email, 0); const contextInfo = extractUsageContextInfo(usageContext); - const { core, metadata } = toInsertableDbUsageRecord(usageStats, contextInfo); + const { core, metadata } = await toInsertableDbUsageRecord(usageStats, contextInfo); const result = await insertUsageRecord(core, metadata); @@ -106,7 +106,7 @@ describe('Usage deduction for migrated users', () => { const usageContext = createMockUsageContext(user.id, user.google_user_email, currentUsage); const contextInfo = extractUsageContextInfo(usageContext); - const { core, metadata } = toInsertableDbUsageRecord(usageStats, contextInfo); + const { core, metadata } = await toInsertableDbUsageRecord(usageStats, contextInfo); const result = await insertUsageRecord(core, metadata); expect(result).not.toBeNull(); @@ -134,7 +134,7 @@ describe('Usage deduction for migrated users', () => { const usageContext = createMockUsageContext(user.id, user.google_user_email, 0); const contextInfo = extractUsageContextInfo(usageContext); - const { core, metadata } = toInsertableDbUsageRecord(usageStats, contextInfo); + const { core, metadata } = await toInsertableDbUsageRecord(usageStats, contextInfo); await insertUsageRecord(core, metadata); diff --git a/apps/web/src/tests/helpers/microdollar-usage.helper.ts b/apps/web/src/tests/helpers/microdollar-usage.helper.ts index ae4a21f81a8..e9bf14e6e4d 100644 --- a/apps/web/src/tests/helpers/microdollar-usage.helper.ts +++ b/apps/web/src/tests/helpers/microdollar-usage.helper.ts @@ -71,10 +71,10 @@ function defineDefaultContextInfo(): UsageContextInfo { } // Returns structured type for new usage -export function defineMicrodollarUsage(): CoreUsageWithMetaData { +export async function defineMicrodollarUsage(): Promise { const stats = defineDefaultUsageStats(); const context = defineDefaultContextInfo(); - const result = toInsertableDbUsageRecord(stats, context); + const result = await toInsertableDbUsageRecord(stats, context); return { core: { ...result.core, created_at: '2025-08-15T12:00:00Z' }, @@ -86,7 +86,7 @@ export function defineMicrodollarUsage(): CoreUsageWithMetaData { export async function insertUsageWithOverrides( overrides: Partial ): Promise { - const { core, metadata } = defineMicrodollarUsage(); + const { core, metadata } = await defineMicrodollarUsage(); await insertUsageRecord({ ...core, ...overrides }, metadata); } @@ -125,12 +125,12 @@ export function createMockUsageContext( }; } -export function createOrganizationUsage( +export async function createOrganizationUsage( cost: number, kilo_user_id: string, organization_id: string -): MicrodollarUsage { - const { core } = defineMicrodollarUsage(); +): Promise { + const { core } = await defineMicrodollarUsage(); return { ...core, kilo_user_id, cost, organization_id }; } From d71f5277af432b1f50fd286c5093c70e8dfd9c5b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 21 May 2026 15:39:55 +0200 Subject: [PATCH 2/2] refactor(gateway): drop isFreeModel docstring --- apps/web/src/lib/ai-gateway/models.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/models.ts b/apps/web/src/lib/ai-gateway/models.ts index e185340973a..2b2b9d09f1d 100644 --- a/apps/web/src/lib/ai-gateway/models.ts +++ b/apps/web/src/lib/ai-gateway/models.ts @@ -58,15 +58,6 @@ export const preferredModels = [ GLM_CURRENT_MODEL_ID, ]; -/** - * "Is this model free for the requesting user this request?" - * - * Async because future implementations need to consult external state - * (e.g. a Redis-backed membership set) to answer for models that are - * conditionally provider-funded. The current body resolves synchronously; - * `Promise` is the contract we want every caller to commit to so - * follow-ups can extend the function without a new round of caller churn. - */ export async function isFreeModel(model: string): Promise { return ( isKiloExclusiveFreeModel(model) ||