first commit
This commit is contained in:
73
ui/app/workspace/providers/views/addProviderDropdown.tsx
Normal file
73
ui/app/workspace/providers/views/addProviderDropdown.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdownMenu";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { ProviderLabels } from "@/lib/constants/logs";
|
||||
import { PlusIcon, Settings2Icon } from "lucide-react";
|
||||
|
||||
export type ProviderOption = { name: string };
|
||||
|
||||
interface AddProviderDropdownProps {
|
||||
/** Provider names that are already in the sidebar (configured or added) */
|
||||
existingInSidebar: Set<string>;
|
||||
/** All known provider options to show (e.g. from ProviderNames / allProviders) */
|
||||
knownProviders: ProviderOption[];
|
||||
onSelectKnownProvider: (name: string) => void;
|
||||
onAddCustomProvider: () => void;
|
||||
disabled?: boolean;
|
||||
/** Optional: use compact trigger for empty state */
|
||||
variant?: "default" | "empty";
|
||||
}
|
||||
|
||||
export function AddProviderDropdown({
|
||||
existingInSidebar,
|
||||
knownProviders,
|
||||
onSelectKnownProvider,
|
||||
onAddCustomProvider,
|
||||
disabled = false,
|
||||
variant = "default",
|
||||
}: AddProviderDropdownProps) {
|
||||
const availableKnown = knownProviders.filter((p) => !existingInSidebar.has(p.name));
|
||||
const hasKnown = availableKnown.length > 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={variant === "empty" ? "default" : "sm"}
|
||||
data-testid="add-provider-btn"
|
||||
className={variant === "empty" ? "" : "w-full justify-start"}
|
||||
aria-label="Add new provider"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{variant === "empty" ? <span>Add provider</span> : <div className="text-xs">Add New Provider</div>}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="custom-scrollbar max-h-[min(70vh,24rem)] min-w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
|
||||
data-testid="add-provider-dropdown"
|
||||
>
|
||||
{availableKnown.map((p) => (
|
||||
<DropdownMenuItem key={p.name} data-testid={`add-provider-option-${p.name}`} onSelect={() => onSelectKnownProvider(p.name)}>
|
||||
<RenderProviderIcon provider={p.name as ProviderIconType} size="sm" className="h-4 w-4" />
|
||||
<span>{ProviderLabels[p.name as keyof typeof ProviderLabels] ?? p.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{hasKnown && <DropdownMenuSeparator />}
|
||||
{/* Add New Provider > Custom provider... — used by E2E (add-provider-option-custom) */}
|
||||
<DropdownMenuItem data-testid="add-provider-option-custom" onSelect={onAddCustomProvider}>
|
||||
<Settings2Icon className="h-4 w-4" />
|
||||
<span>Custom provider...</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
54
ui/app/workspace/providers/views/modelProviderConfig.tsx
Normal file
54
ui/app/workspace/providers/views/modelProviderConfig.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModelProvider } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { SettingsIcon, Trash } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import ProviderConfigSheet from "../dialogs/providerConfigSheet";
|
||||
import ModelProviderKeysTableView from "./modelProviderKeysTableView";
|
||||
import ProviderGovernanceTable from "./providerGovernanceTable";
|
||||
|
||||
interface Props {
|
||||
provider: ModelProvider;
|
||||
onRequestDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function ModelProviderConfig({ provider, onRequestDelete }: Props) {
|
||||
const [showConfigSheet, setShowConfigSheet] = useState(false);
|
||||
const hasGovernanceAccess = useRbac(RbacResource.Governance, RbacOperation.View);
|
||||
const hasDeleteProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Delete);
|
||||
|
||||
const showApiKeys = useMemo(() => {
|
||||
if (provider.custom_provider_config) {
|
||||
return !(provider.custom_provider_config?.is_key_less ?? false);
|
||||
}
|
||||
return true;
|
||||
}, [provider.name, provider.custom_provider_config?.is_key_less]);
|
||||
|
||||
const editConfigButton = (
|
||||
<div className="flex items-center gap-2">
|
||||
{onRequestDelete && hasDeleteProviderAccess && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRequestDelete}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Delete provider"
|
||||
data-testid="provider-delete-btn"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setShowConfigSheet(true)}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
Edit Provider Config
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<ProviderConfigSheet show={showConfigSheet} onCancel={() => setShowConfigSheet(false)} provider={provider} />
|
||||
<ModelProviderKeysTableView provider={provider} headerActions={editConfigButton} isKeyless={!showApiKeys} />
|
||||
{hasGovernanceAccess ? <ProviderGovernanceTable className="mt-4" provider={provider} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
ui/app/workspace/providers/views/modelProviderKeysTableView.tsx
Normal file
300
ui/app/workspace/providers/views/modelProviderKeysTableView.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alertDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getErrorMessage } from "@/lib/store";
|
||||
import { useDeleteProviderKeyMutation, useGetProviderKeysQuery, useUpdateProviderKeyMutation } from "@/lib/store/apis/providersApi";
|
||||
import { ModelProvider } from "@/lib/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { AlertCircle, CheckCircle2, EllipsisIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import AddNewKeySheet from "../dialogs/addNewKeySheet";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
provider: ModelProvider;
|
||||
headerActions?: ReactNode;
|
||||
isKeyless?: boolean;
|
||||
}
|
||||
|
||||
export default function ModelProviderKeysTableView({ provider, className, headerActions, isKeyless }: Props) {
|
||||
const providerName = provider.name?.toLowerCase() ?? "";
|
||||
const isVLLM = providerName === "vllm";
|
||||
const isOllamaOrSGL = providerName === "ollama" || providerName === "sgl";
|
||||
const entityLabel = isVLLM ? "model" : isOllamaOrSGL ? "server" : "key";
|
||||
const entityLabelPlural = isVLLM ? "models" : isOllamaOrSGL ? "servers" : "keys";
|
||||
const EntityLabel = entityLabel.charAt(0).toUpperCase() + entityLabel.slice(1);
|
||||
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
|
||||
const hasDeleteProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Delete);
|
||||
const [updateProviderKey, { isLoading: isUpdatingProviderKey }] = useUpdateProviderKeyMutation();
|
||||
const [deleteProviderKey, { isLoading: isDeletingProviderKey }] = useDeleteProviderKeyMutation();
|
||||
const { data: keys = [] } = useGetProviderKeysQuery(provider.name);
|
||||
const isMutatingProviderKey = isUpdatingProviderKey || isDeletingProviderKey;
|
||||
const [togglingKeyIds, setTogglingKeyIds] = useState<Set<string>>(new Set());
|
||||
const [showAddNewKeyDialog, setShowAddNewKeyDialog] = useState<{ show: boolean; keyId: string | null } | undefined>(undefined);
|
||||
const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState<{ show: boolean; keyId: string } | undefined>(undefined);
|
||||
|
||||
function handleAddKey() {
|
||||
setShowAddNewKeyDialog({ show: true, keyId: null });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{showDeleteKeyDialog && (
|
||||
<AlertDialog open={showDeleteKeyDialog.show}>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {EntityLabel}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this {entityLabel}. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="pt-4">
|
||||
<AlertDialogCancel onClick={() => setShowDeleteKeyDialog(undefined)} disabled={isMutatingProviderKey}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isMutatingProviderKey || !hasDeleteProviderAccess}
|
||||
onClick={() => {
|
||||
deleteProviderKey({
|
||||
provider: provider.name,
|
||||
keyId: showDeleteKeyDialog.keyId,
|
||||
})
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
toast.success(`${EntityLabel} deleted successfully`);
|
||||
setShowDeleteKeyDialog(undefined);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`Failed to delete ${entityLabel}`, {
|
||||
description: getErrorMessage(err),
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{showAddNewKeyDialog && (
|
||||
<AddNewKeySheet
|
||||
show={showAddNewKeyDialog.show}
|
||||
onCancel={() => setShowAddNewKeyDialog(undefined)}
|
||||
provider={provider}
|
||||
keyId={showAddNewKeyDialog.keyId}
|
||||
providerName={providerName}
|
||||
/>
|
||||
)}
|
||||
<CardHeader className="mb-4 px-0">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">Configured {entityLabelPlural}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{!isKeyless && (
|
||||
<Button
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
data-testid="add-key-btn"
|
||||
onClick={() => {
|
||||
handleAddKey();
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add new {entityLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{isKeyless ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-sm border py-10 text-center text-sm">
|
||||
<p>This is a keyless provider - no API keys are required.</p>
|
||||
<p>You can edit the provider configuration using the button above.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-2 rounded-sm border">
|
||||
<Table className="w-full" data-testid="keys-table">
|
||||
<TableHeader className="w-full">
|
||||
<TableRow>
|
||||
<TableHead>{isVLLM ? "Model" : isOllamaOrSGL ? "Server" : "API Key"}</TableHead>
|
||||
<TableHead>Weight</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead className="text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.length === 0 && (
|
||||
<TableRow data-testid="keys-table-empty-state">
|
||||
<TableCell colSpan={4} className="py-6 text-center">
|
||||
No {entityLabelPlural} found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{keys.map((key) => {
|
||||
const isKeyEnabled = key.enabled ?? true;
|
||||
return (
|
||||
<TableRow
|
||||
key={key.id}
|
||||
data-testid={`key-row-${key.name}`}
|
||||
className="text-sm transition-colors hover:bg-white"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{key.status === "success" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Key status: list models working"
|
||||
data-testid={`key-status-success-${key.name}`}
|
||||
className="inline-flex"
|
||||
>
|
||||
<CheckCircle2 aria-hidden className="h-4 w-4 flex-shrink-0 text-green-600" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>List models working</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{key.status === "list_models_failed" &&
|
||||
(() => {
|
||||
// Check if the failure might be due to an env var that the server couldn't resolve
|
||||
const hasEnvVarConfig =
|
||||
key.azure_key_config?.endpoint?.from_env ||
|
||||
key.vertex_key_config?.project_id?.from_env ||
|
||||
key.vertex_key_config?.region?.from_env ||
|
||||
key.bedrock_key_config?.region?.from_env ||
|
||||
key.vllm_key_config?.url?.from_env ||
|
||||
key.value?.from_env;
|
||||
const isEnvResolutionError =
|
||||
hasEnvVarConfig && key.description && /not set|empty|missing/i.test(key.description);
|
||||
|
||||
return isEnvResolutionError ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Key status: env var may not be resolved"
|
||||
data-testid={`key-status-warning-${key.name}`}
|
||||
className="inline-flex"
|
||||
>
|
||||
<AlertCircle aria-hidden className="h-4 w-4 flex-shrink-0 text-orange-500" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs break-words">
|
||||
{key.description} — verify the environment variable is set on the server
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Key status: list models failed"
|
||||
data-testid={`key-status-error-${key.name}`}
|
||||
className="inline-flex"
|
||||
>
|
||||
<AlertCircle aria-hidden className="text-destructive h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs break-words">
|
||||
{key.description || "Model discovery failed for this key"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
<span className="font-mono text-sm">{key.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell data-testid="key-weight-value">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-sm">{key.weight}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
data-testid="key-enabled-switch"
|
||||
checked={isKeyEnabled}
|
||||
size="md"
|
||||
disabled={!hasUpdateProviderAccess || togglingKeyIds.has(key.id)}
|
||||
onAsyncCheckedChange={async (checked) => {
|
||||
setTogglingKeyIds((prev) => new Set(prev).add(key.id));
|
||||
await updateProviderKey({
|
||||
provider: provider.name,
|
||||
keyId: key.id,
|
||||
key: { ...key, enabled: checked },
|
||||
})
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
toast.success(`${EntityLabel} ${checked ? "enabled" : "disabled"} successfully`);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`Failed to update ${entityLabel}`, { description: getErrorMessage(err) });
|
||||
})
|
||||
.finally(() => {
|
||||
setTogglingKeyIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key.id);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button onClick={(e) => e.stopPropagation()} variant="ghost">
|
||||
<EllipsisIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setShowAddNewKeyDialog({ show: true, keyId: key.id });
|
||||
}}
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
>
|
||||
<PencilIcon className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowDeleteKeyDialog({ show: true, keyId: key.id });
|
||||
}}
|
||||
disabled={!hasDeleteProviderAccess}
|
||||
>
|
||||
<TrashIcon className="mr-1 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
ui/app/workspace/providers/views/providerGovernanceTable.tsx
Normal file
250
ui/app/workspace/providers/views/providerGovernanceTable.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { resetDurationLabels } from "@/lib/constants/governance";
|
||||
import { useGetProviderGovernanceQuery } from "@/lib/store";
|
||||
import { ModelProvider } from "@/lib/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatCurrency } from "@/lib/utils/governance";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
provider: ModelProvider;
|
||||
}
|
||||
|
||||
// Helper to format reset duration for display
|
||||
const formatResetDuration = (duration: string) => {
|
||||
return resetDurationLabels[duration] || duration;
|
||||
};
|
||||
|
||||
// Circular progress component
|
||||
function CircularProgress({
|
||||
value,
|
||||
max,
|
||||
size = 80,
|
||||
strokeWidth = 6,
|
||||
isExhausted = false,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
isExhausted?: boolean;
|
||||
}) {
|
||||
const percentage = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90 transform">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-muted/70 dark:text-muted/30"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className={cn(
|
||||
"transition-all duration-500",
|
||||
isExhausted ? "text-red-500/70" : percentage > 80 ? "text-amber-500/70" : "text-emerald-500/70",
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className={cn("text-lg font-medium", isExhausted ? "text-red-500/70" : percentage > 80 ? "text-amber-500/70" : "text-foreground")}
|
||||
>
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Metric card component
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
max,
|
||||
unit,
|
||||
resetDuration,
|
||||
isExhausted,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
max: number;
|
||||
unit: string;
|
||||
resetDuration: string;
|
||||
isExhausted: boolean;
|
||||
}) {
|
||||
// Compute safe percentage to avoid division by zero
|
||||
const percentage = max > 0 ? Math.round((value / max) * 100) : 0;
|
||||
const clampedPercentage = Math.max(0, Math.min(100, percentage));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-sm border p-5 transition-all duration-300",
|
||||
"hover:shadow-lg hover:shadow-black/5",
|
||||
isExhausted ? "border-red-500/30 bg-red-500/5" : "border-border/50 bg-card hover:border-border",
|
||||
)}
|
||||
>
|
||||
{/* Subtle gradient overlay */}
|
||||
<div className="from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
|
||||
<div className="relative flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm font-medium whitespace-nowrap">{title}</span>
|
||||
{isExhausted && (
|
||||
<Badge variant="destructive" className="text-xs whitespace-nowrap">
|
||||
Exhausted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-medium tracking-tight">
|
||||
{unit === "$" ? formatCurrency(value) : value.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
/ {unit === "$" ? formatCurrency(max) : `${max.toLocaleString()} ${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Resets {formatResetDuration(resetDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="font-medium">
|
||||
{clampedPercentage}% of {title.toLowerCase()} used
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<CircularProgress value={value} max={max} isExhausted={isExhausted} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderGovernanceTable({ provider, className }: Props) {
|
||||
const hasViewAccess = useRbac(RbacResource.Governance, RbacOperation.View);
|
||||
const { data: providerGovernanceData, isLoading } = useGetProviderGovernanceQuery(undefined, {
|
||||
skip: !hasViewAccess,
|
||||
pollingInterval: 5000,
|
||||
});
|
||||
|
||||
// Find governance data for this provider
|
||||
const providerGovernance = providerGovernanceData?.providers?.find((p) => p.provider === provider.name);
|
||||
|
||||
// Check if any governance is configured
|
||||
const hasGovernance = providerGovernance?.budget || providerGovernance?.rate_limit;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<CardHeader className="mb-4 px-0">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">Governance</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="border-primary h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Governance not enabled or no governance configured - don't show the section
|
||||
if (!hasGovernance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const budget = providerGovernance?.budget;
|
||||
const rateLimit = providerGovernance?.rate_limit;
|
||||
|
||||
const isBudgetExhausted = !!(budget?.max_limit && budget.max_limit > 0 && budget.current_usage >= budget.max_limit);
|
||||
const isTokenExhausted = !!(
|
||||
rateLimit?.token_max_limit &&
|
||||
rateLimit.token_max_limit > 0 &&
|
||||
rateLimit.token_current_usage >= rateLimit.token_max_limit
|
||||
);
|
||||
const isRequestExhausted = !!(
|
||||
rateLimit?.request_max_limit &&
|
||||
rateLimit.request_max_limit > 0 &&
|
||||
rateLimit.request_current_usage >= rateLimit.request_max_limit
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<CardHeader className="mb-4 px-0">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">Governance</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Budget Card */}
|
||||
{budget && (
|
||||
<MetricCard
|
||||
title="Budget"
|
||||
value={budget.current_usage}
|
||||
max={budget.max_limit}
|
||||
unit="$"
|
||||
resetDuration={budget.reset_duration}
|
||||
isExhausted={isBudgetExhausted}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Token Rate Limit Card */}
|
||||
{rateLimit?.token_max_limit && (
|
||||
<MetricCard
|
||||
title="Token Limit"
|
||||
value={rateLimit.token_current_usage}
|
||||
max={rateLimit.token_max_limit}
|
||||
unit="tokens"
|
||||
resetDuration={rateLimit.token_reset_duration || "1h"}
|
||||
isExhausted={isTokenExhausted}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Request Rate Limit Card */}
|
||||
{rateLimit?.request_max_limit && (
|
||||
<MetricCard
|
||||
title="Request Limit"
|
||||
value={rateLimit.request_current_usage}
|
||||
max={rateLimit.request_max_limit}
|
||||
unit="requests"
|
||||
resetDuration={rateLimit.request_reset_duration || "1h"}
|
||||
isExhausted={isRequestExhausted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
ui/app/workspace/providers/views/providerKeyForm.tsx
Normal file
150
ui/app/workspace/providers/views/providerKeyForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigSyncAlert } from "@/components/ui/configSyncAlert";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getErrorMessage } from "@/lib/store";
|
||||
import { useCreateProviderKeyMutation, useGetProviderKeysQuery, useUpdateProviderKeyMutation } from "@/lib/store/apis/providersApi";
|
||||
import { ModelProvider } from "@/lib/types/config";
|
||||
import { modelProviderKeySchema } from "@/lib/types/schemas";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Save } from "lucide-react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { z } from "zod";
|
||||
import { ApiKeyFormFragment } from "../fragments";
|
||||
interface Props {
|
||||
provider: ModelProvider;
|
||||
keyId: string | null;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
// Create a simple form schema using only ModelProviderKeySchema
|
||||
const providerKeyFormSchema = z.object({
|
||||
key: modelProviderKeySchema,
|
||||
});
|
||||
|
||||
type ProviderKeyFormValues = z.infer<typeof modelProviderKeySchema>;
|
||||
|
||||
export default function ProviderKeyForm({ provider, keyId, onCancel, onSave }: Props) {
|
||||
const [createProviderKey, { isLoading: isCreatingProviderKey }] = useCreateProviderKeyMutation();
|
||||
const [updateProviderKey, { isLoading: isUpdatingProviderKey }] = useUpdateProviderKeyMutation();
|
||||
const { data: keys = [] } = useGetProviderKeysQuery(provider.name);
|
||||
const isEditing = keyId !== null;
|
||||
const currentKey = keyId ? keys.find((k) => k.id === keyId) : undefined;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(providerKeyFormSchema),
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: {
|
||||
key: (currentKey as ProviderKeyFormValues) ?? {
|
||||
id: uuid(),
|
||||
name: "",
|
||||
models: ["*"],
|
||||
blacklisted_models: [],
|
||||
weight: 1.0,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when currentKey arrives (handles late async resolution)
|
||||
// Skip reset if user has unsaved edits to avoid discarding changes during background refetches
|
||||
useEffect(() => {
|
||||
if (!isEditing || !currentKey || form.formState.isDirty) return;
|
||||
form.reset({ key: currentKey as ProviderKeyFormValues });
|
||||
}, [isEditing, currentKey, form]);
|
||||
|
||||
// Trigger validation on mount when editing existing data
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
form.trigger();
|
||||
}
|
||||
}, [isEditing, form]);
|
||||
|
||||
const getTooltipContent = useCallback(() => {
|
||||
if (!form.formState.isValid && form.formState.errors.root?.message) {
|
||||
return form.formState.errors.root?.message;
|
||||
}
|
||||
if (!form.formState.isDirty) {
|
||||
return "No changes made";
|
||||
}
|
||||
return null;
|
||||
}, [form?.formState.errors, form?.formState.isValid, form?.formState.isDirty]);
|
||||
|
||||
const onSubmit = (value: any) => {
|
||||
if (isEditing && !currentKey) return;
|
||||
// Strip internal _auth_type fields before sending to API
|
||||
const key = { ...value.key };
|
||||
if (key.azure_key_config) {
|
||||
const { _auth_type, ...rest } = key.azure_key_config;
|
||||
key.azure_key_config = rest;
|
||||
}
|
||||
if (key.vertex_key_config) {
|
||||
const { _auth_type, ...rest } = key.vertex_key_config;
|
||||
key.vertex_key_config = rest;
|
||||
}
|
||||
if (key.bedrock_key_config) {
|
||||
const { _auth_type, ...rest } = key.bedrock_key_config;
|
||||
key.bedrock_key_config = rest;
|
||||
}
|
||||
const mutation = isEditing
|
||||
? updateProviderKey({
|
||||
provider: provider.name,
|
||||
keyId: currentKey!.id,
|
||||
key,
|
||||
})
|
||||
: createProviderKey({
|
||||
provider: provider.name,
|
||||
key,
|
||||
});
|
||||
|
||||
mutation
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
onSave();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(isEditing ? "Error updating key" : "Error creating key", {
|
||||
description: getErrorMessage(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<ApiKeyFormFragment control={form.control} providerName={provider.name} form={form} />
|
||||
{isEditing && currentKey?.config_hash && <ConfigSyncAlert className="mt-4" />}
|
||||
<div className="dark:bg-card bg-white pt-6">
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button type="button" variant="outline" onClick={onCancel} data-testid="key-cancel-btn">
|
||||
Cancel
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isDirty}
|
||||
isLoading={form.formState.isSubmitting || isCreatingProviderKey || isUpdatingProviderKey}
|
||||
data-testid="key-save-btn"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{getTooltipContent() && <TooltipContent>{getTooltipContent()}</TooltipContent>}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
38
ui/app/workspace/providers/views/providersEmptyState.tsx
Normal file
38
ui/app/workspace/providers/views/providersEmptyState.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowUpRight, Server } from "lucide-react";
|
||||
|
||||
const PROVIDERS_DOCS_URL = "https://docs.getbifrost.ai/providers/supported-providers/overview";
|
||||
|
||||
interface ProvidersEmptyStateProps {
|
||||
/** Dropdown (or button) for adding a provider; never greyed out */
|
||||
addProviderDropdown: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProvidersEmptyState({ addProviderDropdown }: ProvidersEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Server className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-muted-foreground text-xl font-medium">Add a provider to start routing requests</h1>
|
||||
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
|
||||
Configure API keys for OpenAI, Anthropic, Bedrock, and other supported providers. Bifrost unifies them behind a single API.
|
||||
</div>
|
||||
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Read more about providers (opens in new tab)"
|
||||
data-testid="providers-button-read-more"
|
||||
onClick={() => {
|
||||
window.open(`${PROVIDERS_DOCS_URL}?utm_source=bfd`, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
|
||||
</Button>
|
||||
{addProviderDropdown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
ui/app/workspace/providers/views/utils.ts
Normal file
18
ui/app/workspace/providers/views/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DefaultNetworkConfig, DefaultPerformanceConfig } from "@/lib/constants/config";
|
||||
import { ModelProvider, UpdateProviderRequest } from "@/lib/types/config";
|
||||
|
||||
export const buildProviderUpdatePayload = (provider: ModelProvider, updates: Partial<UpdateProviderRequest>) => {
|
||||
const { name } = provider;
|
||||
|
||||
return {
|
||||
name,
|
||||
network_config: updates.network_config ?? provider.network_config ?? DefaultNetworkConfig,
|
||||
concurrency_and_buffer_size: updates.concurrency_and_buffer_size ?? provider.concurrency_and_buffer_size ?? DefaultPerformanceConfig,
|
||||
proxy_config: updates.proxy_config ?? provider.proxy_config,
|
||||
send_back_raw_request: updates.send_back_raw_request ?? provider.send_back_raw_request,
|
||||
send_back_raw_response: updates.send_back_raw_response ?? provider.send_back_raw_response,
|
||||
store_raw_request_response: updates.store_raw_request_response ?? provider.store_raw_request_response,
|
||||
custom_provider_config: updates.custom_provider_config ?? provider.custom_provider_config,
|
||||
openai_config: updates.openai_config ?? provider.openai_config,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user