From 74a27d6fa232fae4c4bc6ea13094c8b672e170dd Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 27 Jul 2026 10:58:56 +0200 Subject: [PATCH 1/6] fix(ai-gateway): sync free endpoint data policy --- .../free-endpoint-data-policy.test.ts | 185 ++++++++++++++++++ .../openrouter/free-endpoint-data-policy.ts | 92 +++++++++ .../providers/openrouter/sync-providers.ts | 13 +- 3 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts create mode 100644 apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts new file mode 100644 index 0000000000..656a32eb29 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from '@jest/globals'; +import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; +import { applyFreeEndpointDataPolicy } from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; +import type { OpenRouterModel } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; +import type { StoredModel } from '@kilocode/db/schema-types'; + +function offering(slug: string): OpenRouterModel { + return { + slug, + name: slug, + author: 'test', + description: '', + context_length: 1, + input_modalities: ['text'], + output_modalities: ['text'], + group: 'test', + updated_at: '2026-07-27T00:00:00.000Z', + endpoint: { + provider_display_name: 'Test', + is_free: false, + pricing: { prompt: '0.1', completion: '0.2' }, + data_policy: { training: false, retainsPrompts: false }, + }, + }; +} + +function storedModel(id: string, tag: string, prompt: string, completion: string): StoredModel { + return { + id, + name: id, + endpoints: [{ tag, pricing: { prompt, completion } }], + }; +} + +function freeExclusiveModel( + public_id: string, + inference_provider_restriction: KiloExclusiveModel['inference_provider_restriction'] +): KiloExclusiveModel { + return { + public_id, + internal_id: public_id, + display_name: public_id, + description: '', + context_length: 1, + max_completion_tokens: 1, + status: 'public', + flags: [], + gateway: 'openrouter', + pricing: null, + inference_provider_restriction, + }; +} + +describe('applyFreeEndpointDataPolicy', () => { + test('applies free OpenRouter endpoint policy by normalized model and provider', () => { + const matching = offering('example/model'); + const otherProvider = offering('example/model'); + const providerModelData = [ + { provider: { slug: 'novita' }, models: [matching] }, + { provider: { slug: 'deepinfra' }, models: [otherProvider] }, + ]; + + applyFreeEndpointDataPolicy({ + providerModelData, + openRouterModels: { + 'example/model:free': storedModel('example/model:free', 'novita/fp8', '0', '0.000'), + }, + kiloExclusiveModels: [], + }); + + expect(matching.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); + expect(otherProvider.endpoint?.data_policy).toEqual({ + training: false, + retainsPrompts: false, + }); + }); + + test('requires both OpenRouter token prices to be zero', () => { + const model = offering('example/model'); + + applyFreeEndpointDataPolicy({ + providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], + openRouterModels: { + 'example/model': storedModel('example/model', 'novita', '0', '0.1'), + }, + kiloExclusiveModels: [], + }); + + expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + }); + + test('does not treat malformed OpenRouter prices as zero', () => { + const model = offering('example/model'); + + applyFreeEndpointDataPolicy({ + providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], + openRouterModels: { + 'example/model': storedModel('example/model', 'novita', '0foo', '0'), + }, + kiloExclusiveModels: [], + }); + + expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + }); + + test('applies an unrestricted free exclusive model to every provider', () => { + const first = offering('example/model'); + const second = offering('example/model'); + + applyFreeEndpointDataPolicy({ + providerModelData: [ + { provider: { slug: 'novita' }, models: [first] }, + { provider: { slug: 'deepinfra' }, models: [second] }, + ], + openRouterModels: {}, + kiloExclusiveModels: [freeExclusiveModel('example/model:free', [])], + }); + + expect(first.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); + expect(second.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); + }); + + test('respects free exclusive model provider restrictions', () => { + const allowed = offering('example/model'); + const blocked = offering('example/model'); + + applyFreeEndpointDataPolicy({ + providerModelData: [ + { provider: { slug: 'stepfun' }, models: [allowed] }, + { provider: { slug: 'novita' }, models: [blocked] }, + ], + openRouterModels: {}, + kiloExclusiveModels: [freeExclusiveModel('example/model:free', ['stepfun'])], + }); + + expect(allowed.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); + expect(blocked.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + }); + + test('combines restrictions from normalized free exclusive variants', () => { + const first = offering('example/model'); + const second = offering('example/model'); + + applyFreeEndpointDataPolicy({ + providerModelData: [ + { provider: { slug: 'stepfun' }, models: [first] }, + { provider: { slug: 'novita' }, models: [second] }, + ], + openRouterModels: {}, + kiloExclusiveModels: [ + freeExclusiveModel('example/model:free', ['stepfun']), + freeExclusiveModel('example/model:promo', ['novita']), + ], + }); + + expect(first.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); + expect(second.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); + }); + + test('ignores disabled and paid exclusive models', () => { + const model = offering('example/model'); + const disabled = freeExclusiveModel('example/model:free', []); + disabled.status = 'disabled'; + const paid = freeExclusiveModel('example/model:discounted', []); + paid.pricing = [ + { + start_context_length: 0, + pricing: { + prompt_per_million: 1, + completion_per_million: 1, + input_cache_read_per_million: null, + input_cache_write_per_million: null, + }, + }, + ]; + + applyFreeEndpointDataPolicy({ + providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], + openRouterModels: {}, + kiloExclusiveModels: [disabled, paid], + }); + + expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + }); +}); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts new file mode 100644 index 0000000000..b5758bc46b --- /dev/null +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts @@ -0,0 +1,92 @@ +import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; +import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; +import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; +import type { OpenRouterModel } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; +import type { StoredModel } from '@kilocode/db/schema-types'; + +type ProviderModels = Array<{ + provider: { slug: string }; + models: OpenRouterModel[]; +}>; + +function isZeroPrice(price: string | undefined): boolean { + if (price === undefined || price.trim() === '') return false; + const value = Number(price); + return Number.isFinite(value) && value === 0; +} + +function freeOpenRouterEndpointKeys(openRouterModels: Record): Set { + const keys = new Set(); + for (const model of Object.values(openRouterModels)) { + const modelId = normalizeModelId(model.id); + for (const endpoint of model.endpoints) { + if ( + endpoint.tag && + isZeroPrice(endpoint.pricing?.prompt) && + isZeroPrice(endpoint.pricing?.completion) + ) { + keys.add(`${modelId}:${normalizeInferenceProviderId(endpoint.tag)}`); + } + } + } + return keys; +} + +function freeKiloExclusiveModels( + kiloExclusiveModels: ReadonlyArray +): Map | null> { + const models = new Map | null>(); + for (const model of kiloExclusiveModels) { + if (model.status === 'disabled' || model.pricing !== null) continue; + const modelId = normalizeModelId(model.public_id); + if (model.inference_provider_restriction.length === 0) { + models.set(modelId, null); + continue; + } + const existing = models.get(modelId); + if (existing === null) continue; + models.set( + modelId, + new Set([ + ...(existing ?? []), + ...model.inference_provider_restriction.map(providerId => + normalizeInferenceProviderId(providerId) + ), + ]) + ); + } + return models; +} + +export function applyFreeEndpointDataPolicy({ + providerModelData, + openRouterModels, + kiloExclusiveModels, +}: { + providerModelData: ProviderModels; + openRouterModels: Record; + kiloExclusiveModels: ReadonlyArray; +}): void { + const openRouterEndpointKeys = freeOpenRouterEndpointKeys(openRouterModels); + const exclusiveModels = freeKiloExclusiveModels(kiloExclusiveModels); + + for (const { provider, models } of providerModelData) { + const providerId = normalizeInferenceProviderId(provider.slug); + for (const model of models) { + if (!model.endpoint) continue; + + const modelId = normalizeModelId(model.slug); + const restrictions = exclusiveModels.get(modelId); + const hasFreeExclusiveEndpoint = + exclusiveModels.has(modelId) && (restrictions === null || restrictions?.has(providerId)); + const hasFreeOpenRouterEndpoint = openRouterEndpointKeys.has(`${modelId}:${providerId}`); + if (!hasFreeExclusiveEndpoint && !hasFreeOpenRouterEndpoint) continue; + + model.endpoint.data_policy = { + ...model.endpoint.data_policy, + training: true, + retainsPrompts: true, + }; + } + } +} diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts index 3362d904ec..b78795939f 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts @@ -41,6 +41,7 @@ import { openRouterToVercelInferenceProviderId, VercelInferenceProviderIdSchema, } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; +import { applyFreeEndpointDataPolicy } from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; /** * Advisory lock key hashed from a stable identifier. Serializes concurrent @@ -235,6 +236,7 @@ function injectExtraProviderModels( async function syncProviders( providers: OpenRouterProvider[], + openRouterModels: Record, vercelModels: Record ) { if (providers.length === 0) { @@ -295,6 +297,9 @@ async function syncProviders( prompt: model.pricing.prompt, completion: model.pricing.completion, }, + ...(!kfm.pricing && { + data_policy: { training: true, retainsPrompts: true }, + }), }, }, provider: inferenceProvider, @@ -313,6 +318,12 @@ async function syncProviders( } } + applyFreeEndpointDataPolicy({ + providerModelData, + openRouterModels, + kiloExclusiveModels, + }); + // Filter out providers with no models const filteredProviderModelData = providerModelData.filter(data => data.models.length > 0); @@ -498,7 +509,7 @@ export async function syncAndStoreProviders() { ); } - const providers = await syncProviders(openrouterProviders, vercel_data); + const providers = await syncProviders(openrouterProviders, openrouter_data, vercel_data); if (providers.total_providers < 10) { throw new Error(`Suspicious: total number of providers is ${providers.total_providers} < 10`); From ab656a77038cc2363df14062cd296b294df9acb0 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 27 Jul 2026 11:10:42 +0200 Subject: [PATCH 2/6] fix(ai-gateway): scope exclusive data policy --- .../free-endpoint-data-policy.test.ts | 48 ++++++++++++++++++- .../openrouter/free-endpoint-data-policy.ts | 7 +-- .../providers/openrouter/sync-providers.ts | 2 +- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts index 656a32eb29..a20f783df1 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts @@ -51,6 +51,26 @@ function freeExclusiveModel( }; } +function dataCollectionExclusiveModel( + public_id: string, + inference_provider_restriction: KiloExclusiveModel['inference_provider_restriction'] +): KiloExclusiveModel { + const model = freeExclusiveModel(public_id, inference_provider_restriction); + model.pricing = [ + { + start_context_length: 0, + pricing: { + prompt_per_million: 1, + completion_per_million: 1, + input_cache_read_per_million: null, + input_cache_write_per_million: null, + }, + }, + ]; + model.flags = ['requires-data-collection']; + return model; +} + describe('applyFreeEndpointDataPolicy', () => { test('applies free OpenRouter endpoint policy by normalized model and provider', () => { const matching = offering('example/model'); @@ -120,6 +140,32 @@ describe('applyFreeEndpointDataPolicy', () => { expect(second.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); }); + test('ignores hidden free exclusive models', () => { + const model = offering('example/model'); + const hidden = freeExclusiveModel('example/model:free', []); + hidden.status = 'hidden'; + + applyFreeEndpointDataPolicy({ + providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], + openRouterModels: {}, + kiloExclusiveModels: [hidden], + }); + + expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + }); + + test('applies public data-collection exclusive model policy', () => { + const model = offering('example/model'); + + applyFreeEndpointDataPolicy({ + providerModelData: [{ provider: { slug: 'deepseek' }, models: [model] }], + openRouterModels: {}, + kiloExclusiveModels: [dataCollectionExclusiveModel('example/model:discounted', ['deepseek'])], + }); + + expect(model.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); + }); + test('respects free exclusive model provider restrictions', () => { const allowed = offering('example/model'); const blocked = offering('example/model'); @@ -157,7 +203,7 @@ describe('applyFreeEndpointDataPolicy', () => { expect(second.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); }); - test('ignores disabled and paid exclusive models', () => { + test('ignores disabled and paid exclusive models without data collection', () => { const model = offering('example/model'); const disabled = freeExclusiveModel('example/model:free', []); disabled.status = 'disabled'; diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts index b5758bc46b..4b01b55a2a 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts @@ -32,12 +32,13 @@ function freeOpenRouterEndpointKeys(openRouterModels: Record ): Map | null> { const models = new Map | null>(); for (const model of kiloExclusiveModels) { - if (model.status === 'disabled' || model.pricing !== null) continue; + const collectsData = model.pricing === null || model.flags.includes('requires-data-collection'); + if (model.status !== 'public' || !collectsData) continue; const modelId = normalizeModelId(model.public_id); if (model.inference_provider_restriction.length === 0) { models.set(modelId, null); @@ -68,7 +69,7 @@ export function applyFreeEndpointDataPolicy({ kiloExclusiveModels: ReadonlyArray; }): void { const openRouterEndpointKeys = freeOpenRouterEndpointKeys(openRouterModels); - const exclusiveModels = freeKiloExclusiveModels(kiloExclusiveModels); + const exclusiveModels = dataCollectingKiloExclusiveModels(kiloExclusiveModels); for (const { provider, models } of providerModelData) { const providerId = normalizeInferenceProviderId(provider.slug); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts index b78795939f..243209346b 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts @@ -297,7 +297,7 @@ async function syncProviders( prompt: model.pricing.prompt, completion: model.pricing.completion, }, - ...(!kfm.pricing && { + ...((!kfm.pricing || kfm.flags.includes('requires-data-collection')) && { data_policy: { training: true, retainsPrompts: true }, }), }, From cbcc5350c8a4ca9590182388fa44f63c309f9035 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 27 Jul 2026 11:21:59 +0200 Subject: [PATCH 3/6] test(mobile): split push preference cases --- .../agent-push-preference.categories.test.ts | 58 +++++++++++++++++++ .../lib/hooks/agent-push-preference.test.ts | 35 ----------- 2 files changed, 58 insertions(+), 35 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/agent-push-preference.categories.test.ts diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.categories.test.ts b/apps/mobile/src/lib/hooks/agent-push-preference.categories.test.ts new file mode 100644 index 0000000000..b91b9f217b --- /dev/null +++ b/apps/mobile/src/lib/hooks/agent-push-preference.categories.test.ts @@ -0,0 +1,58 @@ +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it } from 'vitest'; + +import { + applyAgentPushOptimistic, + DEFAULT_NOTIFICATION_PREFERENCE, + NOTIFICATION_CATEGORY_KEYS, + type NotificationCategoryKey, + type NotificationPreferences, + rollbackAgentPushOptimistic, +} from './agent-push-preference'; + +const key = ['user', 'getNotificationPreferences'] as const; + +function fullRow(): NotificationPreferences { + return { + chatMessages: DEFAULT_NOTIFICATION_PREFERENCE, + agentAttention: DEFAULT_NOTIFICATION_PREFERENCE, + agentUpdates: DEFAULT_NOTIFICATION_PREFERENCE, + sessionStatus: DEFAULT_NOTIFICATION_PREFERENCE, + kiloclawActivity: DEFAULT_NOTIFICATION_PREFERENCE, + agentPushEnabled: DEFAULT_NOTIFICATION_PREFERENCE, + }; +} + +describe('per-category flip flow (each category in turn)', () => { + const scenarios: { category: NotificationCategoryKey; next: boolean }[] = [ + { category: 'chatMessages', next: false }, + { category: 'agentAttention', next: true }, + { category: 'agentUpdates', next: false }, + { category: 'sessionStatus', next: true }, + { category: 'kiloclawActivity', next: false }, + ]; + + for (const { category, next } of scenarios) { + it(`flips only ${category} → ${next} and rolls back cleanly`, async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(key, fullRow()); + + const context = await applyAgentPushOptimistic({ + queryClient, + queryKey: key, + next, + category, + }); + const after = queryClient.getQueryData(key); + expect(after?.[category]).toBe(next); + for (const other of NOTIFICATION_CATEGORY_KEYS) { + if (other !== category) { + expect(after?.[other]).toBe(DEFAULT_NOTIFICATION_PREFERENCE); + } + } + + rollbackAgentPushOptimistic({ queryClient, queryKey: key, context }); + expect(queryClient.getQueryData(key)).toEqual(fullRow()); + }); + } +}); diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts index 762bd73907..24a244a8b2 100644 --- a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts +++ b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts @@ -7,7 +7,6 @@ import { deriveAgentPushEditable, deriveShowEnableCta, NOTIFICATION_CATEGORY_KEYS, - type NotificationCategoryKey, type NotificationPreferences, readAgentPushPreference, rollbackAgentPushOptimistic, @@ -269,37 +268,3 @@ describe('applyAgentPushOptimistic + rollbackAgentPushOptimistic (per-category)' expect(qc.getQueryData(key)).toEqual(fullRow({ agentAttention: true })); }); }); - -describe('per-category flip flow (each category in turn)', () => { - const scenarios: { category: NotificationCategoryKey; next: boolean }[] = [ - { category: 'chatMessages', next: false }, - { category: 'agentAttention', next: true }, - { category: 'agentUpdates', next: false }, - { category: 'sessionStatus', next: true }, - { category: 'kiloclawActivity', next: false }, - ]; - - for (const { category, next } of scenarios) { - it(`flips only ${category} → ${next} and rolls back cleanly`, async () => { - const qc = makeQueryClient(); - qc.setQueryData(key, fullRow()); - - const context = await applyAgentPushOptimistic({ - queryClient: qc, - queryKey: key, - next, - category, - }); - const after = readRow(qc); - expect(after[category]).toBe(next); - for (const other of NOTIFICATION_CATEGORY_KEYS) { - if (other !== category) { - expect(after[other]).toBe(DEFAULT_NOTIFICATION_PREFERENCE); - } - } - - rollbackAgentPushOptimistic({ queryClient: qc, queryKey: key, context }); - expect(qc.getQueryData(key)).toEqual(fullRow()); - }); - } -}); From a75d40a73f4f40861c8ada315e6bea80f5c745ac Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 27 Jul 2026 12:15:30 +0200 Subject: [PATCH 4/6] fix(ai-gateway): use explicit free endpoint metadata --- .../ai-gateway/forbidden-free-models.test.ts | 18 ++++ .../lib/ai-gateway/forbidden-free-models.ts | 9 ++ .../free-endpoint-data-policy.test.ts | 93 +++++++++++-------- .../openrouter/free-endpoint-data-policy.ts | 37 +++----- .../providers/openrouter/sync-providers.ts | 19 ++-- 5 files changed, 107 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts diff --git a/apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts b/apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts new file mode 100644 index 0000000000..21c4d7f692 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from '@jest/globals'; +import { + isForbiddenFreeModel, + isForbiddenFreeModelFamily, +} from '@/lib/ai-gateway/forbidden-free-models'; + +describe('forbidden free models', () => { + test('keeps exact matching for request rejection', () => { + expect(isForbiddenFreeModel('openai/gpt-oss-20b:free')).toBe(true); + expect(isForbiddenFreeModel('openai/gpt-oss-20b')).toBe(false); + }); + + test('matches normalized families for provider metadata', () => { + expect(isForbiddenFreeModelFamily('openai/gpt-oss-20b:free')).toBe(true); + expect(isForbiddenFreeModelFamily('openai/gpt-oss-20b')).toBe(true); + expect(isForbiddenFreeModelFamily('cohere/north-mini-code')).toBe(false); + }); +}); 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 a879e7af45..d355fbcb40 100644 --- a/apps/web/src/lib/ai-gateway/forbidden-free-models.ts +++ b/apps/web/src/lib/ai-gateway/forbidden-free-models.ts @@ -1,4 +1,5 @@ import { claude_sonnet_clawsetup_model } from '@/lib/ai-gateway/providers/anthropic.constants'; +import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; const forbiddenFreeModelIds: ReadonlySet = new Set([ 'auto:free', // this is not a free model, OpenRouter can map it to a paid model @@ -56,3 +57,11 @@ const forbiddenFreeModelIds: ReadonlySet = new Set([ export function isForbiddenFreeModel(modelId: string): boolean { return forbiddenFreeModelIds.has(modelId); } + +const forbiddenFreeModelFamilies: ReadonlySet = new Set( + [...forbiddenFreeModelIds].map(normalizeModelId) +); + +export function isForbiddenFreeModelFamily(modelId: string): boolean { + return forbiddenFreeModelFamilies.has(normalizeModelId(modelId)); +} diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts index a20f783df1..ba1492019c 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from '@jest/globals'; import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; -import { applyFreeEndpointDataPolicy } from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; +import { + applyFreeEndpointDataPolicy, + getOpenRouterFreeEndpointKeys, +} from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; import type { OpenRouterModel } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; -import type { StoredModel } from '@kilocode/db/schema-types'; -function offering(slug: string): OpenRouterModel { +function offering(slug: string, isFree = false): OpenRouterModel { return { slug, name: slug, @@ -17,21 +19,13 @@ function offering(slug: string): OpenRouterModel { updated_at: '2026-07-27T00:00:00.000Z', endpoint: { provider_display_name: 'Test', - is_free: false, + is_free: isFree, pricing: { prompt: '0.1', completion: '0.2' }, data_policy: { training: false, retainsPrompts: false }, }, }; } -function storedModel(id: string, tag: string, prompt: string, completion: string): StoredModel { - return { - id, - name: id, - endpoints: [{ tag, pricing: { prompt, completion } }], - }; -} - function freeExclusiveModel( public_id: string, inference_provider_restriction: KiloExclusiveModel['inference_provider_restriction'] @@ -73,21 +67,22 @@ function dataCollectionExclusiveModel( describe('applyFreeEndpointDataPolicy', () => { test('applies free OpenRouter endpoint policy by normalized model and provider', () => { + const freeVariant = offering('example/model:free', true); const matching = offering('example/model'); const otherProvider = offering('example/model'); const providerModelData = [ - { provider: { slug: 'novita' }, models: [matching] }, + { provider: { slug: 'novita' }, models: [freeVariant, matching] }, { provider: { slug: 'deepinfra' }, models: [otherProvider] }, ]; + const openRouterFreeEndpointKeys = getOpenRouterFreeEndpointKeys(providerModelData); applyFreeEndpointDataPolicy({ providerModelData, - openRouterModels: { - 'example/model:free': storedModel('example/model:free', 'novita/fp8', '0', '0.000'), - }, + openRouterFreeEndpointKeys, kiloExclusiveModels: [], }); + expect(freeVariant.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); expect(matching.endpoint?.data_policy).toEqual({ training: true, retainsPrompts: true }); expect(otherProvider.endpoint?.data_policy).toEqual({ training: false, @@ -95,28 +90,16 @@ describe('applyFreeEndpointDataPolicy', () => { }); }); - test('requires both OpenRouter token prices to be zero', () => { - const model = offering('example/model'); - - applyFreeEndpointDataPolicy({ - providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], - openRouterModels: { - 'example/model': storedModel('example/model', 'novita', '0', '0.1'), - }, - kiloExclusiveModels: [], - }); - - expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); - }); - - test('does not treat malformed OpenRouter prices as zero', () => { + test('does not infer free status from zero token prices', () => { const model = offering('example/model'); + if (!model.endpoint) throw new Error('expected endpoint'); + model.endpoint.pricing = { prompt: '0', completion: '0' }; applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], - openRouterModels: { - 'example/model': storedModel('example/model', 'novita', '0foo', '0'), - }, + openRouterFreeEndpointKeys: getOpenRouterFreeEndpointKeys([ + { provider: { slug: 'novita' }, models: [model] }, + ]), kiloExclusiveModels: [], }); @@ -132,7 +115,7 @@ describe('applyFreeEndpointDataPolicy', () => { { provider: { slug: 'novita' }, models: [first] }, { provider: { slug: 'deepinfra' }, models: [second] }, ], - openRouterModels: {}, + openRouterFreeEndpointKeys: new Set(), kiloExclusiveModels: [freeExclusiveModel('example/model:free', [])], }); @@ -147,7 +130,7 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], - openRouterModels: {}, + openRouterFreeEndpointKeys: new Set(), kiloExclusiveModels: [hidden], }); @@ -159,7 +142,7 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'deepseek' }, models: [model] }], - openRouterModels: {}, + openRouterFreeEndpointKeys: new Set(), kiloExclusiveModels: [dataCollectionExclusiveModel('example/model:discounted', ['deepseek'])], }); @@ -175,7 +158,7 @@ describe('applyFreeEndpointDataPolicy', () => { { provider: { slug: 'stepfun' }, models: [allowed] }, { provider: { slug: 'novita' }, models: [blocked] }, ], - openRouterModels: {}, + openRouterFreeEndpointKeys: new Set(), kiloExclusiveModels: [freeExclusiveModel('example/model:free', ['stepfun'])], }); @@ -192,7 +175,7 @@ describe('applyFreeEndpointDataPolicy', () => { { provider: { slug: 'stepfun' }, models: [first] }, { provider: { slug: 'novita' }, models: [second] }, ], - openRouterModels: {}, + openRouterFreeEndpointKeys: new Set(), kiloExclusiveModels: [ freeExclusiveModel('example/model:free', ['stepfun']), freeExclusiveModel('example/model:promo', ['novita']), @@ -222,10 +205,40 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], - openRouterModels: {}, + openRouterFreeEndpointKeys: new Set(), kiloExclusiveModels: [disabled, paid], }); expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); }); + + test('ignores forbidden OpenRouter and exclusive free models', () => { + const freeVariant = offering('openai/gpt-oss-20b', true); + const model = offering('openai/gpt-oss-20b'); + const providerModelData = [{ provider: { slug: 'darkbloom' }, models: [freeVariant, model] }]; + + applyFreeEndpointDataPolicy({ + providerModelData, + openRouterFreeEndpointKeys: getOpenRouterFreeEndpointKeys(providerModelData), + kiloExclusiveModels: [freeExclusiveModel('openai/gpt-oss-20b:free', [])], + }); + + expect(freeVariant.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + }); + + test('does not collect free keys from offerings injected later', () => { + const model = offering('example/model'); + const providerModelData = [{ provider: { slug: 'novita' }, models: [model] }]; + const openRouterFreeEndpointKeys = getOpenRouterFreeEndpointKeys(providerModelData); + providerModelData[0].models.push(offering('example/model:free', true)); + + applyFreeEndpointDataPolicy({ + providerModelData, + openRouterFreeEndpointKeys, + kiloExclusiveModels: [], + }); + + expect(model.endpoint?.data_policy).toEqual({ training: false, retainsPrompts: false }); + }); }); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts index 4b01b55a2a..431410f72e 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts @@ -1,31 +1,24 @@ import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; +import { + isForbiddenFreeModel, + isForbiddenFreeModelFamily, +} from '@/lib/ai-gateway/forbidden-free-models'; import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import type { OpenRouterModel } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; -import type { StoredModel } from '@kilocode/db/schema-types'; type ProviderModels = Array<{ provider: { slug: string }; models: OpenRouterModel[]; }>; -function isZeroPrice(price: string | undefined): boolean { - if (price === undefined || price.trim() === '') return false; - const value = Number(price); - return Number.isFinite(value) && value === 0; -} - -function freeOpenRouterEndpointKeys(openRouterModels: Record): Set { +export function getOpenRouterFreeEndpointKeys(providerModelData: ProviderModels): Set { const keys = new Set(); - for (const model of Object.values(openRouterModels)) { - const modelId = normalizeModelId(model.id); - for (const endpoint of model.endpoints) { - if ( - endpoint.tag && - isZeroPrice(endpoint.pricing?.prompt) && - isZeroPrice(endpoint.pricing?.completion) - ) { - keys.add(`${modelId}:${normalizeInferenceProviderId(endpoint.tag)}`); + for (const { provider, models } of providerModelData) { + const providerId = normalizeInferenceProviderId(provider.slug); + for (const model of models) { + if (model.endpoint?.is_free && !isForbiddenFreeModelFamily(model.slug)) { + keys.add(`${normalizeModelId(model.slug)}:${providerId}`); } } } @@ -38,7 +31,8 @@ function dataCollectingKiloExclusiveModels( const models = new Map | null>(); for (const model of kiloExclusiveModels) { const collectsData = model.pricing === null || model.flags.includes('requires-data-collection'); - if (model.status !== 'public' || !collectsData) continue; + if (model.status !== 'public' || !collectsData || isForbiddenFreeModel(model.public_id)) + continue; const modelId = normalizeModelId(model.public_id); if (model.inference_provider_restriction.length === 0) { models.set(modelId, null); @@ -61,14 +55,13 @@ function dataCollectingKiloExclusiveModels( export function applyFreeEndpointDataPolicy({ providerModelData, - openRouterModels, + openRouterFreeEndpointKeys, kiloExclusiveModels, }: { providerModelData: ProviderModels; - openRouterModels: Record; + openRouterFreeEndpointKeys: ReadonlySet; kiloExclusiveModels: ReadonlyArray; }): void { - const openRouterEndpointKeys = freeOpenRouterEndpointKeys(openRouterModels); const exclusiveModels = dataCollectingKiloExclusiveModels(kiloExclusiveModels); for (const { provider, models } of providerModelData) { @@ -80,7 +73,7 @@ export function applyFreeEndpointDataPolicy({ const restrictions = exclusiveModels.get(modelId); const hasFreeExclusiveEndpoint = exclusiveModels.has(modelId) && (restrictions === null || restrictions?.has(providerId)); - const hasFreeOpenRouterEndpoint = openRouterEndpointKeys.has(`${modelId}:${providerId}`); + const hasFreeOpenRouterEndpoint = openRouterFreeEndpointKeys.has(`${modelId}:${providerId}`); if (!hasFreeExclusiveEndpoint && !hasFreeOpenRouterEndpoint) continue; model.endpoint.data_policy = { diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts index 243209346b..1a85126150 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts @@ -41,7 +41,11 @@ import { openRouterToVercelInferenceProviderId, VercelInferenceProviderIdSchema, } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; -import { applyFreeEndpointDataPolicy } from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; +import { + applyFreeEndpointDataPolicy, + getOpenRouterFreeEndpointKeys, +} from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; +import { isForbiddenFreeModel } from '@/lib/ai-gateway/forbidden-free-models'; /** * Advisory lock key hashed from a stable identifier. Serializes concurrent @@ -236,7 +240,6 @@ function injectExtraProviderModels( async function syncProviders( providers: OpenRouterProvider[], - openRouterModels: Record, vercelModels: Record ) { if (providers.length === 0) { @@ -267,6 +270,7 @@ async function syncProviders( }) ) ); + const openRouterFreeEndpointKeys = getOpenRouterFreeEndpointKeys(providerModelData); injectExtraProviderModels(vercelModels, providerModelData); @@ -297,9 +301,10 @@ async function syncProviders( prompt: model.pricing.prompt, completion: model.pricing.completion, }, - ...((!kfm.pricing || kfm.flags.includes('requires-data-collection')) && { - data_policy: { training: true, retainsPrompts: true }, - }), + ...((!kfm.pricing || kfm.flags.includes('requires-data-collection')) && + !isForbiddenFreeModel(kfm.public_id) && { + data_policy: { training: true, retainsPrompts: true }, + }), }, }, provider: inferenceProvider, @@ -320,7 +325,7 @@ async function syncProviders( applyFreeEndpointDataPolicy({ providerModelData, - openRouterModels, + openRouterFreeEndpointKeys, kiloExclusiveModels, }); @@ -509,7 +514,7 @@ export async function syncAndStoreProviders() { ); } - const providers = await syncProviders(openrouterProviders, openrouter_data, vercel_data); + const providers = await syncProviders(openrouterProviders, vercel_data); if (providers.total_providers < 10) { throw new Error(`Suspicious: total number of providers is ${providers.total_providers} < 10`); From b8faecf79fc4fdb9fc2bf94fc78d1f65fa460255 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 27 Jul 2026 13:16:52 +0200 Subject: [PATCH 5/6] refactor(ai-gateway): clarify forbidden family helper --- apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts | 8 ++++---- apps/web/src/lib/ai-gateway/forbidden-free-models.ts | 2 +- .../providers/openrouter/free-endpoint-data-policy.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts b/apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts index 21c4d7f692..c7d1ac91ac 100644 --- a/apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts +++ b/apps/web/src/lib/ai-gateway/forbidden-free-models.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from '@jest/globals'; import { + familyHasForbiddenFreeModel, isForbiddenFreeModel, - isForbiddenFreeModelFamily, } from '@/lib/ai-gateway/forbidden-free-models'; describe('forbidden free models', () => { @@ -11,8 +11,8 @@ describe('forbidden free models', () => { }); test('matches normalized families for provider metadata', () => { - expect(isForbiddenFreeModelFamily('openai/gpt-oss-20b:free')).toBe(true); - expect(isForbiddenFreeModelFamily('openai/gpt-oss-20b')).toBe(true); - expect(isForbiddenFreeModelFamily('cohere/north-mini-code')).toBe(false); + expect(familyHasForbiddenFreeModel('openai/gpt-oss-20b:free')).toBe(true); + expect(familyHasForbiddenFreeModel('openai/gpt-oss-20b')).toBe(true); + expect(familyHasForbiddenFreeModel('cohere/north-mini-code')).toBe(false); }); }); 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 d355fbcb40..9ce8522da0 100644 --- a/apps/web/src/lib/ai-gateway/forbidden-free-models.ts +++ b/apps/web/src/lib/ai-gateway/forbidden-free-models.ts @@ -62,6 +62,6 @@ const forbiddenFreeModelFamilies: ReadonlySet = new Set( [...forbiddenFreeModelIds].map(normalizeModelId) ); -export function isForbiddenFreeModelFamily(modelId: string): boolean { +export function familyHasForbiddenFreeModel(modelId: string): boolean { return forbiddenFreeModelFamilies.has(normalizeModelId(modelId)); } diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts index 431410f72e..7408010497 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts @@ -1,7 +1,7 @@ import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; import { + familyHasForbiddenFreeModel, isForbiddenFreeModel, - isForbiddenFreeModelFamily, } from '@/lib/ai-gateway/forbidden-free-models'; import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; import { normalizeInferenceProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; @@ -17,7 +17,7 @@ export function getOpenRouterFreeEndpointKeys(providerModelData: ProviderModels) for (const { provider, models } of providerModelData) { const providerId = normalizeInferenceProviderId(provider.slug); for (const model of models) { - if (model.endpoint?.is_free && !isForbiddenFreeModelFamily(model.slug)) { + if (model.endpoint?.is_free && !familyHasForbiddenFreeModel(model.slug)) { keys.add(`${normalizeModelId(model.slug)}:${providerId}`); } } From cacd3c4074a5c271df8f362ce4d5c70d84df5198 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 27 Jul 2026 13:31:35 +0200 Subject: [PATCH 6/6] refactor(ai-gateway): type free endpoint metadata --- .../free-endpoint-data-policy.test.ts | 26 +++++++++---------- .../openrouter/free-endpoint-data-policy.ts | 23 +++++++++++----- .../providers/openrouter/sync-providers.ts | 6 ++--- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts index ba1492019c..8e783dd699 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from '@jest/globals'; import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; import { applyFreeEndpointDataPolicy, - getOpenRouterFreeEndpointKeys, + getOpenRouterFreeEndpoints, } from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; import type { OpenRouterModel } from '@/lib/ai-gateway/providers/openrouter/openrouter-types'; @@ -74,11 +74,11 @@ describe('applyFreeEndpointDataPolicy', () => { { provider: { slug: 'novita' }, models: [freeVariant, matching] }, { provider: { slug: 'deepinfra' }, models: [otherProvider] }, ]; - const openRouterFreeEndpointKeys = getOpenRouterFreeEndpointKeys(providerModelData); + const openRouterFreeEndpoints = getOpenRouterFreeEndpoints(providerModelData); applyFreeEndpointDataPolicy({ providerModelData, - openRouterFreeEndpointKeys, + openRouterFreeEndpoints, kiloExclusiveModels: [], }); @@ -97,7 +97,7 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], - openRouterFreeEndpointKeys: getOpenRouterFreeEndpointKeys([ + openRouterFreeEndpoints: getOpenRouterFreeEndpoints([ { provider: { slug: 'novita' }, models: [model] }, ]), kiloExclusiveModels: [], @@ -115,7 +115,7 @@ describe('applyFreeEndpointDataPolicy', () => { { provider: { slug: 'novita' }, models: [first] }, { provider: { slug: 'deepinfra' }, models: [second] }, ], - openRouterFreeEndpointKeys: new Set(), + openRouterFreeEndpoints: [], kiloExclusiveModels: [freeExclusiveModel('example/model:free', [])], }); @@ -130,7 +130,7 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], - openRouterFreeEndpointKeys: new Set(), + openRouterFreeEndpoints: [], kiloExclusiveModels: [hidden], }); @@ -142,7 +142,7 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'deepseek' }, models: [model] }], - openRouterFreeEndpointKeys: new Set(), + openRouterFreeEndpoints: [], kiloExclusiveModels: [dataCollectionExclusiveModel('example/model:discounted', ['deepseek'])], }); @@ -158,7 +158,7 @@ describe('applyFreeEndpointDataPolicy', () => { { provider: { slug: 'stepfun' }, models: [allowed] }, { provider: { slug: 'novita' }, models: [blocked] }, ], - openRouterFreeEndpointKeys: new Set(), + openRouterFreeEndpoints: [], kiloExclusiveModels: [freeExclusiveModel('example/model:free', ['stepfun'])], }); @@ -175,7 +175,7 @@ describe('applyFreeEndpointDataPolicy', () => { { provider: { slug: 'stepfun' }, models: [first] }, { provider: { slug: 'novita' }, models: [second] }, ], - openRouterFreeEndpointKeys: new Set(), + openRouterFreeEndpoints: [], kiloExclusiveModels: [ freeExclusiveModel('example/model:free', ['stepfun']), freeExclusiveModel('example/model:promo', ['novita']), @@ -205,7 +205,7 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData: [{ provider: { slug: 'novita' }, models: [model] }], - openRouterFreeEndpointKeys: new Set(), + openRouterFreeEndpoints: [], kiloExclusiveModels: [disabled, paid], }); @@ -219,7 +219,7 @@ describe('applyFreeEndpointDataPolicy', () => { applyFreeEndpointDataPolicy({ providerModelData, - openRouterFreeEndpointKeys: getOpenRouterFreeEndpointKeys(providerModelData), + openRouterFreeEndpoints: getOpenRouterFreeEndpoints(providerModelData), kiloExclusiveModels: [freeExclusiveModel('openai/gpt-oss-20b:free', [])], }); @@ -230,12 +230,12 @@ describe('applyFreeEndpointDataPolicy', () => { test('does not collect free keys from offerings injected later', () => { const model = offering('example/model'); const providerModelData = [{ provider: { slug: 'novita' }, models: [model] }]; - const openRouterFreeEndpointKeys = getOpenRouterFreeEndpointKeys(providerModelData); + const openRouterFreeEndpoints = getOpenRouterFreeEndpoints(providerModelData); providerModelData[0].models.push(offering('example/model:free', true)); applyFreeEndpointDataPolicy({ providerModelData, - openRouterFreeEndpointKeys, + openRouterFreeEndpoints, kiloExclusiveModels: [], }); diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts index 7408010497..6d84de91df 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy.ts @@ -12,17 +12,24 @@ type ProviderModels = Array<{ models: OpenRouterModel[]; }>; -export function getOpenRouterFreeEndpointKeys(providerModelData: ProviderModels): Set { - const keys = new Set(); +export type OpenRouterFreeEndpoint = { + modelId: string; + providerId: string; +}; + +export function getOpenRouterFreeEndpoints( + providerModelData: ProviderModels +): OpenRouterFreeEndpoint[] { + const endpoints: OpenRouterFreeEndpoint[] = []; for (const { provider, models } of providerModelData) { const providerId = normalizeInferenceProviderId(provider.slug); for (const model of models) { if (model.endpoint?.is_free && !familyHasForbiddenFreeModel(model.slug)) { - keys.add(`${normalizeModelId(model.slug)}:${providerId}`); + endpoints.push({ modelId: normalizeModelId(model.slug), providerId }); } } } - return keys; + return endpoints; } function dataCollectingKiloExclusiveModels( @@ -55,11 +62,11 @@ function dataCollectingKiloExclusiveModels( export function applyFreeEndpointDataPolicy({ providerModelData, - openRouterFreeEndpointKeys, + openRouterFreeEndpoints, kiloExclusiveModels, }: { providerModelData: ProviderModels; - openRouterFreeEndpointKeys: ReadonlySet; + openRouterFreeEndpoints: ReadonlyArray; kiloExclusiveModels: ReadonlyArray; }): void { const exclusiveModels = dataCollectingKiloExclusiveModels(kiloExclusiveModels); @@ -73,7 +80,9 @@ export function applyFreeEndpointDataPolicy({ const restrictions = exclusiveModels.get(modelId); const hasFreeExclusiveEndpoint = exclusiveModels.has(modelId) && (restrictions === null || restrictions?.has(providerId)); - const hasFreeOpenRouterEndpoint = openRouterFreeEndpointKeys.has(`${modelId}:${providerId}`); + const hasFreeOpenRouterEndpoint = openRouterFreeEndpoints.some( + endpoint => endpoint.modelId === modelId && endpoint.providerId === providerId + ); if (!hasFreeExclusiveEndpoint && !hasFreeOpenRouterEndpoint) continue; model.endpoint.data_policy = { diff --git a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts index 1a85126150..fab738fb00 100644 --- a/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts +++ b/apps/web/src/lib/ai-gateway/providers/openrouter/sync-providers.ts @@ -43,7 +43,7 @@ import { } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; import { applyFreeEndpointDataPolicy, - getOpenRouterFreeEndpointKeys, + getOpenRouterFreeEndpoints, } from '@/lib/ai-gateway/providers/openrouter/free-endpoint-data-policy'; import { isForbiddenFreeModel } from '@/lib/ai-gateway/forbidden-free-models'; @@ -270,7 +270,7 @@ async function syncProviders( }) ) ); - const openRouterFreeEndpointKeys = getOpenRouterFreeEndpointKeys(providerModelData); + const openRouterFreeEndpoints = getOpenRouterFreeEndpoints(providerModelData); injectExtraProviderModels(vercelModels, providerModelData); @@ -325,7 +325,7 @@ async function syncProviders( applyFreeEndpointDataPolicy({ providerModelData, - openRouterFreeEndpointKeys, + openRouterFreeEndpoints, kiloExclusiveModels, });