mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): common_components \u2014 ModelSelector + PassThroughRoutesSelector + PremiumLoggingSettings
- ModelSelector: antd Select + @tremor TextInput/Text + @ant-design/ icons RobotOutlined \u2192 shadcn Select + Input + lucide Bot. 'custom' option preserved with the inline Input that appears below on selection. - PassThroughRoutesSelector: antd Select mode='tags' (typing an ad-hoc value creates it) \u2192 shadcn Popover + chip-list multi-select with Enter-to-add-freeform inside the search input. - PremiumLoggingSettings: @tremor Text \u2192 plain <p>. Emerald/amber callout badges preserved as categorical colors; file added to .eslintrc.json overrides. Gates: TS \u2713 | Build \u2713. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
591c2c035a
commit
b58fd0b7fc
4 changed files with 205 additions and 92 deletions
|
|
@ -46,7 +46,8 @@
|
|||
"src/components/activity_metrics.tsx",
|
||||
"src/components/skill_hub_table_columns.tsx",
|
||||
"src/components/common_components/PassThroughSecuritySection.tsx",
|
||||
"src/components/common_components/DeleteResourceModal.tsx"
|
||||
"src/components/common_components/DeleteResourceModal.tsx",
|
||||
"src/components/common_components/PremiumLoggingSettings.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"litellm-ui/no-banned-ui-imports": "off",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { TextInput, Text } from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import { RobotOutlined } from "@ant-design/icons";
|
||||
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Bot } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
fetchAvailableModels,
|
||||
ModelGroup,
|
||||
} from "../playground/llm_calls/fetch_models";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
accessToken: string;
|
||||
|
|
@ -28,7 +38,8 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
labelText = "Select Model",
|
||||
}) => {
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>(value);
|
||||
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
|
||||
const [showCustomModelInput, setShowCustomModelInput] =
|
||||
useState<boolean>(false);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const customModelTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
|
@ -43,10 +54,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
try {
|
||||
const uniqueModels = await fetchAvailableModels(accessToken);
|
||||
console.log("Fetched models for selector:", uniqueModels);
|
||||
|
||||
if (uniqueModels.length > 0) {
|
||||
setModelInfo(uniqueModels);
|
||||
}
|
||||
if (uniqueModels.length > 0) setModelInfo(uniqueModels);
|
||||
} catch (error) {
|
||||
console.error("Error fetching model info:", error);
|
||||
}
|
||||
|
|
@ -55,62 +63,62 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
loadModels();
|
||||
}, [accessToken]);
|
||||
|
||||
const onModelChange = (value: string) => {
|
||||
if (value === "custom") {
|
||||
const onModelChange = (v: string) => {
|
||||
if (v === "custom") {
|
||||
setShowCustomModelInput(true);
|
||||
setSelectedModel(undefined);
|
||||
} else {
|
||||
setShowCustomModelInput(false);
|
||||
setSelectedModel(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
setSelectedModel(v);
|
||||
onChange?.(v);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomModelChange = (value: string) => {
|
||||
// Using setTimeout to create a simple debounce effect
|
||||
if (customModelTimeout.current) {
|
||||
clearTimeout(customModelTimeout.current);
|
||||
}
|
||||
|
||||
const handleCustomModelChange = (v: string) => {
|
||||
if (customModelTimeout.current) clearTimeout(customModelTimeout.current);
|
||||
customModelTimeout.current = setTimeout(() => {
|
||||
setSelectedModel(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}, 500); // 500ms delay after typing stops
|
||||
setSelectedModel(v);
|
||||
onChange?.(v);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const uniqueModels = useMemo(
|
||||
() => Array.from(new Set(modelInfo.map((o) => o.model_group))),
|
||||
[modelInfo],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showLabel && (
|
||||
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
|
||||
<RobotOutlined className="mr-2" /> {labelText}
|
||||
</Text>
|
||||
<div className="font-medium mb-2 text-foreground flex items-center">
|
||||
<Bot className="mr-2 h-4 w-4" /> {labelText}
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
value={selectedModel}
|
||||
placeholder={placeholder}
|
||||
onChange={onModelChange}
|
||||
options={[
|
||||
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group, index) => ({
|
||||
value: model_group,
|
||||
label: model_group,
|
||||
key: index,
|
||||
})),
|
||||
{ value: "custom", label: "Enter custom model", key: "custom" },
|
||||
]}
|
||||
style={{ width: "100%", ...style }}
|
||||
showSearch={true}
|
||||
className={`rounded-md ${className || ""}`}
|
||||
onValueChange={onModelChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger
|
||||
style={{ width: "100%", ...style }}
|
||||
className={cn(className)}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uniqueModels.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{g}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Enter custom model</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showCustomModelInput && (
|
||||
<TextInput
|
||||
<Input
|
||||
className="mt-2"
|
||||
placeholder="Enter custom model name"
|
||||
onValueChange={handleCustomModelChange}
|
||||
onChange={(e) => handleCustomModelChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPassThroughEndpointsCall } from "../networking";
|
||||
|
||||
interface PassThroughRoutesSelectorProps {
|
||||
|
|
@ -26,8 +34,12 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
|
|||
disabled = false,
|
||||
teamId,
|
||||
}) => {
|
||||
const [passThroughRoutes, setPassThroughRoutes] = useState<Array<{ label: string; value: string }>>([]);
|
||||
const [passThroughRoutes, setPassThroughRoutes] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPassThroughRoutes = async () => {
|
||||
|
|
@ -37,24 +49,21 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
|
|||
try {
|
||||
const response = await getPassThroughEndpointsCall(accessToken, teamId);
|
||||
if (response.endpoints) {
|
||||
const routes = response.endpoints.flatMap((endpoint: PassThroughEndpoint) => {
|
||||
const path = endpoint.path;
|
||||
const methods = endpoint.methods;
|
||||
|
||||
// If methods are specified, create one entry per method
|
||||
if (methods && methods.length > 0) {
|
||||
return methods.map((method) => ({
|
||||
label: `${method} ${path}`,
|
||||
value: path, // Keep value as path for backward compatibility
|
||||
}));
|
||||
}
|
||||
|
||||
// If no methods specified, show just the path (all methods supported)
|
||||
return [{
|
||||
label: path,
|
||||
value: path,
|
||||
}];
|
||||
});
|
||||
const routes = response.endpoints.flatMap(
|
||||
(endpoint: PassThroughEndpoint) => {
|
||||
const path = endpoint.path;
|
||||
const methods = endpoint.methods;
|
||||
|
||||
if (methods && methods.length > 0) {
|
||||
return methods.map((method) => ({
|
||||
label: `${method} ${path}`,
|
||||
value: path,
|
||||
}));
|
||||
}
|
||||
|
||||
return [{ label: path, value: path }];
|
||||
},
|
||||
);
|
||||
setPassThroughRoutes(routes);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -67,23 +76,111 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
|
|||
fetchPassThroughRoutes();
|
||||
}, [accessToken, teamId]);
|
||||
|
||||
const selected = value ?? [];
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
passThroughRoutes
|
||||
.filter((o) => !selected.includes(o.value))
|
||||
.filter((o) =>
|
||||
query ? o.label.toLowerCase().includes(query.toLowerCase()) : true,
|
||||
),
|
||||
[passThroughRoutes, selected, query],
|
||||
);
|
||||
|
||||
const labelFor = (v: string) =>
|
||||
passThroughRoutes.find((o) => o.value === v)?.label ?? v;
|
||||
|
||||
const addFreeform = () => {
|
||||
const v = query.trim();
|
||||
if (!v) return;
|
||||
if (!selected.includes(v)) onChange([...selected, v]);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowClear
|
||||
options={passThroughRoutes}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"min-h-9 w-full flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-left disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selected.length === 0 ? (
|
||||
<span className="text-muted-foreground px-1">
|
||||
{loading ? "Loading routes…" : placeholder}
|
||||
</span>
|
||||
) : (
|
||||
selected.map((v) => (
|
||||
<Badge
|
||||
key={v}
|
||||
variant="secondary"
|
||||
className="gap-1 inline-flex items-center"
|
||||
>
|
||||
{labelFor(v)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(selected.filter((s) => s !== v));
|
||||
}}
|
||||
className="inline-flex items-center"
|
||||
aria-label={`Remove ${labelFor(v)}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-2"
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search or type a custom route…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addFreeform();
|
||||
}
|
||||
}}
|
||||
className="h-8 mb-2"
|
||||
/>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="py-2 px-3 text-sm text-muted-foreground">
|
||||
{query ? (
|
||||
<span>
|
||||
Press Enter to add <span className="font-mono">{query}</span>
|
||||
</span>
|
||||
) : (
|
||||
"No matches"
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent"
|
||||
onClick={() => onChange([...selected, opt.value])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassThroughRoutesSelector;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import LoggingSettings from "../team/LoggingSettings";
|
||||
|
||||
interface PremiumLoggingSettingsProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange: (settings: any[]) => void;
|
||||
premiumUser?: boolean;
|
||||
disabledCallbacks?: string[];
|
||||
|
|
@ -21,22 +22,28 @@ export function PremiumLoggingSettings({
|
|||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<div className="inline-flex items-center px-3 py-1.5 rounded-lg bg-green-50 border border-green-200 text-green-800 text-sm font-medium opacity-50">
|
||||
<div className="inline-flex items-center px-3 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-900 text-emerald-800 dark:text-emerald-200 text-sm font-medium opacity-50">
|
||||
✨ langfuse-logging
|
||||
</div>
|
||||
<div className="inline-flex items-center px-3 py-1.5 rounded-lg bg-green-50 border border-green-200 text-green-800 text-sm font-medium opacity-50">
|
||||
<div className="inline-flex items-center px-3 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-900 text-emerald-800 dark:text-emerald-200 text-sm font-medium opacity-50">
|
||||
✨ datadog-logging
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for
|
||||
all free users. Get a trial key{" "}
|
||||
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 rounded-lg">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
Setting Key/Team logging settings is a LiteLLM Enterprise feature.
|
||||
Global Logging Settings are available for all free users. Get a
|
||||
trial key{" "}
|
||||
<a
|
||||
href="https://www.litellm.ai/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue