diff --git a/dashboard/src/api/control-layer/hooks.ts b/dashboard/src/api/control-layer/hooks.ts index 36cb271f1..0e03e7300 100644 --- a/dashboard/src/api/control-layer/hooks.ts +++ b/dashboard/src/api/control-layer/hooks.ts @@ -150,13 +150,15 @@ export function useDeleteUser() { } // Models hooks -export function useModels(options?: ModelsQuery) { +export function useModels(options?: ModelsQuery & { enabled?: boolean }) { const queryClient = useQueryClient(); + const { enabled = true, ...queryOptions } = options || {}; return useQuery({ - queryKey: queryKeys.models.query(options), - queryFn: () => dwctlApi.models.list(options), + queryKey: queryKeys.models.query(queryOptions), + queryFn: () => dwctlApi.models.list(queryOptions), placeholderData: keepPreviousData, + enabled, // Populate individual model caches when list is fetched select: (data) => { // Seed the cache with individual models diff --git a/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx new file mode 100644 index 000000000..ca6790dfa --- /dev/null +++ b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx @@ -0,0 +1,1232 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + Menu, + Search, + SlidersHorizontal, + ExternalLink, + Code, + MessageSquare, + Eye, + Brain, + Layers, + Braces, + Copy, + Check, + ArrowDown, + ArrowUp, +} from "lucide-react"; +import { useModels, useGroups, useProviderDisplayConfigs } from "@/api/control-layer"; +import type { Model } from "@/api/control-layer/types"; +import { + useConfig, + useUser, + useUserBalance, +} from "@/api/control-layer/hooks"; +import { useSidebar } from "@/components/ui/sidebar"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Markdown } from "@/components/ui/markdown"; +import { ApiExamples } from "@/components/modals"; +import { + formatContextLength, + formatTariffPrice, +} from "@/utils/formatters"; +import { formatDollars } from "@/utils/money"; +import { copyToClipboard } from "@/utils/clipboard"; +import { useAuthorization } from "@/utils"; +import { isPlaygroundDenied } from "@/utils/modelAccess"; +import { + EVERYONE_GROUP_ID, + FILTERABLE_CAPABILITIES, + NEW_CUTOFF_MONTHS, + formatReleaseDate, + getCheapestInputPriceValue, + getDisplayCapabilities, + type PricingContext, +} from "./shared"; +import { + aggregateFamilies, + computeNewCutoff, + nullsLast, + type AggregatedFamily, +} from "./modelFamily"; +import { + DEFAULT_SORT_DIRECTIONS, + activeFilterCount, + defaultUrlState, + deserializeUrlState, + serializeUrlState, + type CatalogCategory, + type MobileCatalogUrlState, + type MobileSortField, + type SortDirection, +} from "./mobileUrlState"; + +const PRICING_CONTEXT_KEY = "catalog-pricing-context"; + +const CATEGORY_TABS: { value: CatalogCategory; label: string }[] = [ + { value: "all", label: "All" }, + { value: "generation", label: "Generative" }, + { value: "embedding", label: "Embedding" }, + { value: "ocr", label: "OCR" }, +]; + +const CAPABILITY_ICON: Record> = { + text: MessageSquare, + vision: Eye, + reasoning: Brain, + embeddings: Layers, + enhanced_structured_generation: Braces, + code: Code, +}; + +const SORT_OPTIONS: { value: MobileSortField; label: string }[] = [ + { value: "intelligence", label: "Intelligence" }, + { value: "cost", label: "Cost" }, + { value: "context", label: "Context window" }, + { value: "released_at", label: "Release date" }, + { value: "alias", label: "Name" }, +]; + +function loadPricingContext(): PricingContext { + try { + const stored = localStorage.getItem(PRICING_CONTEXT_KEY); + if (stored === "batch" || stored === "async") return stored; + } catch { + // ignore (e.g. SSR or disabled storage) + } + return "async"; +} + +function CapabilityRow({ caps }: { caps: string[] }) { + return ( +
+ {caps.map((cap) => { + const Icon = CAPABILITY_ICON[cap]; + if (!Icon) return null; + return ( + + ); + })} +
+ ); +} + +interface FamilyCardProps { + family: AggregatedFamily; + onOpen: (modelId: string) => void; + pricingContext: PricingContext; +} + +function FamilyCard({ family, onOpen, pricingContext }: FamilyCardProps) { + const navigate = useNavigate(); + const primary = family.variants[0]; + const playgroundAvailable = !isPlaygroundDenied(primary); + const priceFrom = + family.priceFrom ?? + getCheapestInputPriceValue(primary.tariffs, pricingContext); + + return ( + onOpen(primary.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(primary.id); + } + }} + className="cursor-pointer p-4 gap-3 transition-colors hover:bg-doubleword-neutral-50 active:scale-[0.99]" + > +
+
+
+

+ {family.label} +

+ {family.isNew && ( + + New + + )} + {primary.metadata?.quantization && ( + + {primary.metadata.quantization} + + )} +
+
+ {family.providerLabel} +
+
+ +
+ +
+
+
+ Intel +
+
+ {family.intelligenceMax ?? "—"} +
+
+
+
+ Cost +
+
+ {priceFrom != null ? ( + <> + {formatTariffPrice(priceFrom)} + + {" "}/M + + + ) : ( + "——" + )} +
+
+
+
+ Context +
+
+ {family.contextMax != null + ? formatContextLength(family.contextMax) + : "—"} +
+
+
+ +
+ + +
+
+ ); +} + +interface FilterDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + state: MobileCatalogUrlState; + setState: ( + update: (prev: MobileCatalogUrlState) => MobileCatalogUrlState, + ) => void; + providerOptions: { key: string; label: string }[]; + groupOptions: { id: string; name: string }[]; + canManageGroups: boolean; + visibleCount: number; +} + +function FilterDrawer({ + open, + onOpenChange, + state, + setState, + providerOptions, + groupOptions, + canManageGroups, + visibleCount, +}: FilterDrawerProps) { + const toggleProvider = (key: string) => + setState((prev) => ({ + ...prev, + providers: prev.providers.includes(key) + ? prev.providers.filter((p) => p !== key) + : [...prev.providers, key].sort(), + })); + + const toggleCapability = (key: string) => + setState((prev) => ({ + ...prev, + capabilities: prev.capabilities.includes(key) + ? prev.capabilities.filter((c) => c !== key) + : [...prev.capabilities, key].sort(), + })); + + const toggleGroup = (id: string) => + setState((prev) => { + const isSelected = prev.groups.includes(id); + const next = isSelected + ? prev.groups.filter((g) => g !== id) + : [...prev.groups.filter((g) => g !== EVERYONE_GROUP_ID), id].sort(); + return { + ...prev, + groups: next.length === 0 ? [EVERYONE_GROUP_ID] : next, + }; + }); + + const setSort = (sort: MobileSortField) => + setState((prev) => ({ + ...prev, + sort, + dir: + prev.sort === sort ? prev.dir : DEFAULT_SORT_DIRECTIONS[sort], + })); + + const setDir = (dir: SortDirection) => + setState((prev) => ({ ...prev, dir })); + + const reset = () => + setState((prev) => ({ + ...defaultUrlState(), + // Keep search and modelId in URL across reset to be predictable + search: prev.search, + modelId: prev.modelId, + })); + + return ( + + + + Sort & Filter + + + +
+
+

+ Sort by +

+ +
+ + +
+
+ +
+ +
+

+ Providers +

+
+ {providerOptions.length === 0 ? ( +

+ No providers available +

+ ) : ( + providerOptions.map((p) => { + const active = state.providers.includes(p.key); + return ( + + ); + }) + )} +
+
+ +
+ +
+

+ Capabilities (has all) +

+
+ {FILTERABLE_CAPABILITIES.map((c) => { + const active = state.capabilities.includes(c.key); + return ( + + ); + })} +
+
+ + {canManageGroups && groupOptions.length > 0 && ( + <> +
+
+

+ Groups +

+
+ {groupOptions.map((g) => { + const active = state.groups.includes(g.id); + return ( + + ); + })} +
+
+ + )} +
+ +
+ +
+
+
+ ); +} + +interface DetailDrawerProps { + family: AggregatedFamily | null; + activeVariantId: string | null; + onVariantChange: (id: string) => void; + onClose: () => void; + pricingContext: PricingContext; +} + +const CAPABILITY_LABEL: Record = { + text: "Chat", + vision: "Vision", + reasoning: "Reasoning", + embeddings: "Embeddings", + enhanced_structured_generation: "Structured", + code: "Code", +}; + +function DetailDrawer({ + family, + activeVariantId, + onVariantChange, + onClose, + pricingContext, +}: DetailDrawerProps) { + const navigate = useNavigate(); + const [aliasCopied, setAliasCopied] = useState(false); + const [showApiExamples, setShowApiExamples] = useState(false); + + const activeVariant = useMemo(() => { + if (!family) return null; + return ( + family.variants.find((v) => v.id === activeVariantId) ?? + family.variants[0] + ); + }, [family, activeVariantId]); + + if (!family || !activeVariant) { + return ( + + + + ); + } + + const playgroundAvailable = !isPlaygroundDenied(activeVariant); + const cheapest = getCheapestInputPriceValue( + activeVariant.tariffs, + pricingContext, + ); + + return ( + <> + !o && onClose()}> + + + {family.label} +

+ {family.providerLabel} +

+
+ +
+ {family.variants.length > 1 && ( + + )} + +
+ + {activeVariant.alias} + + +
+ +
+
+
+ Intelligence +
+
+ {activeVariant.metadata?.intelligence_index ?? "—"} +
+
+
+
+ Context window +
+
+ {activeVariant.metadata?.context_window + ? formatContextLength(activeVariant.metadata.context_window) + : "—"} +
+
+
+
+ Released +
+
+ {activeVariant.metadata?.released_at + ? formatReleaseDate(activeVariant.metadata.released_at) + : "—"} +
+
+
+
+ Cost ({pricingContext}) +
+
+ {cheapest != null ? `${formatTariffPrice(cheapest)}/M` : "—"} +
+
+
+ +
+
+ Capabilities +
+
+ {getDisplayCapabilities(activeVariant).map((cap) => { + const Icon = CAPABILITY_ICON[cap]; + return ( + + {Icon && } + {CAPABILITY_LABEL[cap] ?? cap} + + ); + })} +
+
+ + {activeVariant.description && ( +
+
+ Description +
+ + {activeVariant.description} + +
+ )} +
+ +
+ + +
+
+
+ setShowApiExamples(false)} + model={activeVariant} + /> + + ); +} + +function MobileLoadingSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); +} + +interface MobileModelCatalogProps { + /** True when the viewport is below the md breakpoint. Drives query gating + * so we don't fire a duplicate list request while the desktop view owns + * the fetch (or vice versa). */ + isMobile: boolean; +} + +export const MobileModelCatalog: React.FC = ({ + isMobile, +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const urlState = useMemo( + () => deserializeUrlState(searchParams), + [searchParams], + ); + + const { hasPermission } = useAuthorization(); + const canManageGroups = hasPermission("manage-groups"); + const sidebar = useSidebar(); + const { data: currentUser } = useUser("current"); + const { data: balance, isLoading: balanceLoading } = useUserBalance( + currentUser?.id ?? "", + ); + const { data: config } = useConfig(); + const billingEnabled = !!config?.payment_enabled; + + // Local state for the search box (debounced into URL) + const [searchInput, setSearchInput] = useState(urlState.search); + useEffect(() => { + // Sync local input when the URL was changed externally (e.g. back button). + setSearchInput(urlState.search); + }, [urlState.search]); + + const [debouncedSearch, setDebouncedSearch] = useState(urlState.search); + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchInput), 250); + return () => clearTimeout(timer); + }, [searchInput]); + + // Persist the debounced search into the URL with replace semantics. + const updateUrl = useCallback( + ( + updater: (prev: MobileCatalogUrlState) => MobileCatalogUrlState, + options: { mode: "replace" | "push" } = { mode: "replace" }, + ) => { + // Read latest from current params instead of `urlState` to avoid stale + // state when batching updates. + const current = deserializeUrlState( + new URLSearchParams(window.location.search), + ); + const next = updater(current); + const params = serializeUrlState(next); + setSearchParams(params, { replace: options.mode === "replace" }); + }, + [setSearchParams], + ); + + useEffect(() => { + if (debouncedSearch === urlState.search) return; + updateUrl((prev) => ({ ...prev, search: debouncedSearch })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + const [pricingContext, setPricingContextState] = useState( + () => loadPricingContext(), + ); + const setPricingContext = (next: PricingContext) => { + setPricingContextState(next); + try { + localStorage.setItem(PRICING_CONTEXT_KEY, next); + } catch { + // ignore + } + }; + + const [filterOpen, setFilterOpen] = useState(false); + + // ----- API queries ----- + const groupFilter = + canManageGroups && + !(urlState.groups.length === 1 && urlState.groups[0] === EVERYONE_GROUP_ID) + ? urlState.groups.join(",") + : undefined; + + const { data, isLoading, isFetching, error, refetch } = useModels({ + search: debouncedSearch || undefined, + is_composite: true, + include: "pricing", + limit: 500, + group: groupFilter, + // Avoid issuing the mobile query (and a duplicate desktop query) when + // the desktop view is the one actually rendered. The viewport-only + // wrappers (`hidden md:block` / `block md:hidden`) keep both views + // mounted, but only the active one should hit the network. + enabled: isMobile, + }); + + const { data: providerDisplayConfigs = [] } = useProviderDisplayConfigs(); + const { data: groupsData } = useGroups({ + limit: 100, + enabled: canManageGroups, + }); + + const providerConfigMap = useMemo( + () => + new Map( + providerDisplayConfigs.map((c) => [c.provider_key.toLowerCase(), c]), + ), + [providerDisplayConfigs], + ); + + const providerLabelOf = useCallback( + (m: Model) => { + const key = (m.metadata?.provider?.trim() || "Other").toLowerCase(); + const cfg = providerConfigMap.get(key); + return cfg?.display_name || m.metadata?.provider?.trim() || "Other"; + }, + [providerConfigMap], + ); + const providerIconOf = useCallback( + (m: Model) => { + const key = (m.metadata?.provider?.trim() || "Other").toLowerCase(); + return providerConfigMap.get(key)?.icon ?? null; + }, + [providerConfigMap], + ); + + // ----- Aggregate families. Re-runs when pricingContext changes. ----- + const newCutoff = useMemo( + () => computeNewCutoff(new Date(), NEW_CUTOFF_MONTHS), + [], + ); + const allFamilies = useMemo( + () => + aggregateFamilies(data?.data ?? [], { + newCutoff, + context: pricingContext, + providerLabelOf, + providerIconOf, + displayCapabilitiesOf: getDisplayCapabilities, + }), + [ + data?.data, + newCutoff, + pricingContext, + providerLabelOf, + providerIconOf, + ], + ); + + // Provider options derived from current data; key = lowercase provider. + const providerOptions = useMemo(() => { + const seen = new Map(); + for (const fam of allFamilies) { + if (!seen.has(fam.providerKey)) { + seen.set(fam.providerKey, fam.providerLabel); + } + } + return [...seen.entries()] + .map(([key, label]) => ({ key, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [allFamilies]); + + const groupOptions = useMemo( + () => + (groupsData?.data ?? []).map((g) => ({ id: g.id, name: g.name })), + [groupsData?.data], + ); + + // ----- Client-side filter pipeline ----- + const filteredFamilies = useMemo(() => { + let result = allFamilies; + + if (urlState.category !== "all") { + result = result.filter((f) => f.category === urlState.category); + } + + if (urlState.providers.length > 0) { + const providerSet = new Set(urlState.providers); + result = result.filter((f) => providerSet.has(f.providerKey)); + } + + if (urlState.capabilities.length > 0) { + result = result.filter((f) => + urlState.capabilities.every((cap) => f.capabilities.includes(cap)), + ); + } + + return result; + }, [ + allFamilies, + urlState.category, + urlState.providers, + urlState.capabilities, + ]); + + // ----- Sorting (always nullsLast) ----- + const sortedFamilies = useMemo(() => { + const dir: 1 | -1 = urlState.dir === "asc" ? 1 : -1; + const list = [...filteredFamilies]; + const numericCmp = (a: number, b: number) => a - b; + const stringCmp = (a: string, b: string) => a.localeCompare(b); + + switch (urlState.sort) { + case "intelligence": + list.sort( + nullsLast( + (f) => f.intelligenceMax, + dir, + numericCmp, + ), + ); + break; + case "cost": + list.sort( + nullsLast( + (f) => f.priceFrom, + dir, + numericCmp, + ), + ); + break; + case "context": + list.sort( + nullsLast( + (f) => f.contextMax, + dir, + numericCmp, + ), + ); + break; + case "released_at": + list.sort( + nullsLast( + (f) => f.releasedAt, + dir, + stringCmp, + ), + ); + break; + case "alias": + list.sort( + nullsLast( + (f) => f.label, + dir, + stringCmp, + ), + ); + break; + } + + return list; + }, [filteredFamilies, urlState.sort, urlState.dir]); + + // ----- Drawer handling ----- + // Tracks whether the current topmost history entry is one we pushed to + // open the drawer. When that is the case, closing the drawer should pop + // history so the user doesn't have to press Back twice (once to no-op + // through the close-replace, then once to actually leave the page). + const ownsHistoryEntry = useRef(false); + const navigate = useNavigate(); + + // If the URL drops modelId via any path other than `closeModel` (popstate, + // navigation away, etc.) we no longer own a history entry to pop. Keep the + // ref in sync with the URL so a stale `true` can't make a future Close + // navigate too far back. + useEffect(() => { + if (!urlState.modelId) ownsHistoryEntry.current = false; + }, [urlState.modelId]); + + const openModel = (id: string) => { + ownsHistoryEntry.current = true; + updateUrl((prev) => ({ ...prev, modelId: id }), { mode: "push" }); + }; + const closeModel = () => { + if (ownsHistoryEntry.current) { + // Reverse the push: this both removes modelId AND removes the + // intermediate history entry, so a subsequent Back leaves the page + // instead of reopening the drawer. + ownsHistoryEntry.current = false; + navigate(-1); + return; + } + // Drawer was opened by a deep link / external navigation; we don't own + // the topmost entry, so just strip modelId in place. + updateUrl((prev) => ({ ...prev, modelId: null }), { mode: "replace" }); + }; + const setActiveVariant = (id: string) => + updateUrl((prev) => ({ ...prev, modelId: id }), { mode: "replace" }); + + const drawerFamily = useMemo(() => { + if (!urlState.modelId) return null; + return ( + allFamilies.find((f) => + f.variants.some((v) => v.id === urlState.modelId), + ) ?? null + ); + }, [allFamilies, urlState.modelId]); + + const filterCount = activeFilterCount(urlState); + const showSkeleton = isLoading && !data; + + const clearAll = () => { + setSearchInput(""); + setDebouncedSearch(""); + updateUrl(() => ({ ...defaultUrlState() })); + }; + + return ( +
+
+
+ + {billingEnabled && currentUser ? ( +
+ Balance: + {!balanceLoading && ( + + {formatDollars(balance ?? 0)} + + )} +
+ ) : ( +
+ )} + + Docs + + +
+ +

+ Models +

+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search models..." + aria-label="Search models" + className="pl-9" + /> +
+ +
+ +
+ {CATEGORY_TABS.map((tab) => { + const active = urlState.category === tab.value; + return ( + + ); + })} +
+ +
+ + Pricing + +
+ {(["async", "batch"] as const).map((opt) => ( + + ))} +
+
+
+ +
+ {error ? ( +
+

+ Failed to load models. +

+ +
+ ) : showSkeleton ? ( + + ) : sortedFamilies.length === 0 ? ( +
+

+ No models found +

+

+ Try adjusting your filters or search. +

+ +
+ ) : ( + <> + {isFetching && !showSkeleton && ( +
+ Updating… +
+ )} + {sortedFamilies.map((family) => ( + + ))} + + )} +
+ + updateUrl(updater)} + providerOptions={providerOptions} + groupOptions={groupOptions} + canManageGroups={canManageGroups} + visibleCount={sortedFamilies.length} + /> + + {drawerFamily && ( + + )} +
+ ); +}; + +export default MobileModelCatalog; diff --git a/dashboard/src/components/features/models/catalog/ModelCatalog.tsx b/dashboard/src/components/features/models/catalog/ModelCatalog.tsx index ff014e3ca..89740ee5e 100644 --- a/dashboard/src/components/features/models/catalog/ModelCatalog.tsx +++ b/dashboard/src/components/features/models/catalog/ModelCatalog.tsx @@ -58,10 +58,14 @@ import { Skeleton } from "../../../ui/skeleton"; import { ApiExamples } from "../../../modals"; import { CatalogIcon } from "./CatalogIcon"; import { useIsMobile } from "../../../../hooks/use-mobile"; -import { MobileModelsView } from "../manage/MobileModelsView"; - - -const EVERYONE_GROUP_ID = "00000000-0000-0000-0000-000000000000"; +import { MobileModelCatalog } from "./MobileModelCatalog"; +import { + EVERYONE_GROUP_ID, + formatReleaseDate, + getCatalogTabForModel, + getCheapestInputPriceValue, + getDisplayCapabilities, +} from "./shared"; const MODEL_PURPOSE_SECTIONS: { type: ModelDisplayCategory; label: string }[] = [ { type: "generation", label: "Generation" }, @@ -88,62 +92,9 @@ const DEFAULT_SORT_DIRECTIONS: Partial> = price_from: "asc", }; -function formatReleaseDate(dateStr: string): string { - const date = new Date(dateStr + "T00:00:00"); - return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); -} - function getCheapestInputPrice(tariffs: Model["tariffs"]): string | null { - if (!tariffs) return null; - const visible = getUserFacingTariffs(tariffs); - if (visible.length === 0) return null; - let cheapest = Infinity; - for (const t of visible) { - const price = parseFloat(t.input_price_per_token); - if (price < cheapest) cheapest = price; - } - if (!isFinite(cheapest)) return null; - return formatTariffPrice(String(cheapest)); -} - -function getCheapestInputPriceValue(tariffs: Model["tariffs"]): number | null { - if (!tariffs) return null; - const visible = getUserFacingTariffs(tariffs); - if (visible.length === 0) return null; - let cheapest = Infinity; - for (const t of visible) { - const price = parseFloat(t.input_price_per_token); - if (price < cheapest) cheapest = price; - } - return Number.isFinite(cheapest) ? cheapest : null; -} - -/** Derive display capabilities from model type + backend capabilities. */ -function getDisplayCapabilities(model: Model): string[] { - const caps: string[] = []; - // Implicit capability from model type - if (model.model_type === "CHAT") caps.push("text"); - else if (model.model_type === "EMBEDDINGS") caps.push("embeddings"); - // Explicit capabilities from backend (vision, reasoning, etc.) - if (model.capabilities) { - for (const c of model.capabilities) { - if (c !== "text" && c !== "embeddings" && !caps.includes(c)) { - caps.push(c); - } - } - } - return caps; -} - -function getCatalogTabForModel(model: Model): ModelDisplayCategory | null { - if (model.metadata?.display_category) { - return model.metadata.display_category; - } - if (model.model_type === "EMBEDDINGS") return "embedding"; - if (model.model_type === "CHAT" || model.model_type === "RERANKER") { - return "generation"; - } - return null; + const value = getCheapestInputPriceValue(tariffs); + return value == null ? null : formatTariffPrice(String(value)); } function CapabilityIcons({ capabilities }: { capabilities: string[] }) { @@ -780,9 +731,8 @@ function LoadingSkeleton() { ); } -export const ModelCatalog: React.FC = () => { +const DesktopModelCatalog: React.FC<{ isMobile: boolean }> = ({ isMobile }) => { const navigate = useNavigate(); - const isMobile = useIsMobile(); const { hasPermission } = useAuthorization(); const canManageGroups = hasPermission("manage-groups"); @@ -829,6 +779,10 @@ export const ModelCatalog: React.FC = () => { search: debouncedSearch || undefined, sort: "released_at", sort_direction: "desc", + // The mobile sibling fetches its own list with different params; gate on + // viewport so only the active view triggers a network request rather + // than burning two on every page load. + enabled: !isMobile, }); const { data: providerDisplayConfigs = [] } = useProviderDisplayConfigs(); @@ -883,15 +837,15 @@ export const ModelCatalog: React.FC = () => { const hasAnyFilters = !!debouncedSearch; return ( -
+
{/* Header */}

Models

-
- {canManageGroups && !isMobile && ( +
+ {canManageGroups && (
Group: @@ -985,7 +939,7 @@ export const ModelCatalog: React.FC = () => {
)} -
+
{ ? "No models matching your filters" : "No models available"}
- ) : isMobile ? ( - navigate(`/models/${id}`)} - /> ) : (
{sections.map((section) => ( @@ -1048,4 +996,18 @@ export const ModelCatalog: React.FC = () => { ); }; +export const ModelCatalog: React.FC = () => { + // Both children mount unconditionally so the layout switch is CSS-only + // (preserves SSR/initial-paint, no flicker). The viewport flag is plumbed + // through so each child can gate its own data fetches and avoid issuing + // two distinct list queries with non-overlapping cache keys. + const isMobile = useIsMobile(); + return ( + <> + + + + ); +}; + export default ModelCatalog; diff --git a/dashboard/src/components/features/models/catalog/mobileUrlState.test.ts b/dashboard/src/components/features/models/catalog/mobileUrlState.test.ts new file mode 100644 index 000000000..8a55a1398 --- /dev/null +++ b/dashboard/src/components/features/models/catalog/mobileUrlState.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + defaultUrlState, + deserializeUrlState, + serializeUrlState, + activeFilterCount, + type MobileCatalogUrlState, +} from "./mobileUrlState"; +import { EVERYONE_GROUP_ID } from "./shared"; + +function roundtrip(state: MobileCatalogUrlState): MobileCatalogUrlState { + return deserializeUrlState(serializeUrlState(state)); +} + +describe("mobileUrlState", () => { + it("round-trips the default state to an empty URL", () => { + const state = defaultUrlState(); + const params = serializeUrlState(state); + expect(params.toString()).toBe(""); + expect(roundtrip(state)).toEqual(state); + }); + + it("omits dir when it matches the default for the active sort", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + sort: "cost", + dir: "asc", + }; + const params = serializeUrlState(state); + expect(params.get("sort")).toBe("cost"); + expect(params.get("dir")).toBeNull(); + expect(roundtrip(state)).toEqual(state); + }); + + it("emits dir when it diverges from the default", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + sort: "cost", + dir: "desc", + }; + const params = serializeUrlState(state); + expect(params.get("dir")).toBe("desc"); + expect(roundtrip(state)).toEqual(state); + }); + + it("encodes repeated providers/capabilities alphabetically", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + providers: ["openai", "alibaba", "moonshot"], + capabilities: ["vision", "reasoning"], + }; + const params = serializeUrlState(state); + expect(params.getAll("providers")).toEqual([ + "alibaba", + "moonshot", + "openai", + ]); + expect(params.getAll("capabilities")).toEqual(["reasoning", "vision"]); + expect(roundtrip(state)).toEqual({ + ...state, + providers: ["alibaba", "moonshot", "openai"], + capabilities: ["reasoning", "vision"], + }); + }); + + it("treats the default Everyone group as omitted", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + groups: [EVERYONE_GROUP_ID], + }; + expect(serializeUrlState(state).getAll("groups")).toEqual([]); + + const customGroups: MobileCatalogUrlState = { + ...defaultUrlState(), + groups: ["bb-group", "aa-group"], + }; + const params = serializeUrlState(customGroups); + expect(params.getAll("groups")).toEqual(["aa-group", "bb-group"]); + expect(roundtrip(customGroups)).toEqual({ + ...customGroups, + groups: ["aa-group", "bb-group"], + }); + }); + + it("preserves modelId when set", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + modelId: "abc-123", + }; + expect(serializeUrlState(state).get("modelId")).toBe("abc-123"); + expect(roundtrip(state)).toEqual(state); + }); + + it("falls back to defaults for invalid params", () => { + const params = new URLSearchParams("category=bogus&sort=garbage&dir=sideways"); + const state = deserializeUrlState(params); + expect(state.category).toBe("all"); + expect(state.sort).toBe("intelligence"); + expect(state.dir).toBe("desc"); + }); + + it("counts active filters", () => { + expect(activeFilterCount(defaultUrlState())).toBe(0); + expect( + activeFilterCount({ ...defaultUrlState(), providers: ["x"] }), + ).toBe(1); + expect( + activeFilterCount({ + ...defaultUrlState(), + providers: ["x"], + capabilities: ["vision"], + groups: ["custom"], + }), + ).toBe(3); + }); +}); diff --git a/dashboard/src/components/features/models/catalog/mobileUrlState.ts b/dashboard/src/components/features/models/catalog/mobileUrlState.ts new file mode 100644 index 000000000..dcbd47e53 --- /dev/null +++ b/dashboard/src/components/features/models/catalog/mobileUrlState.ts @@ -0,0 +1,160 @@ +import { EVERYONE_GROUP_ID } from "./shared"; + +/** Top-level catalog category. `all` is encoded by omitting the param. */ +export type CatalogCategory = "all" | "generation" | "embedding" | "ocr"; + +/** Sort field exposed via the mobile UI. Composite-aware. */ +export type MobileSortField = + | "intelligence" + | "cost" + | "context" + | "released_at" + | "alias"; + +export type SortDirection = "asc" | "desc"; + +/** + * Default sort direction per field. The URL serialiser emits `dir` only when + * it differs from the default, so the absence of `dir` implies the default. + */ +export const DEFAULT_SORT_DIRECTIONS: Record = { + intelligence: "desc", + released_at: "desc", + context: "desc", + cost: "asc", + alias: "asc", +}; + +export interface MobileCatalogUrlState { + search: string; + category: CatalogCategory; + providers: string[]; + capabilities: string[]; + groups: string[]; + sort: MobileSortField; + dir: SortDirection; + modelId: string | null; +} + +/** + * Empty / default URL state. Used by the empty-state "Clear all filters" + * action to produce a canonical reset URL (preserves nothing). + */ +export function defaultUrlState(): MobileCatalogUrlState { + return { + search: "", + category: "all", + providers: [], + capabilities: [], + groups: [EVERYONE_GROUP_ID], + sort: "intelligence", + dir: DEFAULT_SORT_DIRECTIONS.intelligence, + modelId: null, + }; +} + +const VALID_CATEGORIES: ReadonlySet = new Set([ + "all", + "generation", + "embedding", + "ocr", +]); + +const VALID_SORTS: ReadonlySet = new Set([ + "intelligence", + "cost", + "context", + "released_at", + "alias", +]); + +/** + * Decode URLSearchParams into a typed mobile catalog state. Unknown / invalid + * values fall back to defaults so a hand-crafted URL never crashes the UI. + */ +export function deserializeUrlState( + params: URLSearchParams, +): MobileCatalogUrlState { + const rawCategory = params.get("category"); + const category: CatalogCategory = + rawCategory && VALID_CATEGORIES.has(rawCategory as CatalogCategory) + ? (rawCategory as CatalogCategory) + : "all"; + + const rawSort = params.get("sort"); + const sort: MobileSortField = + rawSort && VALID_SORTS.has(rawSort as MobileSortField) + ? (rawSort as MobileSortField) + : "intelligence"; + + const rawDir = params.get("dir"); + const dir: SortDirection = + rawDir === "asc" || rawDir === "desc" + ? (rawDir as SortDirection) + : DEFAULT_SORT_DIRECTIONS[sort]; + + const providers = [...params.getAll("providers")].sort(); + const capabilities = [...params.getAll("capabilities")].sort(); + const groupsRaw = params.getAll("groups"); + const groups = + groupsRaw.length === 0 ? [EVERYONE_GROUP_ID] : [...groupsRaw].sort(); + + return { + search: params.get("search") ?? "", + category, + providers, + capabilities, + groups, + sort, + dir, + modelId: params.get("modelId"), + }; +} + +/** + * Encode a mobile catalog state into URLSearchParams using stable key order + * and alphabetical repeated-param order. Defaults are omitted to keep URLs + * short and to make `serialize(deserialize(x))` idempotent. + */ +export function serializeUrlState( + state: MobileCatalogUrlState, +): URLSearchParams { + const params = new URLSearchParams(); + + if (state.search) params.set("search", state.search); + if (state.category !== "all") params.set("category", state.category); + + for (const p of [...state.providers].sort()) params.append("providers", p); + for (const c of [...state.capabilities].sort()) { + params.append("capabilities", c); + } + + const groupsAreDefault = + state.groups.length === 1 && state.groups[0] === EVERYONE_GROUP_ID; + if (!groupsAreDefault) { + for (const g of [...state.groups].sort()) params.append("groups", g); + } + + if (state.sort !== "intelligence") params.set("sort", state.sort); + if (state.dir !== DEFAULT_SORT_DIRECTIONS[state.sort]) { + params.set("dir", state.dir); + } + + if (state.modelId) params.set("modelId", state.modelId); + + return params; +} + +/** + * Compose the count of "real" filters (providers/capabilities/groups) that + * differ from the default. Used to badge the filter button. + */ +export function activeFilterCount(state: MobileCatalogUrlState): number { + let count = 0; + if (state.providers.length > 0) count += 1; + if (state.capabilities.length > 0) count += 1; + const groupsAreDefault = + state.groups.length === 1 && state.groups[0] === EVERYONE_GROUP_ID; + if (!groupsAreDefault) count += 1; + return count; +} diff --git a/dashboard/src/components/features/models/catalog/modelFamily.test.ts b/dashboard/src/components/features/models/catalog/modelFamily.test.ts new file mode 100644 index 000000000..3a6d7c4ae --- /dev/null +++ b/dashboard/src/components/features/models/catalog/modelFamily.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import type { Model } from "../../../../api/control-layer/types"; +import { + aggregateFamilies, + computeNewCutoff, + nullsLast, + type AggregatedFamily, +} from "./modelFamily"; +import { getDisplayCapabilities } from "./shared"; + +function build(model: Partial & { id: string }): Model { + return { + alias: model.alias ?? model.id, + model_name: model.model_name ?? model.alias ?? model.id, + model_type: "CHAT", + is_composite: true, + metadata: null, + ...model, + } as Model; +} + +const baseOpts = { + newCutoff: "2025-12-31", + context: "async" as const, + providerLabelOf: (m: Model) => m.metadata?.provider ?? "Other", + providerIconOf: (m: Model) => m.metadata?.provider?.toLowerCase() ?? null, + displayCapabilitiesOf: getDisplayCapabilities, +}; + +describe("aggregateFamilies", () => { + it("groups variants sharing display_name into a single family", () => { + const families = aggregateFamilies( + [ + build({ + id: "a", + display_name: "Qwen 3.5 397B", + metadata: { + intelligence_index: 45, + context_window: 262144, + released_at: "2026-02-16", + provider: "Alibaba", + }, + }), + build({ + id: "b", + display_name: "Qwen 3.5 397B", + metadata: { + intelligence_index: 47, + context_window: 131072, + released_at: "2026-02-20", + provider: "Alibaba", + }, + }), + ], + baseOpts, + ); + expect(families).toHaveLength(1); + const fam = families[0]; + expect(fam.label).toBe("Qwen 3.5 397B"); + expect(fam.variants.map((v) => v.id)).toEqual(["a", "b"]); + expect(fam.intelligenceMax).toBe(47); + expect(fam.contextMax).toBe(262144); + expect(fam.releasedAt).toBe("2026-02-20"); + expect(fam.isNew).toBe(true); + }); + + it("falls back to canonicalised model_name when display_name is missing", () => { + const families = aggregateFamilies( + [ + build({ + id: "a", + model_name: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP8", + alias: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP8", + }), + build({ + id: "b", + model_name: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP16", + alias: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP16", + }), + ], + baseOpts, + ); + expect(families).toHaveLength(1); + expect(families[0].variants).toHaveLength(2); + }); + + it("preserves input order and selects the first variant as primary", () => { + const families = aggregateFamilies( + [ + build({ id: "first", display_name: "GLM 5.1" }), + build({ id: "other", display_name: "Kimi K2.6" }), + build({ id: "second", display_name: "GLM 5.1" }), + ], + baseOpts, + ); + expect(families.map((f) => f.id)).toEqual(["first", "other"]); + expect(families[0].variants.map((v) => v.id)).toEqual(["first", "second"]); + }); +}); + +describe("computeNewCutoff", () => { + it("subtracts the requested number of months", () => { + expect(computeNewCutoff(new Date(2026, 3, 15), 3)).toBe("2026-01-15"); + }); + + it("clamps to the last day of the target month when the source day overflows", () => { + // May has 31 days, February doesn't. Naive setMonth(getMonth() - 3) on + // May 31 produces "Feb 31" → JS normalises to early March, pushing the + // cutoff days forward. The clamped impl must stay in February. + expect(computeNewCutoff(new Date(2026, 4, 31), 3)).toBe("2026-02-28"); + // Leap year: target Feb has 29 days. + expect(computeNewCutoff(new Date(2024, 4, 31), 3)).toBe("2024-02-29"); + // March 31 → Nov 30 of previous year (Nov has 30 days). + expect(computeNewCutoff(new Date(2026, 2, 31), 4)).toBe("2025-11-30"); + }); + + it("crosses year boundaries correctly", () => { + expect(computeNewCutoff(new Date(2026, 0, 15), 3)).toBe("2025-10-15"); + }); +}); + +describe("nullsLast", () => { + type Item = { v: number | null }; + const cmp = (a: number, b: number) => a - b; + + it("sorts non-null values ascending when dir=1", () => { + const items: Item[] = [{ v: 3 }, { v: 1 }, { v: 2 }]; + items.sort(nullsLast((i) => i.v, 1, cmp)); + expect(items.map((i) => i.v)).toEqual([1, 2, 3]); + }); + + it("sorts non-null values descending when dir=-1", () => { + const items: Item[] = [{ v: 1 }, { v: 3 }, { v: 2 }]; + items.sort(nullsLast((i) => i.v, -1, cmp)); + expect(items.map((i) => i.v)).toEqual([3, 2, 1]); + }); + + it("keeps null/undefined values at the end regardless of direction", () => { + const ascending: Item[] = [ + { v: 2 }, + { v: null }, + { v: 1 }, + { v: null }, + ]; + ascending.sort(nullsLast((i) => i.v, 1, cmp)); + expect(ascending.map((i) => i.v)).toEqual([1, 2, null, null]); + + const descending: Item[] = [ + { v: 2 }, + { v: null }, + { v: 1 }, + { v: null }, + ]; + descending.sort(nullsLast((i) => i.v, -1, cmp)); + expect(descending.map((i) => i.v)).toEqual([2, 1, null, null]); + }); +}); + +describe("AggregatedFamily shape", () => { + it("retains all expected fields", () => { + const families = aggregateFamilies( + [ + build({ + id: "a", + display_name: "Test", + capabilities: ["vision"], + metadata: { provider: "Acme" }, + }), + ], + baseOpts, + ); + const fam: AggregatedFamily = families[0]; + expect(fam.providerLabel).toBe("Acme"); + expect(fam.capabilities).toContain("vision"); + }); +}); diff --git a/dashboard/src/components/features/models/catalog/modelFamily.ts b/dashboard/src/components/features/models/catalog/modelFamily.ts new file mode 100644 index 000000000..c96df25cc --- /dev/null +++ b/dashboard/src/components/features/models/catalog/modelFamily.ts @@ -0,0 +1,205 @@ +import type { + Model, + ModelDisplayCategory, +} from "../../../../api/control-layer/types"; +import { + getCatalogTabForModel, + getCheapestInputPriceValue, + type PricingContext, +} from "./shared"; + +export interface AggregatedFamily { + /** Stable id derived from the primary variant. */ + id: string; + /** User-facing label, typically display_name of the primary variant. */ + label: string; + /** Lowercase provider key for icon lookup. */ + providerKey: string; + /** Human-readable provider name. */ + providerLabel: string; + /** Optional provider icon string (URL or initials). */ + providerIcon?: string | null; + /** Catalog category for the family. */ + category: ModelDisplayCategory | null; + /** Variants within the family, ordered with the primary variant first. */ + variants: Model[]; + /** Highest intelligence_index across variants (null if none have one). */ + intelligenceMax: number | null; + /** Cheapest visible input price (per token) across variants for the active pricing context. */ + priceFrom: number | null; + /** Largest context window across variants. */ + contextMax: number | null; + /** Most recent released_at across variants (ISO YYYY-MM-DD). */ + releasedAt: string | null; + /** Union of display capabilities across variants. */ + capabilities: string[]; + /** Whether the family was released after the new-cutoff date. */ + isNew: boolean; +} + +export interface AggregateFamiliesOptions { + /** ISO date (YYYY-MM-DD) below which a release is no longer considered "new". */ + newCutoff: string; + /** Pricing context to use when computing priceFrom across variants. */ + context: PricingContext; + /** Resolves a model to a human-readable provider label. */ + providerLabelOf: (model: Model) => string; + /** Resolves a model to an optional provider icon. */ + providerIconOf: (model: Model) => string | null | undefined; + /** Computes a list of display capabilities for a given variant. */ + displayCapabilitiesOf: (model: Model) => string[]; +} + +/** + * Build a stable family key for a model. We prefer `display_name` (which is + * curated to be the family-level label, with the variant suffix dropped). When + * `display_name` is absent we fall back to a heuristic on the `model_name` + * (strip the leading provider prefix and any trailing FP8/INT4/etc suffix). + */ +function familyKeyOf(model: Model): string { + const display = (model.display_name ?? "").trim(); + if (display) return display.toLowerCase(); + + const raw = model.model_name || model.alias; + const slashIdx = raw.indexOf("/"); + const tail = slashIdx >= 0 ? raw.slice(slashIdx + 1) : raw; + const withoutQuant = tail.replace(/-(?:fp8|fp16|int4|int8|nvfp4|q\d+)$/i, ""); + return withoutQuant.toLowerCase(); +} + +function maxNumber(a: number | null, b: number | null): number | null { + if (a == null) return b; + if (b == null) return a; + return Math.max(a, b); +} + +function minNumber(a: number | null, b: number | null): number | null { + if (a == null) return b; + if (b == null) return a; + return Math.min(a, b); +} + +function maxIso(a: string | null, b: string | null): string | null { + if (!a) return b; + if (!b) return a; + return a > b ? a : b; +} + +/** + * Aggregate a flat list of (composite) models into families. Each family + * groups all variants that share a `display_name` (or, lacking one, a + * canonicalised model_name), preserving the order in which the variants + * arrived from the API. The primary variant is the first one in the input. + */ +export function aggregateFamilies( + models: Model[], + options: AggregateFamiliesOptions, +): AggregatedFamily[] { + const { + newCutoff, + context, + providerLabelOf, + providerIconOf, + displayCapabilitiesOf, + } = options; + + const order: string[] = []; + const byKey = new Map(); + + for (const model of models) { + const key = familyKeyOf(model); + let family = byKey.get(key); + + const variantPrice = getCheapestInputPriceValue(model.tariffs, context); + const intelligence = model.metadata?.intelligence_index ?? null; + const ctx = model.metadata?.context_window ?? null; + const releasedAt = model.metadata?.released_at ?? null; + const variantCaps = displayCapabilitiesOf(model); + + if (!family) { + const label = + (model.display_name && model.display_name.trim()) || + model.alias || + model.model_name; + const providerLabel = providerLabelOf(model); + const providerIcon = providerIconOf(model) ?? null; + const providerKey = (model.metadata?.provider?.trim() || "Other") + .toLowerCase(); + + family = { + id: model.id, + label, + providerKey, + providerLabel, + providerIcon, + category: getCatalogTabForModel(model), + variants: [model], + intelligenceMax: intelligence, + priceFrom: variantPrice, + contextMax: ctx, + releasedAt, + capabilities: [...variantCaps], + isNew: !!(releasedAt && releasedAt >= newCutoff), + }; + byKey.set(key, family); + order.push(key); + continue; + } + + family.variants.push(model); + family.intelligenceMax = maxNumber(family.intelligenceMax, intelligence); + family.priceFrom = minNumber(family.priceFrom, variantPrice); + family.contextMax = maxNumber(family.contextMax, ctx); + family.releasedAt = maxIso(family.releasedAt, releasedAt); + for (const cap of variantCaps) { + if (!family.capabilities.includes(cap)) family.capabilities.push(cap); + } + if (releasedAt && releasedAt >= newCutoff) family.isNew = true; + } + + return order.map((k) => byKey.get(k) as AggregatedFamily); +} + +/** + * Compute the ISO date (YYYY-MM-DD) used as the "new" cutoff, given a number + * of months back from `now`. Pure function for testability. + * + * Implementation note: a naive `setMonth(getMonth() - months)` overflows + * silently when the source day exceeds the target month's length (e.g. + * May 31 → "Feb 31" → Mar 3, three days later than intended), pushing the + * cutoff forward and incorrectly demoting borderline-new models. We clamp + * the day to the last day of the target month to avoid that wrap-around. + */ +export function computeNewCutoff(now: Date, months: number): string { + const year = now.getFullYear(); + const monthIdx = now.getMonth() - months; + // Day 0 of (target+1) is the last day of the target month — used to clamp. + const lastDayOfTargetMonth = new Date(year, monthIdx + 1, 0).getDate(); + const day = Math.min(now.getDate(), lastDayOfTargetMonth); + const cutoff = new Date(year, monthIdx, day); + const isoYear = String(cutoff.getFullYear()).padStart(4, "0"); + const isoMonth = String(cutoff.getMonth() + 1).padStart(2, "0"); + const isoDay = String(cutoff.getDate()).padStart(2, "0"); + return `${isoYear}-${isoMonth}-${isoDay}`; +} + +/** + * Build a comparator that sorts items by `getKey`, applying `dir` (1=asc, + * -1=desc), with null/undefined keys ALWAYS sorted to the end regardless of + * direction. Falling back to a final tiebreaker is the caller's + * responsibility. + */ +export function nullsLast( + getKey: (item: T) => K | null | undefined, + dir: 1 | -1, + cmp: (a: K, b: K) => number, +): (a: T, b: T) => number { + return (a, b) => { + const av = getKey(a); + const bv = getKey(b); + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + return cmp(av, bv) * dir; + }; +} diff --git a/dashboard/src/components/features/models/catalog/shared.ts b/dashboard/src/components/features/models/catalog/shared.ts new file mode 100644 index 000000000..13a63fccb --- /dev/null +++ b/dashboard/src/components/features/models/catalog/shared.ts @@ -0,0 +1,101 @@ +import type { + Model, + ModelDisplayCategory, +} from "../../../../api/control-layer/types"; +import { getUserFacingTariffs } from "../../../../utils/formatters"; + +/** UUID for the implicit "Everyone" group. */ +export const EVERYONE_GROUP_ID = "00000000-0000-0000-0000-000000000000"; + +/** + * Number of months a model is considered "new" since release. Used to derive + * the cutoff date for badge / new-section calculations. + */ +export const NEW_CUTOFF_MONTHS = 3; + +/** + * Capabilities that the catalog allows users to filter by. Order is meaningful + * for filter UIs. + */ +export const FILTERABLE_CAPABILITIES: { key: string; label: string }[] = [ + { key: "vision", label: "Vision" }, + { key: "reasoning", label: "Reasoning" }, + { key: "enhanced_structured_generation", label: "Structured" }, +]; + +/** Pricing context that controls which tariff window powers price aggregation. */ +export type PricingContext = "async" | "batch"; + +/** + * Compute the cheapest visible input price (per token) from a tariffs array. + * When `context` is provided, restricts to tariffs matching the context: + * - "async" → realtime tariffs (with completion_window omitted) + * - "batch" → batch tariffs (any window) + * Returns null when no matching tariff exists. + */ +export function getCheapestInputPriceValue( + tariffs: Model["tariffs"], + context?: PricingContext, +): number | null { + if (!tariffs) return null; + let visible = getUserFacingTariffs(tariffs); + if (context === "async") { + visible = visible.filter((t) => t.api_key_purpose !== "batch"); + } else if (context === "batch") { + visible = visible.filter((t) => t.api_key_purpose === "batch"); + } + if (visible.length === 0) return null; + let cheapest = Infinity; + for (const t of visible) { + const price = parseFloat(t.input_price_per_token); + if (price < cheapest) cheapest = price; + } + return Number.isFinite(cheapest) ? cheapest : null; +} + +/** + * Derive a stable, ordered list of display capabilities for a model. The + * primary capability is implied by `model_type` (text / embeddings); explicit + * capabilities (vision, reasoning, etc.) are appended. + */ +export function getDisplayCapabilities(model: Model): string[] { + const caps: string[] = []; + if (model.model_type === "CHAT") caps.push("text"); + else if (model.model_type === "EMBEDDINGS") caps.push("embeddings"); + if (model.capabilities) { + for (const c of model.capabilities) { + if (c !== "text" && c !== "embeddings" && !caps.includes(c)) { + caps.push(c); + } + } + } + return caps; +} + +/** + * Map a model into one of the catalog's top-level tabs. Returns null when the + * model has no recognised display category (it should be hidden in that case). + */ +export function getCatalogTabForModel( + model: Model, +): ModelDisplayCategory | null { + if (model.metadata?.display_category) { + return model.metadata.display_category; + } + if (model.model_type === "EMBEDDINGS") return "embedding"; + if (model.model_type === "CHAT" || model.model_type === "RERANKER") { + return "generation"; + } + return null; +} + +/** + * Format an ISO release date as a "Mon YYYY" string. The input must be a + * YYYY-MM-DD string (the metadata.released_at format). Invalid input is + * returned as-is to surface the data issue to the user. + */ +export function formatReleaseDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + if (Number.isNaN(date.getTime())) return dateStr; + return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +} diff --git a/dashboard/src/components/features/models/manage/MobileModelsView.tsx b/dashboard/src/components/features/models/manage/MobileModelsView.tsx deleted file mode 100644 index 7a5be8e54..000000000 --- a/dashboard/src/components/features/models/manage/MobileModelsView.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, { useState, useMemo } from "react"; -import type { Model, ProviderDisplayConfig } from "../../../../api/control-layer/types"; -import { CatalogIcon } from "../catalog/CatalogIcon"; - -type FilterType = "category" | "capability"; - -const FILTERS: { key: string; label: string; type: FilterType }[] = [ - { key: "generation", label: "Generative", type: "category" }, - { key: "embedding", label: "Embedding", type: "category" }, - { key: "ocr", label: "OCR", type: "category" }, - { key: "vision", label: "Vision", type: "capability" }, - { key: "reasoning", label: "Reasoning", type: "capability" }, - { key: "enhanced_structured_generation", label: "Enhanced Structured Generation", type: "capability" }, -]; - -function getModelCategory(model: Model): string { - if (model.metadata?.display_category) return model.metadata.display_category; - if (model.model_type === "EMBEDDINGS") return "embedding"; - return "generation"; -} - -function matchesFilter(model: Model, key: string, type: FilterType): boolean { - if (type === "category") return getModelCategory(model) === key; - return model.capabilities?.includes(key) ?? false; -} - -function sortByNewest(models: Model[]): Model[] { - return [...models].sort((a, b) => - (b.metadata?.released_at || "").localeCompare(a.metadata?.released_at || ""), - ); -} - -interface SwimlaneCardProps { - model: Model; - onTap: () => void; -} - -const SwimlaneCard: React.FC = ({ - model, - onTap, -}) => ( - -); - -interface SwimlaneProps { - title: string; - titleIcon?: string; - models: Model[]; - onNavigate: (modelId: string) => void; -} - -const Swimlane: React.FC = ({ - title, - titleIcon, - models, - onNavigate, -}) => ( -
-
- {titleIcon && ( - - )} -

- {title} -

-
-
    - {models.map((model) => ( -
  • - onNavigate(model.id)} - /> -
  • - ))} -
-
-); - -export interface MobileModelsViewProps { - models: Model[]; - providerConfigMap: Map; - onNavigate: (modelId: string) => void; -} - -export const MobileModelsView: React.FC = ({ - models, - providerConfigMap, - onNavigate, -}) => { - const [activeFilter, setActiveFilter] = useState("all"); - - const filtered = useMemo(() => { - if (activeFilter === "all") return models; - const filter = FILTERS.find((f) => f.key === activeFilter); - if (!filter) return models; - return models.filter((m) => matchesFilter(m, filter.key, filter.type)); - }, [models, activeFilter]); - - const newModels = useMemo(() => { - const withDate = filtered.filter((m) => m.metadata?.released_at); - if (withDate.length === 0) return []; - return sortByNewest(withDate).slice(0, 3); - }, [filtered]); - - const providerGroups = useMemo(() => { - const groups: Record = {}; - filtered.forEach((m) => { - const rawProvider = m.metadata?.provider || "Other"; - const key = rawProvider.toLowerCase().trim(); - if (!groups[key]) { - const config = providerConfigMap.get(key); - groups[key] = { - displayName: config?.display_name || rawProvider, - models: [], - }; - } - groups[key].models.push(m); - }); - return Object.entries(groups) - .sort((a, b) => b[1].models.length - a[1].models.length) - .map(([key, { displayName, models: laneModels }]) => ({ - key, - displayName, - models: sortByNewest(laneModels), - })); - }, [filtered, providerConfigMap]); - - return ( -
- {/* Filter chips */} -
- - {FILTERS.map(({ key, label }) => ( - - ))} -
- - {filtered.length === 0 ? ( -
- No models match this filter -
- ) : ( - <> - {newModels.length > 0 && ( - - )} - - {providerGroups.map(({ key, displayName, models: laneModels }) => { - const config = providerConfigMap.get(key); - return ( - - ); - })} - - )} -
- ); -};