Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added gui/public/logos/openrouter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
262 changes: 183 additions & 79 deletions gui/src/components/modelSelection/ModelSelectionListbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,16 +20,86 @@ 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({
selectedProvider,
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 (
<Listbox value={selectedProvider} onChange={setSelectedProvider}>
<Listbox
value={selectedProvider}
onChange={(value) => {
setSelectedProvider(value);
setSearchQuery("");
}}
>
<div className="relative mb-2 mt-1">
<ListboxButton className="bg-background border-border text-foreground hover:bg-input relative m-0 grid h-full w-full cursor-pointer grid-cols-[1fr_auto] items-center rounded-lg border border-solid py-2 pl-3 pr-10 text-left focus:outline-none">
<span className="flex items-center">
Expand All @@ -54,87 +125,120 @@ function ModelSelectionListbox({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="bg-input rounded-default absolute left-0 top-full z-10 mt-1 h-fit w-3/5 overflow-y-auto p-0 focus:outline-none [&]:!max-h-[30vh]">
{topOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Popular
</div>
{topOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${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 }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
)}
<span className="text-xs">{option.title}</span>
</div>
{selected && (
<CheckIcon className="h-3 w-3" aria-hidden="true" />
)}
</>
)}
</ListboxOption>
))}
<ListboxOptions className="bg-input rounded-default absolute left-0 top-full z-10 mt-1 flex h-fit w-3/5 flex-col overflow-y-auto p-0 focus:outline-none [&]:!max-h-[30vh]">
{/* Search Box */}
<div className="border-border sticky top-0 border-b p-2">
<div className="bg-background border-border flex items-center rounded border pl-2">
<MagnifyingGlassIcon className="text-description-muted h-4 w-4" />
<input
type="text"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => 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()}
/>
</div>
)}
{topOptions.length > 0 && otherOptions.length > 0 && (
<div className="bg-border my-1 h-px min-h-px" />
)}
{otherOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Additional providers
</div>

{/* Results */}
<div className="flex-1 overflow-y-auto">
{!hasResults ? (
<div className="text-description-muted px-3 py-4 text-center text-xs">
No models found matching "{searchQuery}"
</div>
{otherOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${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 }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
) : (
<>
{filteredTopOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Popular
</div>
{filteredTopOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${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 }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
)}
<span className="text-xs">{option.title}</span>
</div>
{selected && (
<CheckIcon
className="h-3 w-3"
aria-hidden="true"
/>
)}
</>
)}
<span className="text-xs">{option.title}</span>
</div>

{selected && (
<CheckIcon className="h-3 w-3" aria-hidden="true" />
)}
</>
</ListboxOption>
))}
</div>
)}
{filteredTopOptions.length > 0 &&
filteredOtherOptions.length > 0 && (
<div className="bg-border my-1 h-px min-h-px" />
)}
</ListboxOption>
))}
</div>
)}
{filteredOtherOptions.length > 0 && (
<div className="py-1">
<div className="text-description-muted px-3 py-1 text-xs font-medium uppercase tracking-wider">
Additional providers
</div>
{filteredOtherOptions.map((option, index) => (
<ListboxOption
key={index}
className={({ selected }: { selected: boolean }) =>
` ${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 }) => (
<>
<div className="flex items-center">
{option.title === "Autodetect" ? (
<CubeIcon className="mr-2 h-4 w-4" />
) : (
window.vscMediaUrl &&
option.icon && (
<img
src={`${window.vscMediaUrl}/logos/${option.icon}`}
className="mr-2 h-4 w-4 object-contain object-center"
/>
)
)}
<span className="text-xs">{option.title}</span>
</div>

{selected && (
<CheckIcon
className="h-3 w-3"
aria-hidden="true"
/>
)}
</>
)}
</ListboxOption>
))}
</div>
)}
</>
)}
</div>
</ListboxOptions>
</Transition>
</div>
Expand Down
2 changes: 2 additions & 0 deletions gui/src/forms/AddModelForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function AddModelForm({
providers["gemini"]?.title || "",
providers["azure"]?.title || "",
providers["ollama"]?.title || "",
providers["openrouter"]?.title || "",
];

const allProviders = Object.entries(providers)
Expand Down Expand Up @@ -149,6 +150,7 @@ export function AddModelForm({
}}
topOptions={popularProviders}
otherOptions={otherProviders}
searchPlaceholder="Search providers..."
/>
<span className="text-description-muted mt-1 block text-xs">
Don't see your provider?{" "}
Expand Down
74 changes: 74 additions & 0 deletions gui/src/pages/AddNewModel/configs/openRouterModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ModelPackage } from "./models";
import openRouterModelsData from "./openRouterModels.json";

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],
};
}

/**
* Generate ModelPackage objects from OpenRouter models JSON
*/
export function generateOpenRouterModels(): {
[key: string]: ModelPackage;
} {
const models: { [key: string]: ModelPackage } = {};

const data = openRouterModelsData as { data: OpenRouterModel[] };

if (!data.data || !Array.isArray(data.data)) {
console.warn("Invalid OpenRouter models data structure");
return models;
}

data.data.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 all OpenRouter models as a pre-generated object
*/
export const openRouterModels = generateOpenRouterModels();

/**
* Export OpenRouter models as an array for use in provider packages
*/
export const openRouterModelsList = Object.values(openRouterModels);
Loading