From a693124be8d3273322cfc2e8a13d764d2ced9b93 Mon Sep 17 00:00:00 2001 From: Rodolfo Nobrega Date: Wed, 20 May 2026 20:36:10 -0300 Subject: [PATCH] Add multi-select filters and multi-column sorting - Provider filter now supports selecting multiple providers at once - Mode filter also supports multi-selection (Chat, Embedding, etc.) - Sort by multiple columns with priority ordering - All advanced filters collapsed into a clean expandable panel - Fix sticky header bug causing first table row to be hidden - Filter out sample_spec from the model list - Add all missing mode labels (OCR, Video Gen, Realtime, etc.) Co-authored-by: Cursor --- src/App.svelte | 783 +++++++++++++++++++++++++++++++++--- src/ProviderDropdown.svelte | 68 +++- 2 files changed, 774 insertions(+), 77 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 3b1c2f4..a3645dd 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -24,13 +24,29 @@ const RESOURCE_PATH = `${RESOURCE_NAME}`; const RESOURCE_BACKUP_PATH = `litellm/${RESOURCE_BACKUP_NAME}`; let providers: string[] = []; - let selectedProvider: string = ""; + let modes: string[] = []; + let selectedProviders: string[] = []; + let selectedModes: string[] = []; + let modeDropdownOpen = false; let maxInputTokens: number | null = null; let maxOutputTokens: number | null = null; + let maxInputCost: number | null = null; + let maxOutputCost: number | null = null; + let advancedFiltersOpen = false; + let capabilityFilters: Record = { + supports_function_calling: false, + supports_vision: false, + supports_response_schema: false, + supports_tool_choice: false, + supports_parallel_function_calling: false, + supports_audio_input: false, + supports_prompt_caching: false, + }; - // Sorting state - let sortColumn: string = ""; - let sortDirection: "asc" | "desc" = "asc"; + // Sorting state (multi-column) + type SortCriterion = { column: string; direction: "asc" | "desc" }; + let sortCriteria: SortCriterion[] = [{ column: "name", direction: "asc" }]; + let newSortColumn: string = ""; // Copy toast let copiedModel = ""; @@ -49,6 +65,13 @@ } } + function handleWindowClick(e: MouseEvent) { + const target = e.target as HTMLElement; + if (!target.closest(".mode-dropdown")) { + modeDropdownOpen = false; + } + } + // Quick start tab state per model let codeTabStates: Record = {}; @@ -75,12 +98,14 @@ .then((res) => res.text()) .then((text) => { lines = text.split("\n"); - const items: Item[] = Object.entries(JSON.parse(text)).map( - ([k, v]: any) => ({ name: k, ...v }), - ); + const items: Item[] = Object.entries(JSON.parse(text)) + .map(([k, v]: any) => ({ name: k, ...v })) + .filter((item) => item.name !== "sample_spec"); - providers = [...new Set(items.map((i) => i.litellm_provider))]; + providers = [...new Set(items.map((i) => i.litellm_provider).filter(Boolean))]; providers.sort(); + modes = [...new Set(items.map((i) => i.mode).filter(Boolean))]; + modes.sort(); index = new Fuse(items, { threshold: 0.3, @@ -95,6 +120,7 @@ }); results = items.map((item, refIndex) => ({ item, refIndex })); + applySorting(); loading = false; }); }); @@ -159,7 +185,16 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ $: { if (index) { - filterResults(query, selectedProvider, maxInputTokens, maxOutputTokens); + filterResults( + query, + selectedProviders, + selectedModes, + maxInputTokens, + maxOutputTokens, + maxInputCost, + maxOutputCost, + capabilityFilters, + ); } } @@ -190,18 +225,58 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ } function handleSort(column: string) { - if (sortColumn === column) { - sortDirection = sortDirection === "asc" ? "desc" : "asc"; + const existing = sortCriteria.find((c) => c.column === column); + if (existing) { + sortCriteria = sortCriteria.map((c) => + c.column === column + ? { ...c, direction: c.direction === "asc" ? "desc" : "asc" } + : c, + ); } else { - sortColumn = column; - sortDirection = "asc"; + sortCriteria = [{ column, direction: "asc" }]; } applySorting(); } - function getSortValue(item: any, column: string): number { + function addSortCriterion() { + if (!newSortColumn) return; + if (sortCriteria.some((c) => c.column === newSortColumn)) return; + sortCriteria = [...sortCriteria, { column: newSortColumn, direction: "asc" }]; + newSortColumn = ""; + applySorting(); + } + + function removeSortCriterion(column: string) { + sortCriteria = sortCriteria.filter((c) => c.column !== column); + applySorting(); + } + + function toggleSortDirection(column: string) { + sortCriteria = sortCriteria.map((c) => + c.column === column + ? { ...c, direction: c.direction === "asc" ? "desc" : "asc" } + : c, + ); + applySorting(); + } + + function getSortLabel(column: string): string { + const labels: Record = { + name: "Model name", provider: "Provider", mode: "Mode", + context: "Input context", output_context: "Output context", + input: "Input cost", output: "Output cost", + cache_read: "Cache read", cache_write: "Cache write", + }; + return labels[column] || column; + } + + function getSortValue(item: any, column: string): number | string { switch (column) { + case "name": return getDisplayModelName(item.name, item.litellm_provider).toLowerCase(); + case "provider": return (item.litellm_provider || "").toLowerCase(); + case "mode": return (item.mode || "").toLowerCase(); case "context": return item.max_input_tokens || 0; + case "output_context": return item.max_output_tokens || 0; case "input": return item.input_cost_per_token || 0; case "output": return item.output_cost_per_token || 0; case "cache_read": return item.cache_read_input_token_cost || 0; @@ -211,14 +286,57 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ } function applySorting() { - if (!sortColumn) return; + if (sortCriteria.length === 0) return; results = [...results].sort((a, b) => { - const aVal = getSortValue(a.item, sortColumn); - const bVal = getSortValue(b.item, sortColumn); - return sortDirection === "asc" ? aVal - bVal : bVal - aVal; + for (const criterion of sortCriteria) { + const aVal = getSortValue(a.item, criterion.column); + const bVal = getSortValue(b.item, criterion.column); + let comparison = 0; + if (typeof aVal === "string" || typeof bVal === "string") { + comparison = String(aVal).localeCompare(String(bVal)); + } else { + comparison = (aVal as number) - (bVal as number); + } + if (comparison !== 0) { + return criterion.direction === "asc" ? comparison : -comparison; + } + } + return 0; }); } + function resetFilters() { + query = ""; + selectedProviders = []; + selectedModes = []; + maxInputTokens = null; + maxOutputTokens = null; + maxInputCost = null; + maxOutputCost = null; + sortCriteria = [{ column: "name", direction: "asc" }]; + newSortColumn = ""; + capabilityFilters = Object.fromEntries( + Object.keys(capabilityFilters).map((key) => [key, false]), + ); + } + + function updateCapabilityFilter(key: string, checked: boolean) { + capabilityFilters = { ...capabilityFilters, [key]: checked }; + } + + function hasActiveFilters() { + return Boolean( + query || + selectedProviders.length || + selectedModes.length || + maxInputTokens || + maxOutputTokens || + maxInputCost || + maxOutputCost || + Object.values(capabilityFilters).some(Boolean), + ); + } + function formatCost(costPerToken: number | undefined): string { if (!costPerToken) return "—"; const perMillion = costPerToken * 1000000; @@ -252,34 +370,59 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ "completion": "Completion", "embedding": "Embedding", "image_generation": "Image Gen", + "image_edit": "Image Edit", "audio_transcription": "Transcription", "audio_speech": "TTS", "moderation": "Moderation", "rerank": "Rerank", + "responses": "Responses", + "video_generation": "Video Gen", + "search": "Search", + "ocr": "OCR", + "realtime": "Realtime", + "vector_store": "Vector Store", }; return labels[mode] || mode; } function filterResults( query: string, - selectedProvider: string, + selectedProviders: string[], + selectedModes: string[], maxInputTokens: number | null, maxOutputTokens: number | null, + maxInputCost: number | null, + maxOutputCost: number | null, + capabilityFilters: Record, ) { if (index) { let filteredResults: Item[]; const allItems = index["_docs"] as Item[]; + const selectedProviderSet = new Set(selectedProviders); + const selectedModeSet = new Set(selectedModes); + const requiredCapabilities = Object.entries(capabilityFilters) + .filter(([, enabled]) => enabled) + .map(([key]) => key); filteredResults = allItems.filter( (item) => - (!selectedProvider || item.litellm_provider === selectedProvider) && + (selectedProviderSet.size === 0 || + selectedProviderSet.has(item.litellm_provider)) && + (selectedModeSet.size === 0 || selectedModeSet.has(item.mode)) && (maxInputTokens === null || (item.max_input_tokens && item.max_input_tokens >= maxInputTokens)) && (maxOutputTokens === null || (item.max_output_tokens && - item.max_output_tokens >= maxOutputTokens)), + item.max_output_tokens >= maxOutputTokens)) && + (maxInputCost === null || + (item.input_cost_per_token !== undefined && + item.input_cost_per_token * 1000000 <= maxInputCost)) && + (maxOutputCost === null || + (item.output_cost_per_token !== undefined && + item.output_cost_per_token * 1000000 <= maxOutputCost)) && + requiredCapabilities.every((capability) => Boolean(item[capability])), ); if (query) { @@ -302,14 +445,14 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ results = filteredResults.map((item, refIndex) => ({ item, refIndex })); loading = false; - if (sortColumn) applySorting(); + if (sortCriteria.length > 0) applySorting(); - trackSearchDebounced(query, selectedProvider, results.length); + trackSearchDebounced(query, selectedProviders.join(","), results.length); } } - +
@@ -407,39 +550,210 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ {/if} - + + +
+ + {#if modeDropdownOpen} +
+ {#if selectedModes.length > 0} + + {/if} + {#each modes as mode} + + {/each} +
+ {/if} +
-
-
- - + + + {#if advancedFiltersOpen} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {#each sortCriteria as criterion, i} +
+ {i + 1}. + {getSortLabel(criterion.column)} + + +
+ {/each} +
+
+ + +
+
+
+ +
+ Required capabilities + {#each [ + { key: "supports_function_calling", label: "Functions" }, + { key: "supports_vision", label: "Vision" }, + { key: "supports_response_schema", label: "JSON" }, + { key: "supports_tool_choice", label: "Tool choice" }, + { key: "supports_parallel_function_calling", label: "Parallel" }, + { key: "supports_audio_input", label: "Audio" }, + { key: "supports_prompt_caching", label: "Caching" }, + ] as capability} + + {/each} +
-
- - + {/if} + + {#if selectedProviders.length > 0} +
+ {#each selectedProviders as provider} + + {/each}
-
+ {/if} {#if !loading}
{results.length.toLocaleString()} models - {#if query || selectedProvider || maxInputTokens || maxOutputTokens} - {/if} @@ -469,29 +783,33 @@ We also need to update [${RESOURCE_BACKUP_NAME}](https://github.com/${REPO_FULL_ {/if}
+
- + @@ -688,6 +1006,7 @@ curl http://0.0.0.0:4000/v1/chat/completions \ {/each}
Model handleSort("name")}> + Model + c.column === "name")} class:desc={sortCriteria.some(c => c.column === "name" && c.direction === "desc")}>↑ + handleSort("context")}> Context - + c.column === "context")} class:desc={sortCriteria.some(c => c.column === "context" && c.direction === "desc")}>↑ handleSort("input")}> Input $/M - + c.column === "input")} class:desc={sortCriteria.some(c => c.column === "input" && c.direction === "desc")}>↑ handleSort("output")}> Output $/M - + c.column === "output")} class:desc={sortCriteria.some(c => c.column === "output" && c.direction === "desc")}>↑ handleSort("cache_read")}> Cache Read - + c.column === "cache_read")} class:desc={sortCriteria.some(c => c.column === "cache_read" && c.direction === "desc")}>↑ handleSort("cache_write")}> Cache Write - + c.column === "cache_write")} class:desc={sortCriteria.some(c => c.column === "cache_write" && c.direction === "desc")}>↑
+
{/if}
@@ -891,6 +1210,90 @@ curl http://0.0.0.0:4000/v1/chat/completions \ gap: 0.75rem; margin-bottom: 0.75rem; align-items: center; + flex-wrap: wrap; + } + + .mode-dropdown { + position: relative; + } + + .mode-dropdown-trigger { + height: 44px; + padding: 0 0.875rem; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-color); + color: var(--text-color); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s ease; + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + } + + .mode-dropdown-trigger:hover { + border-color: var(--border-color-strong); + } + + .mode-dropdown-caret { + font-size: 0.75rem; + color: var(--muted-color); + } + + .mode-dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 180px; + max-height: 320px; + overflow-y: auto; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 100; + padding: 0.375rem; + } + + .mode-dropdown-clear { + width: 100%; + padding: 0.375rem 0.75rem; + border: none; + background: none; + color: var(--muted-color); + font-size: 0.75rem; + text-align: left; + cursor: pointer; + border-radius: 6px; + } + + .mode-dropdown-clear:hover { + background: var(--bg-secondary); + color: var(--text-color); + } + + .mode-dropdown-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.8125rem; + transition: background 0.1s ease; + } + + .mode-dropdown-option:hover { + background: var(--bg-secondary); + } + + .mode-dropdown-option input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--litellm-primary); } .search-input-wrapper { @@ -968,9 +1371,9 @@ curl http://0.0.0.0:4000/v1/chat/completions \ } /* Filters */ - .filters-row { + .filters-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.75rem; } @@ -1010,6 +1413,253 @@ curl http://0.0.0.0:4000/v1/chat/completions \ .filter-input::placeholder { color: var(--muted-color); } + .sort-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + } + + .sort-multi-group { + grid-column: 1 / -1; + } + + .sort-criteria-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .sort-criterion-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-color); + font-size: 0.8125rem; + } + + .sort-criterion-priority { + color: var(--muted-color); + font-weight: 600; + font-size: 0.75rem; + } + + .sort-criterion-label { + font-weight: 500; + color: var(--text-color); + } + + .sort-criterion-dir, + .sort-criterion-remove { + border: none; + background: none; + cursor: pointer; + padding: 0 0.25rem; + font-size: 0.875rem; + color: var(--muted-color); + border-radius: 4px; + line-height: 1; + } + + .sort-criterion-dir:hover { + color: var(--litellm-primary); + background: var(--bg-secondary); + } + + .sort-criterion-remove:hover { + color: #e53e3e; + background: var(--bg-secondary); + } + + .sort-add-row { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .sort-add-select { + flex: 1; + max-width: 200px; + } + + .sort-add-btn { + height: 36px; + width: 36px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-color); + color: var(--text-color); + font-size: 1.125rem; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + } + + .sort-add-btn:hover:not(:disabled) { + background: var(--litellm-primary); + color: white; + border-color: var(--litellm-primary); + } + + .sort-add-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .sort-direction { + height: 40px; + width: 40px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-color); + color: var(--text-color); + font-weight: 700; + font-size: 1rem; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + } + + .sort-direction:hover { + border-color: var(--border-color-strong); + background: var(--bg-secondary); + } + + .advanced-toggle-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + height: 36px; + padding: 0 0.875rem; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-color); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.15s ease; + margin-top: 0.75rem; + } + + .advanced-toggle-button:hover { + border-color: var(--border-color-strong); + background: var(--bg-secondary); + color: var(--text-color); + } + + .advanced-toggle-button svg { + flex-shrink: 0; + color: var(--muted-color); + } + + .active-filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--litellm-primary); + } + + .advanced-toggle-icon { + display: inline-block; + transition: transform 0.15s ease; + } + + .advanced-toggle-icon.open { + transform: rotate(180deg); + } + + .advanced-filters { + margin-top: 0.75rem; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + display: flex; + flex-direction: column; + gap: 1rem; + } + + .capability-filter-group { + border: 0; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-content: end; + } + + .capability-filter-group legend { + width: 100%; + margin-bottom: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--muted-color); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .capability-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.45rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8125rem; + font-weight: 600; + transition: all 0.15s ease; + } + + .capability-chip input { + display: none; + } + + .capability-chip.active { + border-color: var(--litellm-primary); + color: var(--litellm-primary); + background: rgba(99, 102, 241, 0.08); + } + + .selected-provider-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; + } + + .selected-provider-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.625rem; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + } + + .selected-provider-chip:hover { + border-color: var(--litellm-primary); + color: var(--litellm-primary); + } + /* Results meta */ .results-meta { display: flex; @@ -1067,16 +1717,20 @@ curl http://0.0.0.0:4000/v1/chat/completions \ margin: 1rem auto 4rem; max-width: 1400px; padding: 0 2rem; - overflow-x: auto; + } + + .table-scroll-wrapper { + border-radius: 12px; + border: 1px solid var(--border-color); + overflow: auto; + max-height: calc(100vh - 120px); + background: var(--card-bg); } table { width: 100%; border-collapse: collapse; background: var(--card-bg); - border-radius: 12px; - border: 1px solid var(--border-color); - overflow: hidden; } thead { @@ -1096,7 +1750,7 @@ curl http://0.0.0.0:4000/v1/chat/completions \ white-space: nowrap; user-select: none; position: sticky; - top: 63px; + top: 0; z-index: 10; } @@ -1516,6 +2170,9 @@ curl http://0.0.0.0:4000/v1/chat/completions \ @media (max-width: 900px) { .th-hide-mobile, .td-hide-mobile { display: none; } + .filters-grid { + grid-template-columns: 1fr 1fr; + } } @media (max-width: 768px) { @@ -1526,7 +2183,7 @@ curl http://0.0.0.0:4000/v1/chat/completions \ .btn { width: 100%; } .search-section { padding: 0 1rem; } .search-bar-container { flex-direction: column; } - .filters-row { grid-template-columns: 1fr; } + .filters-grid { grid-template-columns: 1fr; } .table-container { padding: 0 1rem; } th, td { padding: 0.5rem 0.625rem; font-size: 0.8125rem; } .model-name { min-width: 180px; } diff --git a/src/ProviderDropdown.svelte b/src/ProviderDropdown.svelte index 3336cab..037203a 100644 --- a/src/ProviderDropdown.svelte +++ b/src/ProviderDropdown.svelte @@ -3,7 +3,7 @@ import { getProviderInitial, getProviderLogo } from "./providers"; export let providers: string[] = []; - export let selectedProvider: string = ""; + export let selectedProviders: string[] = []; let isOpen = false; let searchQuery = ""; @@ -12,6 +12,15 @@ provider.toLowerCase().includes(searchQuery.toLowerCase()) ); + $: selectedProviderSet = new Set(selectedProviders); + + $: triggerLabel = + selectedProviders.length === 0 + ? "All Providers" + : selectedProviders.length === 1 + ? selectedProviders[0] + : `${selectedProviders.length} providers`; + function toggle() { isOpen = !isOpen; if (isOpen) { @@ -19,9 +28,16 @@ } } - function select(provider: string) { - selectedProvider = provider; - isOpen = false; + function toggleProvider(provider: string) { + if (selectedProviderSet.has(provider)) { + selectedProviders = selectedProviders.filter((selected) => selected !== provider); + } else { + selectedProviders = [...selectedProviders, provider]; + } + } + + function clearSelection() { + selectedProviders = []; searchQuery = ""; } @@ -44,17 +60,17 @@ type="button" > @@ -77,8 +93,8 @@