diff --git a/gui/public/logos/openrouter.png b/gui/public/logos/openrouter.png new file mode 100644 index 00000000000..04f998a53af Binary files /dev/null and b/gui/public/logos/openrouter.png differ diff --git a/gui/src/components/modelSelection/ModelSelectionListbox.tsx b/gui/src/components/modelSelection/ModelSelectionListbox.tsx index 04510b6bb33..87ba2822c56 100644 --- a/gui/src/components/modelSelection/ModelSelectionListbox.tsx +++ b/gui/src/components/modelSelection/ModelSelectionListbox.tsx @@ -2,8 +2,9 @@ import { CheckIcon, ChevronUpDownIcon, CubeIcon, + MagnifyingGlassIcon, } from "@heroicons/react/24/outline"; -import { Fragment } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { Listbox, ListboxButton, @@ -19,6 +20,34 @@ interface ModelSelectionListboxProps { setSelectedProvider: (val: DisplayInfo) => void; topOptions?: DisplayInfo[]; otherOptions?: DisplayInfo[]; + searchPlaceholder?: string; +} + +/** + * Simple fuzzy search algorithm + * Returns a score based on how well the query matches the text + */ +function fuzzyScore(query: string, text: string): number { + const q = query.toLowerCase(); + const t = text.toLowerCase(); + + if (!q) return 1; // Empty query matches everything + if (!t) return 0; + + let score = 0; + let queryIdx = 0; + let lastMatchIdx = -1; + + for (let i = 0; i < t.length && queryIdx < q.length; i++) { + if (t[i] === q[queryIdx]) { + score += 1 + (lastMatchIdx === i - 1 ? 5 : 0); // Bonus for consecutive matches + lastMatchIdx = i; + queryIdx++; + } + } + + // Return 0 if not all query characters were found + return queryIdx === q.length ? score / t.length : 0; } function ModelSelectionListbox({ @@ -26,9 +55,51 @@ function ModelSelectionListbox({ setSelectedProvider, topOptions = [], otherOptions = [], + searchPlaceholder = "Search models...", }: ModelSelectionListboxProps) { + const [searchQuery, setSearchQuery] = useState(""); + + // Clear search query when provider changes + useEffect(() => { + setSearchQuery(""); + }, [selectedProvider]); + + // Combine and filter options based on fuzzy search + const filteredTopOptions = useMemo(() => { + if (!searchQuery) return topOptions; + return topOptions + .map((opt) => ({ + option: opt, + score: fuzzyScore(searchQuery, opt.title), + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ option }) => option); + }, [searchQuery, topOptions]); + + const filteredOtherOptions = useMemo(() => { + if (!searchQuery) return otherOptions; + return otherOptions + .map((opt) => ({ + option: opt, + score: fuzzyScore(searchQuery, opt.title), + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ option }) => option); + }, [searchQuery, otherOptions]); + + const hasResults = + filteredTopOptions.length > 0 || filteredOtherOptions.length > 0; + return ( - + { + setSelectedProvider(value); + setSearchQuery(""); + }} + >
@@ -54,87 +125,120 @@ function ModelSelectionListbox({ leaveFrom="opacity-100" leaveTo="opacity-0" > - - {topOptions.length > 0 && ( -
-
- Popular -
- {topOptions.map((option, index) => ( - - ` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-default cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4` - } - value={option} - > - {({ selected }) => ( - <> -
- {option.title === "Autodetect" ? ( - - ) : ( - window.vscMediaUrl && - option.icon && ( - - ) - )} - {option.title} -
- {selected && ( -
- ))} + + {/* Search Box */} +
+
+ + setSearchQuery(e.target.value)} + className="bg-background text-foreground placeholder-description-muted w-full border-0 px-2 py-1.5 outline-none" + onClick={(e) => e.stopPropagation()} + />
- )} - {topOptions.length > 0 && otherOptions.length > 0 && ( -
- )} - {otherOptions.length > 0 && ( -
-
- Additional providers +
+ + {/* Results */} +
+ {!hasResults ? ( +
+ No models found matching "{searchQuery}"
- {otherOptions.map((option, index) => ( - - ` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-default cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4` - } - value={option} - > - {({ selected }) => ( - <> -
- {option.title === "Autodetect" ? ( - - ) : ( - window.vscMediaUrl && - option.icon && ( - - ) + ) : ( + <> + {filteredTopOptions.length > 0 && ( +
+
+ Popular +
+ {filteredTopOptions.map((option, index) => ( + + ` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4` + } + value={option} + > + {({ selected }) => ( + <> +
+ {option.title === "Autodetect" ? ( + + ) : ( + window.vscMediaUrl && + option.icon && ( + + ) + )} + {option.title} +
+ {selected && ( +
- - {selected && ( -
+ )} + {filteredTopOptions.length > 0 && + filteredOtherOptions.length > 0 && ( +
)} - - ))} -
- )} + {filteredOtherOptions.length > 0 && ( +
+
+ Additional providers +
+ {filteredOtherOptions.map((option, index) => ( + + ` ${selected ? "bg-list-active" : "bg-input"} hover:bg-list-active hover:text-list-active-foreground relative flex cursor-pointer select-none items-center justify-between gap-2 p-1.5 px-3 py-2 pr-4` + } + value={option} + > + {({ selected }) => ( + <> +
+ {option.title === "Autodetect" ? ( + + ) : ( + window.vscMediaUrl && + option.icon && ( + + ) + )} + {option.title} +
+ + {selected && ( +
+ ))} +
+ )} + + )} +
diff --git a/gui/src/forms/AddModelForm.tsx b/gui/src/forms/AddModelForm.tsx index 61a7142992e..ae28cd07423 100644 --- a/gui/src/forms/AddModelForm.tsx +++ b/gui/src/forms/AddModelForm.tsx @@ -9,6 +9,7 @@ import { IdeMessengerContext } from "../context/IdeMessenger"; import { completionParamsInputs } from "../pages/AddNewModel/configs/completionParamsInputs"; import { DisplayInfo } from "../pages/AddNewModel/configs/models"; import { + initializeOpenRouterModels, ProviderInfo, providers, } from "../pages/AddNewModel/configs/providers"; @@ -40,6 +41,11 @@ export function AddModelForm({ const formMethods = useForm(); const ideMessenger = useContext(IdeMessengerContext); + // Initialize OpenRouter models from API on component mount + useEffect(() => { + void initializeOpenRouterModels(); + }, []); + const popularProviderTitles = [ providers["openai"]?.title || "", providers["anthropic"]?.title || "", @@ -47,6 +53,7 @@ export function AddModelForm({ providers["gemini"]?.title || "", providers["azure"]?.title || "", providers["ollama"]?.title || "", + providers["openrouter"]?.title || "", ]; const allProviders = Object.entries(providers) @@ -63,11 +70,10 @@ export function AddModelForm({ .filter((provider) => !popularProviderTitles.includes(provider.title)) .sort((a, b) => a.title.localeCompare(b.title)); - const selectedProviderApiKeyUrl = selectedModel.params.model.startsWith( - "codestral", - ) - ? CODESTRAL_URL - : selectedProvider.apiKeyUrl; + const selectedProviderApiKeyUrl = + selectedModel && selectedModel.params.model.startsWith("codestral") + ? CODESTRAL_URL + : selectedProvider.apiKeyUrl; function isDisabled() { if (selectedProvider.downloadUrl) { @@ -149,6 +155,7 @@ export function AddModelForm({ }} topOptions={popularProviders} otherOptions={otherProviders} + searchPlaceholder="Search providers..." /> Don't see your provider?{" "} diff --git a/gui/src/pages/AddNewModel/configs/openRouterModel.ts b/gui/src/pages/AddNewModel/configs/openRouterModel.ts new file mode 100644 index 00000000000..f0d7e745a49 --- /dev/null +++ b/gui/src/pages/AddNewModel/configs/openRouterModel.ts @@ -0,0 +1,105 @@ +import { ModelPackage } from "./models"; + +interface OpenRouterModel { + id: string; + name: string; + description: string; + context_length: number; + hugging_face_id: string; +} + +/** + * Convert OpenRouter model data to ModelPackage format + */ +function convertOpenRouterModelToPackage(model: OpenRouterModel): ModelPackage { + // Extract provider name from id (e.g., "openai/gpt-5.1" -> "openai") + const [provider] = model.id.split("/"); + + return { + title: model.name, + description: model.description, + refUrl: `https://openrouter.ai/models/${model.id}`, + params: { + model: model.id, + contextLength: model.context_length, + }, + isOpenSource: !!model.hugging_face_id, + tags: [provider as any], + }; +} + +/** + * Fetch OpenRouter models from the API + */ +async function fetchOpenRouterModelsFromAPI(): Promise { + const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"; + + try { + const response = await fetch(OPENROUTER_API_URL); + + if (!response.ok) { + throw new Error( + `Failed to fetch OpenRouter models: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + + if (!data.data || !Array.isArray(data.data)) { + console.warn("Invalid OpenRouter models data structure from API"); + return []; + } + + return data.data; + } catch (error) { + console.error("Error fetching OpenRouter models from API:", error); + return []; + } +} + +/** + * Generate ModelPackage objects from OpenRouter models API + */ +async function generateOpenRouterModels(): Promise<{ + [key: string]: ModelPackage; +}> { + const models: { [key: string]: ModelPackage } = {}; + + const apiModels = await fetchOpenRouterModelsFromAPI(); + + apiModels.forEach((model: OpenRouterModel) => { + if (!model.id || !model.name) { + console.warn("Skipping model with missing id or name", model); + return; + } + + // Create a unique key from the model id (replace slashes and dots with underscores) + const key = model.id.replace(/[\/.]/g, "_"); + + try { + models[key] = convertOpenRouterModelToPackage(model); + } catch (error) { + console.error(`Failed to convert model ${model.id}:`, error); + } + }); + + return models; +} + +/** + * Export a function to fetch all OpenRouter models + * This returns a promise since we're now fetching from the API + */ +export async function getOpenRouterModels(): Promise<{ + [key: string]: ModelPackage; +}> { + return generateOpenRouterModels(); +} + +/** + * Export a function to get OpenRouter models as an array + */ +export async function getOpenRouterModelsList(): Promise { + const models = await getOpenRouterModels(); + return Object.values(models); +} diff --git a/gui/src/pages/AddNewModel/configs/providers.ts b/gui/src/pages/AddNewModel/configs/providers.ts index 1bab7abeb81..273bf97c549 100644 --- a/gui/src/pages/AddNewModel/configs/providers.ts +++ b/gui/src/pages/AddNewModel/configs/providers.ts @@ -3,6 +3,7 @@ import { ModelProviderTags } from "../../../components/modelSelection/utils"; import { completionParamsInputs } from "./completionParamsInputs"; import type { ModelPackage } from "./models"; import { models } from "./models"; +import { getOpenRouterModelsList } from "./openRouterModel"; export interface InputDescriptor { inputType: HTMLInputTypeAttribute; @@ -40,6 +41,39 @@ const openSourceModels = Object.values(models).filter( ({ isOpenSource }) => isOpenSource, ); +// Initialize OpenRouter models placeholder with a loading placeholder +const OPENROUTER_LOADING_PLACEHOLDER: ModelPackage = { + title: "Loading models...", + description: "Fetching available models from OpenRouter", + params: { + model: "placeholder", + contextLength: 0, + }, + isOpenSource: false, +}; + +let openRouterModelsList: ModelPackage[] = [OPENROUTER_LOADING_PLACEHOLDER]; + +/** + * Initialize OpenRouter models by fetching from the API + * This should be called once when the component mounts + */ +export async function initializeOpenRouterModels() { + try { + const models = await getOpenRouterModelsList(); + if (models.length > 0) { + openRouterModelsList = models; + // Update the providers object with the fetched models + if (providers.openrouter) { + providers.openrouter.packages = openRouterModelsList; + } + } + } catch (error) { + console.error("Failed to initialize OpenRouter models:", error); + // Keep placeholder on error so the UI doesn't break + } +} + export const apiBaseInput: InputDescriptor = { inputType: "text", key: "apiBase", @@ -170,6 +204,29 @@ export const providers: Partial> = { packages: [models.claude4Sonnet, models.claude41Opus, models.claude35Haiku], apiKeyUrl: "https://console.anthropic.com/account/keys", }, + openrouter: { + title: "OpenRouter", + provider: "openrouter", + description: + "OpenRouter provides access to a variety of LLMs including open-source and proprietary models.", + longDescription: `To get started with OpenRouter, sign up for an account at [openrouter.ai](https://openrouter.ai/) and obtain your API key from the dashboard.`, + icon: "openrouter.png", + tags: [ModelProviderTags.RequiresApiKey], + refPage: "openrouter", + apiKeyUrl: "https://openrouter.ai/settings/keys", + collectInputFor: [ + { + inputType: "text", + key: "apiKey", + label: "API Key", + placeholder: "Enter your OpenRouter API key", + required: true, + }, + ...completionParamsInputsConfigs, + ], + packages: openRouterModelsList, + }, + moonshot: { title: "Moonshot", provider: "moonshot",