diff --git a/src/app/api/openrouter/[...path]/route.ts b/src/app/api/openrouter/[...path]/route.ts index 60bec65689..4444ab98e3 100644 --- a/src/app/api/openrouter/[...path]/route.ts +++ b/src/app/api/openrouter/[...path]/route.ts @@ -49,11 +49,10 @@ import { } from '@/lib/anonymous'; import { checkFreeModelRateLimit, logFreeModelRequest } from '@/lib/free-model-rate-limiter'; import { classifyAbuse } from '@/lib/abuse-service'; +import { KILO_AUTO_MODEL_ID } from '@/lib/kilo-auto-model'; const MAX_TOKENS_LIMIT = 99999999999; // GPT4.1 default is ~32k -const AUTO_MODEL_ID = 'kilo/auto'; - const OPUS = 'anthropic/claude-opus-4.5'; const SONNET = 'anthropic/claude-sonnet-4.5'; @@ -119,7 +118,7 @@ export async function POST(request: NextRequest): Promise({ queryKey: ['openrouter-models'], @@ -169,7 +207,9 @@ export function useOpenRouterModelsAndProviders() { } } - return [...modelBySlug.values()]; + const modelsWithEndpoints = [...modelBySlug.values()]; + const hasAutoAlready = modelsWithEndpoints.some(model => model.slug === KILO_AUTO_MODEL_ID); + return hasAutoAlready ? modelsWithEndpoints : [buildKiloAutoModel(), ...modelsWithEndpoints]; }, [query.data]); return { diff --git a/src/lib/kilo-auto-model.ts b/src/lib/kilo-auto-model.ts new file mode 100644 index 0000000000..756139ae68 --- /dev/null +++ b/src/lib/kilo-auto-model.ts @@ -0,0 +1,13 @@ +export const KILO_AUTO_MODEL_ID = 'kilo/auto'; + +export const KILO_AUTO_MODEL_NAME = 'Kilo: Auto'; + +export const KILO_AUTO_MODEL_DESCRIPTION = + 'Automatically routes your request to the best model for the task.'; + +export const KILO_AUTO_MODEL_CONTEXT_LENGTH = 200_000; +export const KILO_AUTO_MODEL_MAX_COMPLETION_TOKENS = 64_000; + +// Keep non-zero so "limited access" UIs don't treat it as free. +export const KILO_AUTO_MODEL_PROMPT_PRICE = '0.0000010'; +export const KILO_AUTO_MODEL_COMPLETION_PRICE = '0.0000010'; diff --git a/src/lib/providers/openrouter/index.ts b/src/lib/providers/openrouter/index.ts index a09777a301..b037545d53 100644 --- a/src/lib/providers/openrouter/index.ts +++ b/src/lib/providers/openrouter/index.ts @@ -9,6 +9,15 @@ import { errorExceptInTest } from '@/lib/utils.server'; import { captureException, captureMessage } from '@sentry/nextjs'; import { convertFromKiloModel } from '@/lib/providers/kilo-free-model'; import { getModelSettings, getVersionedModelSettings } from '@/lib/providers/recommended-models'; +import { + KILO_AUTO_MODEL_COMPLETION_PRICE, + KILO_AUTO_MODEL_CONTEXT_LENGTH, + KILO_AUTO_MODEL_DESCRIPTION, + KILO_AUTO_MODEL_ID, + KILO_AUTO_MODEL_MAX_COMPLETION_TOKENS, + KILO_AUTO_MODEL_NAME, + KILO_AUTO_MODEL_PROMPT_PRICE, +} from '@/lib/kilo-auto-model'; // Re-export from shared module for backwards compatibility export { normalizeModelId } from '@/lib/model-utils'; @@ -17,7 +26,37 @@ export function isRateLimitedToDeathFree(model: string) { return model.endsWith(':free') && !isFreeModel(model); } +function buildAutoModel(): OpenRouterModel { + return { + id: KILO_AUTO_MODEL_ID, + name: KILO_AUTO_MODEL_NAME, + created: 0, + description: KILO_AUTO_MODEL_DESCRIPTION, + architecture: { + input_modalities: ['text'], + output_modalities: ['text'], + tokenizer: 'Other', + }, + top_provider: { + is_moderated: false, + context_length: KILO_AUTO_MODEL_CONTEXT_LENGTH, + max_completion_tokens: KILO_AUTO_MODEL_MAX_COMPLETION_TOKENS, + }, + pricing: { + prompt: KILO_AUTO_MODEL_PROMPT_PRICE, + completion: KILO_AUTO_MODEL_COMPLETION_PRICE, + request: '0', + image: '0', + web_search: '0', + internal_reasoning: '0', + }, + context_length: KILO_AUTO_MODEL_CONTEXT_LENGTH, + supported_parameters: ['max_tokens', 'temperature', 'tools', 'reasoning', 'include_reasoning'], + }; +} + function enhancedModelList(models: OpenRouterModel[]) { + const autoModel = buildAutoModel(); const enhancedModels = models .filter( (model: OpenRouterModel) => @@ -25,14 +64,17 @@ function enhancedModelList(models: OpenRouterModel[]) { !kiloFreeModels.some(m => m.public_id === model.id && m.is_enabled) ) .concat(kiloFreeModels.filter(m => m.is_enabled).map(model => convertFromKiloModel(model))) + .concat([autoModel]) .map((model: OpenRouterModel) => { - const preferredIndex = preferredModels.indexOf(model.id); + const preferredIndex = + model.id === KILO_AUTO_MODEL_ID ? -1 : preferredModels.indexOf(model.id); const ageDays = (Date.now() / 1_000 - model.created) / (24 * 3600); const isNew = preferredIndex >= 0 && ageDays >= 0 && ageDays < 7; return { ...model, name: isNew ? model.name + ' (new)' : model.name, - preferredIndex: preferredIndex >= 0 ? preferredIndex : undefined, + preferredIndex: + preferredIndex >= 0 || model.id === KILO_AUTO_MODEL_ID ? preferredIndex : undefined, settings: getModelSettings(model.id), versioned_settings: getVersionedModelSettings(model.id), }; diff --git a/src/lib/providers/recommended-models.ts b/src/lib/providers/recommended-models.ts index 3eba8a93dd..d057b8f09a 100644 --- a/src/lib/providers/recommended-models.ts +++ b/src/lib/providers/recommended-models.ts @@ -1,4 +1,5 @@ import type { ModelSettings, VersionedSettings } from '@/lib/organizations/organization-types'; +import { KILO_AUTO_MODEL_ID } from '@/lib/kilo-auto-model'; import { giga_potato_model } from '@/lib/providers/gigapotato'; import { minimax_m21_free_model } from '@/lib/providers/minimax'; import { zai_glm47_free_model } from '@/lib/providers/zai'; @@ -10,6 +11,11 @@ export type RecommendedModel = { }; export const recommendedModels = [ + { + public_id: KILO_AUTO_MODEL_ID, + tool_choice_required: false, + random_vercel_routing: true, + }, { public_id: minimax_m21_free_model.is_enabled ? minimax_m21_free_model.public_id diff --git a/src/tests/openrouter-models-sorting.approved.json b/src/tests/openrouter-models-sorting.approved.json index ca62d2d5c9..dcf394c906 100644 --- a/src/tests/openrouter-models-sorting.approved.json +++ b/src/tests/openrouter-models-sorting.approved.json @@ -1,5 +1,42 @@ { "data": [ + { + "id": "kilo/auto", + "name": "Kilo: Auto", + "created": 0, + "description": "Automatically routes your request to the best model for the task.", + "architecture": { + "input_modalities": [ + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Other" + }, + "top_provider": { + "is_moderated": false, + "context_length": 200000, + "max_completion_tokens": 64000 + }, + "pricing": { + "prompt": "0.0000010", + "completion": "0.0000010", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0" + }, + "context_length": 200000, + "supported_parameters": [ + "max_tokens", + "temperature", + "tools", + "reasoning", + "include_reasoning" + ], + "preferredIndex": -1 + }, { "id": "minimax/minimax-m2.1:free", "canonical_slug": "minimax/minimax-m2.1:free", @@ -41,7 +78,7 @@ "include_reasoning" ], "default_parameters": {}, - "preferredIndex": 0, + "preferredIndex": 1, "settings": { "included_tools": [ "search_and_replace" @@ -93,7 +130,7 @@ "include_reasoning" ], "default_parameters": {}, - "preferredIndex": 1, + "preferredIndex": 2, "versioned_settings": { "4.146.0": { "included_tools": [ @@ -149,7 +186,7 @@ "include_reasoning" ], "default_parameters": {}, - "preferredIndex": 3, + "preferredIndex": 4, "versioned_settings": { "4.146.0": { "included_tools": [ @@ -201,7 +238,7 @@ "tools" ], "default_parameters": {}, - "preferredIndex": 4 + "preferredIndex": 5 }, { "id": "anthropic/claude-sonnet-4",