import { Autocomplete, AutocompleteItem, AutocompleteSection, } from "@heroui/react"; import React from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { mapProvider } from "#/utils/map-provider"; import { extractModelAndProvider } from "#/utils/extract-model-and-provider"; import { cn } from "#/utils/utils"; import { formControlSettingsFieldClassName } from "#/utils/form-control-classes"; import { heroUiAutocompleteSelectorButtonClassName } from "#/ui/combobox-caret"; import { HelpLink } from "#/ui/help-link"; import { PRODUCT_URL } from "#/utils/constants"; import { useSearchProviders } from "#/hooks/query/use-search-providers"; import { useProviderModels } from "#/hooks/query/use-provider-models"; import { FREE_MODEL_BADGE_LABEL } from "#/utils/format-model-name"; import { FreeOpenHandsModelsNote } from "#/components/shared/free-models-note"; const freeModelBadgeClassName = "shrink-0 rounded-full border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] leading-none text-warning"; interface ModelSelectorProps { isDisabled?: boolean; currentModel?: string; onChange?: (provider: string | null, model: string | null) => void; onDefaultValuesChanged?: ( provider: string | null, model: string | null, ) => void; wrapperClassName?: string; labelClassName?: string; } export function ModelSelector({ isDisabled, currentModel, onChange, onDefaultValuesChanged, wrapperClassName, labelClassName, }: ModelSelectorProps) { const [, setLitellmId] = React.useState(null); const [selectedProvider, setSelectedProvider] = React.useState( null, ); const [selectedModel, setSelectedModel] = React.useState(null); const { data: providers = [] } = useSearchProviders(); const { data: providerModels = [], isLoading: isLoadingModels, error: modelsError, } = useProviderModels(selectedProvider); const verifiedProviders = React.useMemo( () => providers.filter((p) => p.verified), [providers], ); const unverifiedProviders = React.useMemo( () => providers.filter((p) => !p.verified), [providers], ); const verifiedModels = React.useMemo( () => providerModels.filter((m) => m.verified), [providerModels], ); const unverifiedModels = React.useMemo( () => providerModels.filter((m) => !m.verified), [providerModels], ); // DB-driven set of free model names for the selected provider. Mirrors the // `verified` flag: the frontend no longer hardcodes which models are free. const freeModelNames = React.useMemo( () => providerModels.filter((m) => m.free).map((m) => m.name), [providerModels], ); const freeModelNameSet = React.useMemo( () => new Set(freeModelNames), [freeModelNames], ); React.useEffect(() => { if (currentModel) { const { provider, model } = extractModelAndProvider(currentModel); setLitellmId(currentModel); setSelectedProvider(provider || null); setSelectedModel(model); onDefaultValuesChanged?.(provider || null, model); } }, [currentModel]); const handleChangeProvider = (provider: string) => { setSelectedProvider(provider); setSelectedModel(null); setLitellmId(`${provider}/`); onChange?.(provider, null); }; const handleChangeModel = (model: string) => { let fullModel = `${selectedProvider}/${model}`; if (selectedProvider === "openai") { fullModel = model; } setLitellmId(fullModel); setSelectedModel(model); onChange?.(selectedProvider, model); }; const clear = () => { setSelectedProvider(null); setLitellmId(null); }; const isSelectedModelFree = Boolean( selectedModel && freeModelNameSet.has(selectedModel), ); const selectedModelMeasureRef = React.useRef(null); const [selectedModelTextWidth, setSelectedModelTextWidth] = React.useState(0); React.useLayoutEffect(() => { if (!isSelectedModelFree || !selectedModelMeasureRef.current) { setSelectedModelTextWidth(0); return undefined; } const measureSelectedModel = () => { setSelectedModelTextWidth( Math.ceil( selectedModelMeasureRef.current?.getBoundingClientRect().width ?? 0, ), ); }; measureSelectedModel(); if (typeof ResizeObserver === "undefined") { return undefined; } const observer = new ResizeObserver(measureSelectedModel); observer.observe(selectedModelMeasureRef.current); return () => observer.disconnect(); }, [isSelectedModelFree, selectedModel]); const { t } = useTranslation("openhands"); return (
{ if (e?.toString()) handleChangeProvider(e.toString()); }} onInputChange={(value) => !value && clear()} defaultSelectedKey={selectedProvider ?? undefined} selectedKey={selectedProvider} classNames={{ popoverContent: "bg-content1 rounded-xl border border-[var(--oh-border)]", selectorButton: heroUiAutocompleteSelectorButtonClassName, }} selectorButtonProps={{ disableRipple: true }} inputProps={{ classNames: { inputWrapper: formControlSettingsFieldClassName, }, }} > {verifiedProviders.map((provider) => ( {mapProvider(provider.name)} ))} {unverifiedProviders.length > 0 ? ( {unverifiedProviders.map((provider) => ( {mapProvider(provider.name)} ))} ) : null}
{selectedProvider === "openhands" && (
)}
{ if (e?.toString()) handleChangeModel(e.toString()); }} isDisabled={isDisabled || !selectedProvider} selectedKey={selectedModel} defaultSelectedKey={selectedModel ?? undefined} classNames={{ popoverContent: "bg-content1 rounded-xl border border-[var(--oh-border)]", selectorButton: heroUiAutocompleteSelectorButtonClassName, }} selectorButtonProps={{ disableRipple: true }} inputProps={{ classNames: { inputWrapper: formControlSettingsFieldClassName, }, }} > {verifiedModels.map((model) => ( {model.name} {model.free ? ( {FREE_MODEL_BADGE_LABEL} ) : null} ))} {unverifiedModels.length > 0 ? ( {unverifiedModels.map((model) => ( {model.name} ))} ) : null} {isSelectedModelFree && selectedModel ? ( <> {selectedModel} {FREE_MODEL_BADGE_LABEL} ) : null}
{modelsError && (

{t(I18nKey.CONFIGURATION$ERROR_FETCH_MODELS)}

)} {selectedProvider === "openhands" && freeModelNames.length > 0 ? ( `${selectedProvider}/${name}`, )} /> ) : null}
); }