first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
};