diff --git a/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx index 46c4db68c..6940526a9 100644 --- a/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx +++ b/ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx @@ -22,6 +22,7 @@ export enum RbacResource { Customers = "Customers", Teams = "Teams", RBAC = "RBAC", + Governance = "Governance", } // RBAC Operation Names (must match backend definitions) diff --git a/ui/app/workspace/model-limits/layout.tsx b/ui/app/workspace/model-limits/layout.tsx new file mode 100644 index 000000000..6a4a6013d --- /dev/null +++ b/ui/app/workspace/model-limits/layout.tsx @@ -0,0 +1,3 @@ +export default function ModelLimitsLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/ui/app/workspace/model-limits/page.tsx b/ui/app/workspace/model-limits/page.tsx new file mode 100644 index 000000000..5285e94e3 --- /dev/null +++ b/ui/app/workspace/model-limits/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ModelLimitsView from "./views/modelLimitsView"; + +export default function ModelLimitsPage() { + return ; +} diff --git a/ui/app/workspace/model-limits/views/modelLimitSheet.tsx b/ui/app/workspace/model-limits/views/modelLimitSheet.tsx new file mode 100644 index 000000000..fb5cafe13 --- /dev/null +++ b/ui/app/workspace/model-limits/views/modelLimitSheet.tsx @@ -0,0 +1,461 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import NumberAndSelect from "@/components/ui/numberAndSelect"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { DottedSeparator } from "@/components/ui/separator"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { resetDurationOptions } from "@/lib/constants/governance"; +import { RenderProviderIcon } from "@/lib/constants/icons"; +import { ProviderLabels, ProviderName } from "@/lib/constants/logs"; +import { getErrorMessage, useCreateModelConfigMutation, useGetProvidersQuery, useUpdateModelConfigMutation } from "@/lib/store"; +import { KnownProvider } from "@/lib/types/config"; +import { ModelConfig } from "@/lib/types/governance"; +import { cn } from "@/lib/utils"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, ChevronsUpDown, Gauge, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +interface ModelLimitSheetProps { + modelConfig?: ModelConfig | null; + onSave: () => void; + onCancel: () => void; +} + +const formSchema = z.object({ + modelName: z.string().min(1, "Model name is required"), + provider: z.string().optional(), + budgetMaxLimit: z.string().optional(), + budgetResetDuration: z.string().optional(), + tokenMaxLimit: z.string().optional(), + tokenResetDuration: z.string().optional(), + requestMaxLimit: z.string().optional(), + requestResetDuration: z.string().optional(), +}); + +type FormData = z.infer; + +export default function ModelLimitSheet({ modelConfig, onSave, onCancel }: ModelLimitSheetProps) { + const [isOpen, setIsOpen] = useState(true); + const isEditing = !!modelConfig; + + const hasCreateAccess = useRbac(RbacResource.Governance, RbacOperation.Create); + const hasUpdateAccess = useRbac(RbacResource.Governance, RbacOperation.Update); + const canSubmit = isEditing ? hasUpdateAccess : hasCreateAccess; + + const handleClose = () => { + setIsOpen(false); + setTimeout(() => { + onCancel(); + }, 150); + }; + + const { data: providersData } = useGetProvidersQuery(); + const [createModelConfig, { isLoading: isCreating }] = useCreateModelConfigMutation(); + const [updateModelConfig, { isLoading: isUpdating }] = useUpdateModelConfigMutation(); + const isLoading = isCreating || isUpdating; + + const availableProviders = providersData || []; + const [providerOpen, setProviderOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + modelName: modelConfig?.model_name || "", + provider: modelConfig?.provider || "", + budgetMaxLimit: modelConfig?.budget ? String(modelConfig.budget.max_limit) : "", + budgetResetDuration: modelConfig?.budget?.reset_duration || "1M", + tokenMaxLimit: modelConfig?.rate_limit?.token_max_limit ? String(modelConfig.rate_limit.token_max_limit) : "", + tokenResetDuration: modelConfig?.rate_limit?.token_reset_duration || "1h", + requestMaxLimit: modelConfig?.rate_limit?.request_max_limit ? String(modelConfig.rate_limit.request_max_limit) : "", + requestResetDuration: modelConfig?.rate_limit?.request_reset_duration || "1h", + }, + }); + + useEffect(() => { + if (modelConfig) { + // Never reset form if user is editing - skip reset entirely + if (form.formState.isDirty) { + return; + } + form.reset({ + modelName: modelConfig.model_name || "", + provider: modelConfig.provider || "", + budgetMaxLimit: modelConfig.budget ? String(modelConfig.budget.max_limit) : "", + budgetResetDuration: modelConfig.budget?.reset_duration || "1M", + tokenMaxLimit: modelConfig.rate_limit?.token_max_limit ? String(modelConfig.rate_limit.token_max_limit) : "", + tokenResetDuration: modelConfig.rate_limit?.token_reset_duration || "1h", + requestMaxLimit: modelConfig.rate_limit?.request_max_limit ? String(modelConfig.rate_limit.request_max_limit) : "", + requestResetDuration: modelConfig.rate_limit?.request_reset_duration || "1h", + }); + } + }, [modelConfig, form]); + + const onSubmit = async (data: FormData) => { + if (!canSubmit) { + toast.error("You don't have permission to perform this action"); + return; + } + + try { + const budgetMaxLimit = data.budgetMaxLimit ? parseFloat(data.budgetMaxLimit) : undefined; + const tokenMaxLimit = data.tokenMaxLimit ? parseInt(data.tokenMaxLimit) : undefined; + const requestMaxLimit = data.requestMaxLimit ? parseInt(data.requestMaxLimit) : undefined; + const provider = data.provider && data.provider.trim() !== "" ? data.provider : undefined; + + if (isEditing && modelConfig) { + const hadBudget = !!modelConfig.budget; + const hasBudget = !!budgetMaxLimit; + const hadRateLimit = !!modelConfig.rate_limit; + const hasRateLimit = !!tokenMaxLimit || !!requestMaxLimit; + + let budgetPayload: { max_limit?: number; reset_duration?: string } | undefined; + if (hasBudget) { + budgetPayload = { + max_limit: budgetMaxLimit, + reset_duration: data.budgetResetDuration || "1M", + }; + } else if (hadBudget) { + budgetPayload = {}; + } + + let rateLimitPayload: + | { + token_max_limit?: number | null; + token_reset_duration?: string | null; + request_max_limit?: number | null; + request_reset_duration?: string | null; + } + | undefined; + if (hasRateLimit) { + rateLimitPayload = { + token_max_limit: tokenMaxLimit ?? null, + token_reset_duration: tokenMaxLimit ? data.tokenResetDuration || "1h" : null, + request_max_limit: requestMaxLimit ?? null, + request_reset_duration: requestMaxLimit ? data.requestResetDuration || "1h" : null, + }; + } else if (hadRateLimit) { + rateLimitPayload = {}; + } + + await updateModelConfig({ + id: modelConfig.id, + data: { + model_name: data.modelName, + provider: provider, + budget: budgetPayload, + rate_limit: rateLimitPayload, + }, + }).unwrap(); + toast.success("Model limit updated successfully"); + } else { + await createModelConfig({ + model_name: data.modelName, + provider, + budget: budgetMaxLimit + ? { + max_limit: budgetMaxLimit, + reset_duration: data.budgetResetDuration || "1M", + } + : undefined, + rate_limit: + tokenMaxLimit || requestMaxLimit + ? { + token_max_limit: tokenMaxLimit, + token_reset_duration: data.tokenResetDuration || "1h", + request_max_limit: requestMaxLimit, + request_reset_duration: data.requestResetDuration || "1h", + } + : undefined, + }).unwrap(); + toast.success("Model limit created successfully"); + } + + onSave(); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }; + + return ( + !open && handleClose()}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + +
+ +
+ {isEditing ? "Edit Model Limit" : "Create Model Limit"} +
+ + {isEditing ? "Update budget and rate limit configuration." : "Set up budget and rate limits for a model."} + +
+ +
+ +
+ {/* Model Name */} + ( + + Model Name + + + + + + )} + /> + + {/* Provider */} + ( + + Provider + + + + + + + + + + + No provider found. + + {availableProviders.map((provider) => { + const isSelected = field.value === provider.name; + return ( + { + field.onChange(isSelected ? "" : provider.name); + setProviderOpen(false); + }} + > + + + + {provider.custom_provider_config + ? provider.name + : ProviderLabels[provider.name as ProviderName] || provider.name} + + + ); + })} + + + + + +

Leave empty to apply across all providers.

+ +
+ )} + /> + + + + {/* Budget Configuration */} +
+ + ( + + field.onChange(value)} + onChangeSelect={(value) => form.setValue("budgetResetDuration", value, { shouldDirty: true })} + options={resetDurationOptions} + /> + + + )} + /> +
+ + + + {/* Rate Limiting Configuration */} +
+ + + ( + + field.onChange(value)} + onChangeSelect={(value) => form.setValue("tokenResetDuration", value, { shouldDirty: true })} + options={resetDurationOptions} + /> + + + )} + /> + + ( + + field.onChange(value)} + onChangeSelect={(value) => form.setValue("requestResetDuration", value, { shouldDirty: true })} + options={resetDurationOptions} + /> + + + )} + /> +
+ + {/* Current Usage Display (for editing) */} + {isEditing && (modelConfig?.budget || modelConfig?.rate_limit) && ( + <> + +
+ +
+ {modelConfig?.budget && ( +
+

Budget

+

+ ${modelConfig.budget.current_usage.toFixed(2)} / ${modelConfig.budget.max_limit.toFixed(2)} +

+
+ )} + {modelConfig?.rate_limit?.token_max_limit && ( +
+

Tokens

+

+ {modelConfig.rate_limit.token_current_usage.toLocaleString()} /{" "} + {modelConfig.rate_limit.token_max_limit.toLocaleString()} +

+
+ )} + {modelConfig?.rate_limit?.request_max_limit && ( +
+

Requests

+

+ {modelConfig.rate_limit.request_current_usage.toLocaleString()} /{" "} + {modelConfig.rate_limit.request_max_limit.toLocaleString()} +

+
+ )} +
+
+ + )} +
+ + {/* Footer */} +
+
+ + + + + + + + + {(isLoading || !form.formState.isDirty || !form.formState.isValid || !canSubmit) && ( + +

+ {!canSubmit + ? "You don't have permission" + : isLoading + ? "Saving..." + : !form.formState.isDirty + ? "No changes made" + : "Please fix validation errors"} +

+
+ )} +
+
+
+
+
+ +
+
+ ); +} diff --git a/ui/app/workspace/model-limits/views/modelLimitsTable.tsx b/ui/app/workspace/model-limits/views/modelLimitsTable.tsx new file mode 100644 index 000000000..af6b4048b --- /dev/null +++ b/ui/app/workspace/model-limits/views/modelLimitsTable.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alertDialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; +import { ProviderLabels, ProviderName } from "@/lib/constants/logs"; +import { resetDurationLabels } from "@/lib/constants/governance"; +import { getErrorMessage, useDeleteModelConfigMutation } from "@/lib/store"; +import { ModelConfig } from "@/lib/types/governance"; +import { cn } from "@/lib/utils"; +import { formatCurrency } from "@/lib/utils/governance"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { Clock, DollarSign, Edit, Gauge, Plus, Trash2, Zap } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import ModelLimitSheet from "./modelLimitSheet"; + +// Helper to format reset duration for display +const formatResetDuration = (duration: string) => { + return resetDurationLabels[duration] || duration; +}; + +interface ModelLimitsTableProps { + modelConfigs: ModelConfig[]; + onRefresh: () => void; +} + +export default function ModelLimitsTable({ modelConfigs, onRefresh }: ModelLimitsTableProps) { + const [showModelLimitSheet, setShowModelLimitSheet] = useState(false); + const [editingModelConfig, setEditingModelConfig] = useState(null); + + const hasCreateAccess = useRbac(RbacResource.Governance, RbacOperation.Create); + const hasUpdateAccess = useRbac(RbacResource.Governance, RbacOperation.Update); + const hasDeleteAccess = useRbac(RbacResource.Governance, RbacOperation.Delete); + + const [deleteModelConfig, { isLoading: isDeleting }] = useDeleteModelConfigMutation(); + + const handleDelete = async (id: string) => { + try { + await deleteModelConfig(id).unwrap(); + toast.success("Model limit deleted successfully"); + onRefresh(); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }; + + const handleAddModelLimit = () => { + setEditingModelConfig(null); + setShowModelLimitSheet(true); + }; + + const handleEditModelLimit = (config: ModelConfig, e: React.MouseEvent) => { + e.stopPropagation(); + setEditingModelConfig(config); + setShowModelLimitSheet(true); + }; + + const handleModelLimitSaved = () => { + setShowModelLimitSheet(false); + setEditingModelConfig(null); + onRefresh(); + }; + + return ( + <> + {showModelLimitSheet && ( + setShowModelLimitSheet(false)} /> + )} + +
+ {/* Header */} +
+
+

+ Configure budgets and rate limits at the model level. For provider-specific limits, visit each provider's settings. +

+
+ +
+ + {/* Table or Empty State */} + {modelConfigs?.length === 0 ? ( +
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +

No Model Limits Configured

+

+ Add model-level limits to control spending and request rates across your infrastructure. +

+ + {/* CTA */} + + + {/* Feature hints */} +
+ Budget Controls + + Token Limits + + Request Throttling +
+
+ {/* Decorative elements */} +
+
+
+ ) : ( +
+ + + + Model + Provider + Budget + Rate Limit + + + + + {modelConfigs?.map((config) => { + const isBudgetExhausted = + config.budget?.max_limit && config.budget.max_limit > 0 && config.budget.current_usage >= config.budget.max_limit; + const isRateLimitExhausted = + (config.rate_limit?.token_max_limit && + config.rate_limit.token_max_limit > 0 && + config.rate_limit.token_current_usage >= config.rate_limit.token_max_limit) || + (config.rate_limit?.request_max_limit && + config.rate_limit.request_max_limit > 0 && + config.rate_limit.request_current_usage >= config.rate_limit.request_max_limit); + const isExhausted = isBudgetExhausted || isRateLimitExhausted; + + // Compute safe percentages to avoid division by zero + const budgetPercentage = + config.budget?.max_limit && config.budget.max_limit > 0 + ? Math.min((config.budget.current_usage / config.budget.max_limit) * 100, 100) + : 0; + const tokenPercentage = + config.rate_limit?.token_max_limit && config.rate_limit.token_max_limit > 0 + ? Math.min((config.rate_limit.token_current_usage / config.rate_limit.token_max_limit) * 100, 100) + : 0; + const requestPercentage = + config.rate_limit?.request_max_limit && config.rate_limit.request_max_limit > 0 + ? Math.min((config.rate_limit.request_current_usage / config.rate_limit.request_max_limit) * 100, 100) + : 0; + + return ( + + +
+ {config.model_name} + {isExhausted && ( + + Limit Reached + + )} +
+
+ + {config.provider ? ( +
+ + {ProviderLabels[config.provider as ProviderName] || config.provider} +
+ ) : ( + All Providers + )} +
+ + {config.budget ? ( + + + +
+
+
+ + {formatCurrency(config.budget.max_limit)} +
+
+ + {formatResetDuration(config.budget.reset_duration)} +
+
+ div]:bg-red-500" + : budgetPercentage > 80 + ? "[&>div]:bg-amber-500" + : "[&>div]:bg-emerald-500", + )} + /> +
+
+ +

+ {formatCurrency(config.budget.current_usage)} / {formatCurrency(config.budget.max_limit)} +

+

+ Resets {formatResetDuration(config.budget.reset_duration)} +

+
+
+
+ ) : ( + + )} +
+ + {config.rate_limit ? ( +
+ {config.rate_limit.token_max_limit && ( + + + +
+
+
+ + {config.rate_limit.token_max_limit.toLocaleString()} tokens +
+
+ + {formatResetDuration(config.rate_limit.token_reset_duration || "1h")} +
+
+ = config.rate_limit.token_max_limit + ? "[&>div]:bg-red-500" + : tokenPercentage > 80 + ? "[&>div]:bg-amber-500" + : "[&>div]:bg-emerald-500", + )} + /> +
+
+ +

+ {config.rate_limit.token_current_usage.toLocaleString()} /{" "} + {config.rate_limit.token_max_limit.toLocaleString()} tokens +

+

+ Resets {formatResetDuration(config.rate_limit.token_reset_duration || "1h")} +

+
+
+
+ )} + {config.rate_limit.request_max_limit && ( + + + +
+
+
+ + {config.rate_limit.request_max_limit.toLocaleString()} req +
+
+ + {formatResetDuration(config.rate_limit.request_reset_duration || "1h")} +
+
+ = config.rate_limit.request_max_limit + ? "[&>div]:bg-red-500" + : requestPercentage > 80 + ? "[&>div]:bg-amber-500" + : "[&>div]:bg-emerald-500", + )} + /> +
+
+ +

+ {config.rate_limit.request_current_usage.toLocaleString()} /{" "} + {config.rate_limit.request_max_limit.toLocaleString()} requests +

+

+ Resets {formatResetDuration(config.rate_limit.request_reset_duration || "1h")} +

+
+
+
+ )} +
+ ) : ( + + )} +
+ e.stopPropagation()}> +
+ + + + + + Edit + + + + + + + + + + + Delete + + + + + Delete Model Limit + + Are you sure you want to delete the limit for " + {config.model_name.length > 30 ? `${config.model_name.slice(0, 30)}...` : config.model_name} + "? This action cannot be undone. + + + + Cancel + handleDelete(config.id)} + disabled={isDeleting} + className="bg-red-600 hover:bg-red-700" + > + {isDeleting ? "Deleting..." : "Delete"} + + + + +
+
+
+ ); + })} +
+
+
+ )} +
+ + ); +} diff --git a/ui/app/workspace/model-limits/views/modelLimitsView.tsx b/ui/app/workspace/model-limits/views/modelLimitsView.tsx new file mode 100644 index 000000000..01a6fcb51 --- /dev/null +++ b/ui/app/workspace/model-limits/views/modelLimitsView.tsx @@ -0,0 +1,70 @@ +"use client"; + +import FullPageLoader from "@/components/fullPageLoader"; +import { getErrorMessage, useGetModelConfigsQuery, useLazyGetCoreConfigQuery } from "@/lib/store"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import ModelLimitsTable from "./modelLimitsTable"; + +export default function ModelLimitsView() { + const [governanceEnabled, setGovernanceEnabled] = useState(null); + const hasGovernanceAccess = useRbac(RbacResource.Governance, RbacOperation.View); + + const [triggerGetConfig] = useLazyGetCoreConfigQuery(); + + // Use regular query with skip, polling, and refetch on focus + const { + data: modelConfigsData, + error: modelConfigsError, + isLoading: modelConfigsLoading, + refetch: refetchModelConfigs, + } = useGetModelConfigsQuery(undefined, { + skip: !governanceEnabled || !hasGovernanceAccess, + pollingInterval: governanceEnabled && hasGovernanceAccess ? 10000 : 0, + refetchOnFocus: true, + skipPollingIfUnfocused: true, + }); + + const isLoading = modelConfigsLoading || governanceEnabled === null; + + useEffect(() => { + triggerGetConfig({ fromDB: true }) + .then((res) => { + if (res.data?.client_config?.enable_governance) { + setGovernanceEnabled(true); + } else { + setGovernanceEnabled(false); + toast.error("Governance is not enabled. Please enable it in the config."); + } + }) + .catch((err) => { + console.error("Failed to fetch config:", err); + setGovernanceEnabled(false); + toast.error(getErrorMessage(err) || "Failed to load configuration"); + }); + }, [triggerGetConfig]); + + // Handle query errors + useEffect(() => { + if (modelConfigsError) { + toast.error(`Failed to load model configs: ${getErrorMessage(modelConfigsError)}`); + } + }, [modelConfigsError]); + + const handleRefresh = () => { + if (governanceEnabled) { + refetchModelConfigs(); + } + }; + + if (isLoading) { + return ; + } + + return ( +
+ +
+ ); +} diff --git a/ui/app/workspace/providers/fragments/governanceFormFragment.tsx b/ui/app/workspace/providers/fragments/governanceFormFragment.tsx new file mode 100644 index 000000000..c0afe7d2f --- /dev/null +++ b/ui/app/workspace/providers/fragments/governanceFormFragment.tsx @@ -0,0 +1,495 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alertDialog"; +import { Button } from "@/components/ui/button"; +import { Form, FormField, FormItem } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import NumberAndSelect from "@/components/ui/numberAndSelect"; +import { DottedSeparator } from "@/components/ui/separator"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { resetDurationOptions } from "@/lib/constants/governance"; +import { + getErrorMessage, + useDeleteProviderGovernanceMutation, + useGetProviderGovernanceQuery, + useLazyGetCoreConfigQuery, + useUpdateProviderGovernanceMutation, +} from "@/lib/store"; +import { ModelProvider } from "@/lib/types/config"; +import { cn } from "@/lib/utils"; +import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowLeft, Plus, Settings2, Shield, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +interface GovernanceFormFragmentProps { + provider: ModelProvider; +} + +const formSchema = z.object({ + // Budget + budgetMaxLimit: z.string().optional(), + budgetResetDuration: z.string().optional(), + // Token limits + tokenMaxLimit: z.string().optional(), + tokenResetDuration: z.string().optional(), + // Request limits + requestMaxLimit: z.string().optional(), + requestResetDuration: z.string().optional(), +}); + +type FormData = z.infer; + +const DEFAULT_GOVERNANCE_FORM_VALUES: FormData = { + budgetMaxLimit: "", + budgetResetDuration: "1M", + tokenMaxLimit: "", + tokenResetDuration: "1h", + requestMaxLimit: "", + requestResetDuration: "1h", +}; + +export function GovernanceFormFragment({ provider }: GovernanceFormFragmentProps) { + const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update); + const hasViewAccess = useRbac(RbacResource.Governance, RbacOperation.View); + const [governanceEnabled, setGovernanceEnabled] = useState(null); + const [triggerGetConfig] = useLazyGetCoreConfigQuery(); + + // Check if governance is enabled + useEffect(() => { + triggerGetConfig({ fromDB: true }) + .then((res) => { + setGovernanceEnabled(!!res.data?.client_config?.enable_governance); + }) + .catch(() => { + setGovernanceEnabled(false); + }); + }, [triggerGetConfig]); + + const { data: providerGovernanceData, isLoading: isLoadingGovernance } = useGetProviderGovernanceQuery(undefined, { + skip: !hasViewAccess || !governanceEnabled, + pollingInterval: hasViewAccess && governanceEnabled ? 10000 : 0, + refetchOnFocus: true, + skipPollingIfUnfocused: true, + }); + const [updateProviderGovernance, { isLoading: isUpdating }] = useUpdateProviderGovernanceMutation(); + const [deleteProviderGovernance, { isLoading: isDeleting }] = useDeleteProviderGovernanceMutation(); + + // Track if user is in "create" mode (clicked the add button) + const [isCreating, setIsCreating] = useState(false); + + // Find governance data for this provider + const providerGovernance = providerGovernanceData?.providers?.find((p) => p.provider === provider.name); + const hasExistingGovernance = !!(providerGovernance?.budget || providerGovernance?.rate_limit); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: DEFAULT_GOVERNANCE_FORM_VALUES, + }); + + // Update form values when provider governance data is loaded (polling) + useEffect(() => { + // Never reset form during polling if user is editing + if (providerGovernance && !form.formState.isDirty) { + form.reset({ + budgetMaxLimit: providerGovernance.budget?.max_limit ? String(providerGovernance.budget.max_limit) : "", + budgetResetDuration: providerGovernance.budget?.reset_duration || "1M", + tokenMaxLimit: providerGovernance.rate_limit?.token_max_limit ? String(providerGovernance.rate_limit.token_max_limit) : "", + tokenResetDuration: providerGovernance.rate_limit?.token_reset_duration || "1h", + requestMaxLimit: providerGovernance.rate_limit?.request_max_limit ? String(providerGovernance.rate_limit.request_max_limit) : "", + requestResetDuration: providerGovernance.rate_limit?.request_reset_duration || "1h", + }); + } + }, [providerGovernance, form]); + + // Reset form and creation state when provider changes + useEffect(() => { + setIsCreating(false); + // Never reset form if user is editing - just skip the reset + if (form.formState.isDirty) { + return; + } + const newProvGov = providerGovernanceData?.providers?.find((p) => p.provider === provider.name); + form.reset({ + budgetMaxLimit: newProvGov?.budget?.max_limit ? String(newProvGov.budget.max_limit) : "", + budgetResetDuration: newProvGov?.budget?.reset_duration || "1M", + tokenMaxLimit: newProvGov?.rate_limit?.token_max_limit ? String(newProvGov.rate_limit.token_max_limit) : "", + tokenResetDuration: newProvGov?.rate_limit?.token_reset_duration || "1h", + requestMaxLimit: newProvGov?.rate_limit?.request_max_limit ? String(newProvGov.rate_limit.request_max_limit) : "", + requestResetDuration: newProvGov?.rate_limit?.request_reset_duration || "1h", + }); + }, [provider.name, form]); + + const onSubmit = async (data: FormData) => { + try { + const budgetMaxLimit = data.budgetMaxLimit ? parseFloat(data.budgetMaxLimit) : undefined; + const tokenMaxLimit = data.tokenMaxLimit ? parseInt(data.tokenMaxLimit) : undefined; + const requestMaxLimit = data.requestMaxLimit ? parseInt(data.requestMaxLimit) : undefined; + + // Determine if we need to send empty objects to signal removal + const hadBudget = !!providerGovernance?.budget; + const hasBudget = !!budgetMaxLimit; + const hadRateLimit = !!providerGovernance?.rate_limit; + const hasRateLimit = !!tokenMaxLimit || !!requestMaxLimit; + + let budgetPayload: { max_limit?: number; reset_duration?: string } | undefined; + if (hasBudget) { + budgetPayload = { + max_limit: budgetMaxLimit, + reset_duration: data.budgetResetDuration || "1M", + }; + } else if (hadBudget) { + budgetPayload = {}; + } + + let rateLimitPayload: + | { + token_max_limit?: number | null; + token_reset_duration?: string | null; + request_max_limit?: number | null; + request_reset_duration?: string | null; + } + | undefined; + if (hasRateLimit) { + rateLimitPayload = { + token_max_limit: tokenMaxLimit ?? null, + token_reset_duration: tokenMaxLimit ? data.tokenResetDuration || "1h" : null, + request_max_limit: requestMaxLimit ?? null, + request_reset_duration: requestMaxLimit ? data.requestResetDuration || "1h" : null, + }; + } else if (hadRateLimit) { + rateLimitPayload = {}; + } + + await updateProviderGovernance({ + provider: provider.name, + data: { + budget: budgetPayload, + rate_limit: rateLimitPayload, + }, + }).unwrap(); + + toast.success(isCreating ? "Governance configured successfully" : "Governance updated successfully"); + setIsCreating(false); + } catch (error) { + toast.error("Failed to update provider governance", { + description: getErrorMessage(error), + }); + } + }; + + const handleDelete = async () => { + try { + await deleteProviderGovernance(provider.name).unwrap(); + toast.success("Governance removed successfully"); + setIsCreating(false); + form.reset(DEFAULT_GOVERNANCE_FORM_VALUES); + } catch (error) { + toast.error("Failed to remove governance", { + description: getErrorMessage(error), + }); + } + }; + + const handleCancel = () => { + setIsCreating(false); + form.reset(DEFAULT_GOVERNANCE_FORM_VALUES); + }; + + if (isLoadingGovernance || governanceEnabled === null) { + return ( +
+
+
+ ); + } + + // Governance not enabled + if (!governanceEnabled) { + return ( +
+
+ +
+

Governance Not Enabled

+

+ Enable governance in your configuration to set up budget and rate limits for this provider. +

+
+ ); + } + + // Empty state - no governance configured and not in create mode + if (!hasExistingGovernance && !isCreating) { + return ( +
+
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +

No Governance Configured

+

+ Set up budget limits and rate controls to manage costs and prevent overuse of this provider's resources. +

+ + {/* CTA Button */} + + + + + + {!hasUpdateProviderAccess && ( + +

You don't have permission to configure governance

+
+ )} +
+
+ + {/* Feature hints */} +
+ Budget Limits + + Token Rate Limiting + + Request Throttling +
+
+
+
+ ); + } + + // Form state - either creating new or editing existing + return ( +
+ + {/* Header with back button (only in create mode) or settings icon */} +
+
+ {isCreating && !hasExistingGovernance ? ( +
+ +
+

Configure Governance

+

Set up budget and rate limits for this provider.

+
+
+ ) : ( +
+
+ +
+
+

Provider-Level Governance

+

+ Configure budget and rate limits that apply to all requests made through this provider. +

+
+
+ )} +
+
+ + {/* Budget Configuration */} +
+ + ( + + field.onChange(value)} + onChangeSelect={(value) => form.setValue("budgetResetDuration", value, { shouldDirty: true })} + options={resetDurationOptions} + /> + + )} + /> +
+ + + + {/* Rate Limiting Configuration */} +
+ + + ( + + field.onChange(value)} + onChangeSelect={(value) => form.setValue("tokenResetDuration", value, { shouldDirty: true })} + options={resetDurationOptions} + /> + + )} + /> + + ( + + field.onChange(value)} + onChangeSelect={(value) => form.setValue("requestResetDuration", value, { shouldDirty: true })} + options={resetDurationOptions} + /> + + )} + /> +
+ + {/* Current Usage Display - only when editing existing */} + {hasExistingGovernance && (providerGovernance?.budget || providerGovernance?.rate_limit) && ( + <> + +
+ +
+ {providerGovernance?.budget && ( +
+

Budget Usage

+

+ ${providerGovernance.budget.current_usage.toFixed(2)} / ${providerGovernance.budget.max_limit.toFixed(2)} +

+
+ )} + {providerGovernance?.rate_limit?.token_max_limit && ( +
+

Token Usage

+

+ {providerGovernance.rate_limit.token_current_usage.toLocaleString()} /{" "} + {providerGovernance.rate_limit.token_max_limit.toLocaleString()} +

+
+ )} + {providerGovernance?.rate_limit?.request_max_limit && ( +
+

Request Usage

+

+ {providerGovernance.rate_limit.request_current_usage.toLocaleString()} /{" "} + {providerGovernance.rate_limit.request_max_limit.toLocaleString()} +

+
+ )} +
+
+ + )} + + {/* Form Actions */} +
+ {/* Delete button - only show when editing existing governance */} + {hasExistingGovernance && ( + + + + + + + Remove Governance Configuration? + + This will remove all budget limits and rate controls for this provider. Any accumulated usage data will be lost. This + action cannot be undone. + + + + Cancel + + {isDeleting ? "Removing..." : "Remove Governance"} + + + + + )} + +
+ {isCreating && !hasExistingGovernance && ( + + )} + + + + + + {(!form.formState.isDirty || !form.formState.isValid) && ( + +

+ {!form.formState.isDirty && !form.formState.isValid + ? "No changes made and validation errors present" + : !form.formState.isDirty + ? "No changes made" + : "Please fix validation errors"} +

+
+ )} +
+
+
+
+ + + ); +} diff --git a/ui/app/workspace/providers/fragments/index.ts b/ui/app/workspace/providers/fragments/index.ts index 3358bcc0d..b203bcb85 100644 --- a/ui/app/workspace/providers/fragments/index.ts +++ b/ui/app/workspace/providers/fragments/index.ts @@ -1,6 +1,7 @@ export { AllowedRequestsFields } from "./allowedRequestsFields"; export { ApiKeyFormFragment } from "./apiKeysFormFragment"; export { ApiStructureFormFragment } from "./apiStructureFormFragment"; +export { GovernanceFormFragment } from "./governanceFormFragment"; export { NetworkFormFragment } from "./networkFormFragment"; export { PerformanceFormFragment as PerformanceTab } from "./performanceFormFragment"; export { ProxyFormFragment } from "./proxyFormFragment"; diff --git a/ui/app/workspace/providers/views/modelProviderConfig.tsx b/ui/app/workspace/providers/views/modelProviderConfig.tsx index 3f49e2fe3..09e185acc 100644 --- a/ui/app/workspace/providers/views/modelProviderConfig.tsx +++ b/ui/app/workspace/providers/views/modelProviderConfig.tsx @@ -2,10 +2,11 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { isKnownProvider, ModelProvider } from "@/lib/types/config"; import { useEffect, useMemo, useState } from "react"; -import { ApiStructureFormFragment, ProxyFormFragment } from "../fragments"; +import { ApiStructureFormFragment, GovernanceFormFragment, ProxyFormFragment } from "../fragments"; import { NetworkFormFragment } from "../fragments/networkFormFragment"; import { PerformanceFormFragment } from "../fragments/performanceFormFragment"; import ModelProviderKeysTableView from "./modelProviderKeysTableView"; +import ProviderGovernanceTable from "./providerGovernanceTable"; import { keysRequired } from "./utils"; interface Props { @@ -38,6 +39,12 @@ const availableTabs = (provider: ModelProvider) => { label: "Performance tuning", }); + // Governance tab for budgets and rate limits + availableTabs.push({ + id: "governance", + label: "Governance", + }); + return availableTabs; }; @@ -96,6 +103,9 @@ export default function ModelProviderConfig({ provider }: Props) { + + +
@@ -107,6 +117,7 @@ export default function ModelProviderConfig({ provider }: Props) { )} +
); } diff --git a/ui/app/workspace/providers/views/providerGovernanceTable.tsx b/ui/app/workspace/providers/views/providerGovernanceTable.tsx new file mode 100644 index 000000000..d78f59be2 --- /dev/null +++ b/ui/app/workspace/providers/views/providerGovernanceTable.tsx @@ -0,0 +1,278 @@ +"use client"; + +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, useLazyGetCoreConfigQuery } 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"; +import { AlertTriangle, Clock, DollarSign, Gauge, RefreshCw, Shield, Zap } from "lucide-react"; +import { useEffect, useState } from "react"; + +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 ( +
+ + {/* Background circle */} + + {/* Progress circle */} + 80 ? "text-amber-500" : "text-emerald-500", + )} + /> + +
+ 80 ? "text-amber-500" : "text-foreground")}> + {Math.round(percentage)}% + +
+
+ ); +} + +// Metric card component +function MetricCard({ + icon: Icon, + title, + value, + max, + unit, + resetDuration, + isExhausted, +}: { + icon: React.ElementType; + 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 ( +
+ {/* Subtle gradient overlay */} +
+ +
+
+
+
+ +
+ {title} + {isExhausted && ( + + + Exhausted + + )} +
+ + + + +
+
+ + {unit === "$" ? formatCurrency(value) : value.toLocaleString()} + + + / {unit === "$" ? formatCurrency(max) : `${max.toLocaleString()} ${unit}`} + +
+
+ + Resets {formatResetDuration(resetDuration)} +
+
+
+ +

+ {clampedPercentage}% of {title.toLowerCase()} used +

+
+
+
+
+ + +
+
+ ); +} + +export default function ProviderGovernanceTable({ provider, className }: Props) { + const hasViewAccess = useRbac(RbacResource.Governance, RbacOperation.View); + const [governanceEnabled, setGovernanceEnabled] = useState(null); + const [triggerGetConfig] = useLazyGetCoreConfigQuery(); + + // Check if governance is enabled + useEffect(() => { + triggerGetConfig({ fromDB: true }) + .then((res) => { + setGovernanceEnabled(!!res.data?.client_config?.enable_governance); + }) + .catch(() => { + setGovernanceEnabled(false); + }); + }, [triggerGetConfig]); + + const { data: providerGovernanceData, isLoading } = useGetProviderGovernanceQuery(undefined, { + skip: !hasViewAccess || !governanceEnabled, + pollingInterval: hasViewAccess && governanceEnabled ? 10000 : 0, + refetchOnFocus: true, + skipPollingIfUnfocused: true, + }); + + // 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 || governanceEnabled === null) { + return ( +
+ + +
Governance
+
+
+
+
+
+
+ ); + } + + // Governance not enabled or no governance configured - don't show the section + if (!governanceEnabled || !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 ( +
+ + +
Governance
+
+
+ +
+ {/* Budget Card */} + {budget && ( + + )} + + {/* Token Rate Limit Card */} + {rateLimit?.token_max_limit && ( + + )} + + {/* Request Rate Limit Card */} + {rateLimit?.request_max_limit && ( + + )} +
+
+ ); +} diff --git a/ui/components/sidebar.tsx b/ui/components/sidebar.tsx index 1c72e7fca..743985f8b 100644 --- a/ui/components/sidebar.tsx +++ b/ui/components/sidebar.tsx @@ -324,6 +324,7 @@ export default function AppSidebar() { const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View); const hasRbacAccess = useRbac(RbacResource.RBAC, RbacOperation.View); const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View); + const hasGovernanceAccess = useRbac(RbacResource.Governance, RbacOperation.View); const hasGuardrailsProvidersAccess = useRbac(RbacResource.GuardrailsProviders, RbacOperation.View); const hasGuardrailsConfigAccess = useRbac(RbacResource.GuardrailsConfig, RbacOperation.View); const hasClusterConfigAccess = useRbac(RbacResource.Cluster, RbacOperation.View); @@ -388,7 +389,8 @@ export default function AppSidebar() { url: "/workspace/governance", icon: Landmark, description: "Govern access", - hasAccess: hasVirtualKeysAccess || hasCustomersAccess || hasTeamsAccess || hasUserProvisioningAccess || hasRbacAccess, + hasAccess: + hasVirtualKeysAccess || hasGovernanceAccess || hasCustomersAccess || hasTeamsAccess || hasUserProvisioningAccess || hasRbacAccess, subItems: [ { title: "Virtual Keys", @@ -397,6 +399,13 @@ export default function AppSidebar() { description: "Manage virtual keys & access", hasAccess: hasVirtualKeysAccess, }, + { + title: "Model Limits", + url: "/workspace/model-limits", + icon: Gauge, + description: "Model-level budgets & rate limits", + hasAccess: hasGovernanceAccess, + }, { title: "Users & Groups", url: "/workspace/user-groups", diff --git a/ui/lib/constants/governance.ts b/ui/lib/constants/governance.ts index 562a117c6..b65f2e881 100644 --- a/ui/lib/constants/governance.ts +++ b/ui/lib/constants/governance.ts @@ -18,3 +18,16 @@ export const budgetDurationOptions = [ { label: "Weekly", value: "1w" }, { label: "Monthly", value: "1M" }, ]; + +// Map of duration values to short labels for display +export const resetDurationLabels: Record = { + "1m": "Every Minute", + "5m": "Every 5 Minutes", + "15m": "Every 15 Minutes", + "30m": "Every 30 Minutes", + "1h": "Hourly", + "6h": "Every 6 Hours", + "1d": "Daily", + "1w": "Weekly", + "1M": "Monthly", +}; diff --git a/ui/lib/store/apis/baseApi.ts b/ui/lib/store/apis/baseApi.ts index 754f826e2..f5898073f 100644 --- a/ui/lib/store/apis/baseApi.ts +++ b/ui/lib/store/apis/baseApi.ts @@ -155,6 +155,8 @@ export const baseApi = createApi({ "HealthCheck", "DBKeys", "Models", + "ModelConfigs", + "ProviderGovernance", "Plugins", "SCIMProviders", "User", @@ -172,7 +174,7 @@ export const baseApi = createApi({ // Helper function to extract error message from RTK Query error export const getErrorMessage = (error: unknown): string => { - if(error === undefined || error === null) { + if (error === undefined || error === null) { return "An unexpected error occurred"; } if (error instanceof Error) { diff --git a/ui/lib/store/apis/governanceApi.ts b/ui/lib/store/apis/governanceApi.ts index 0a15c300a..e26e942da 100644 --- a/ui/lib/store/apis/governanceApi.ts +++ b/ui/lib/store/apis/governanceApi.ts @@ -1,22 +1,29 @@ import { Budget, CreateCustomerRequest, + CreateModelConfigRequest, CreateTeamRequest, CreateVirtualKeyRequest, Customer, DebugStatsResponse, GetBudgetsResponse, GetCustomersResponse, + GetModelConfigsResponse, + GetProviderGovernanceResponse, GetRateLimitsResponse, GetTeamsResponse, GetUsageStatsResponse, GetVirtualKeysResponse, HealthCheckResponse, + ModelConfig, + ProviderGovernance, RateLimit, ResetUsageRequest, Team, UpdateBudgetRequest, UpdateCustomerRequest, + UpdateModelConfigRequest, + UpdateProviderGovernanceRequest, UpdateRateLimitRequest, UpdateTeamRequest, UpdateVirtualKeyRequest, @@ -224,6 +231,69 @@ export const governanceApi = baseApi.injectEndpoints({ query: () => "/governance/debug/health", providesTags: ["HealthCheck"], }), + + // Model Configs + getModelConfigs: builder.query({ + query: () => "/governance/model-configs", + providesTags: ["ModelConfigs"], + }), + + getModelConfig: builder.query<{ model_config: ModelConfig }, string>({ + query: (id) => `/governance/model-configs/${id}`, + providesTags: (result, error, id) => [{ type: "ModelConfigs", id }], + }), + + createModelConfig: builder.mutation<{ message: string; model_config: ModelConfig }, CreateModelConfigRequest>({ + query: (data) => ({ + url: "/governance/model-configs", + method: "POST", + body: data, + }), + invalidatesTags: ["ModelConfigs"], + }), + + updateModelConfig: builder.mutation<{ message: string; model_config: ModelConfig }, { id: string; data: UpdateModelConfigRequest }>({ + query: ({ id, data }) => ({ + url: `/governance/model-configs/${id}`, + method: "PUT", + body: data, + }), + invalidatesTags: (result, error, { id }) => ["ModelConfigs", { type: "ModelConfigs", id }], + }), + + deleteModelConfig: builder.mutation<{ message: string }, string>({ + query: (id) => ({ + url: `/governance/model-configs/${id}`, + method: "DELETE", + }), + invalidatesTags: ["ModelConfigs"], + }), + + // Provider Governance + getProviderGovernance: builder.query({ + query: () => "/governance/providers", + providesTags: ["ProviderGovernance"], + }), + + updateProviderGovernance: builder.mutation< + { message: string; provider: ProviderGovernance }, + { provider: string; data: UpdateProviderGovernanceRequest } + >({ + query: ({ provider, data }) => ({ + url: `/governance/providers/${encodeURIComponent(provider)}`, + method: "PUT", + body: data, + }), + invalidatesTags: ["ProviderGovernance"], + }), + + deleteProviderGovernance: builder.mutation<{ message: string }, string>({ + query: (provider) => ({ + url: `/governance/providers/${encodeURIComponent(provider)}`, + method: "DELETE", + }), + invalidatesTags: ["ProviderGovernance"], + }), }), }); @@ -269,6 +339,18 @@ export const { useGetGovernanceDebugStatsQuery, useGetGovernanceHealthQuery, + // Model Configs + useGetModelConfigsQuery, + useGetModelConfigQuery, + useCreateModelConfigMutation, + useUpdateModelConfigMutation, + useDeleteModelConfigMutation, + + // Provider Governance + useGetProviderGovernanceQuery, + useUpdateProviderGovernanceMutation, + useDeleteProviderGovernanceMutation, + // Lazy queries useLazyGetVirtualKeysQuery, useLazyGetVirtualKeyQuery, @@ -283,4 +365,6 @@ export const { useLazyGetUsageStatsQuery, useLazyGetGovernanceDebugStatsQuery, useLazyGetGovernanceHealthQuery, + useLazyGetModelConfigsQuery, + useLazyGetProviderGovernanceQuery, } = governanceApi; diff --git a/ui/lib/types/governance.ts b/ui/lib/types/governance.ts index c6ca80ab0..2360fe54c 100644 --- a/ui/lib/types/governance.ts +++ b/ui/lib/types/governance.ts @@ -207,10 +207,10 @@ export interface CreateRateLimitRequest { } export interface UpdateRateLimitRequest { - token_max_limit?: number; // Maximum tokens allowed - token_reset_duration?: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M" - request_max_limit?: number; // Maximum requests allowed - request_reset_duration?: string; // e.g., "30s", "5m", "1h", "1d", "1w", "1M" + token_max_limit?: number | null; // Maximum tokens allowed (null to clear) + token_reset_duration?: string | null; // e.g., "30s", "5m", "1h", "1d", "1w", "1M" (null to clear) + request_max_limit?: number | null; // Maximum requests allowed (null to clear) + request_reset_duration?: string | null; // e.g., "30s", "5m", "1h", "1d", "1w", "1M" (null to clear) } export interface ResetUsageRequest { @@ -276,3 +276,57 @@ export interface HealthCheckResponse { } >; } + +// Model Config for per-model budgeting and rate limiting +export interface ModelConfig { + id: string; + model_name: string; + provider?: string; // Optional provider - if empty/null, applies to all providers + budget_id?: string; + rate_limit_id?: string; + // Populated relationships + budget?: Budget; + rate_limit?: RateLimit; + created_at: string; + updated_at: string; +} + +// Request types for model config operations +export interface CreateModelConfigRequest { + model_name: string; + provider?: string; // Optional provider - if empty/null, applies to all providers + budget?: CreateBudgetRequest; + rate_limit?: CreateRateLimitRequest; +} + +export interface UpdateModelConfigRequest { + model_name?: string; + provider?: string; // Optional provider - if empty/null, applies to all providers + budget?: UpdateBudgetRequest; + rate_limit?: UpdateRateLimitRequest; +} + +// Response types for model configs +export interface GetModelConfigsResponse { + model_configs: ModelConfig[]; + count: number; +} + +// Provider governance - for extending provider with budget/rate limit +export interface ProviderGovernance { + provider: string; + budget_id?: string; + rate_limit_id?: string; + budget?: Budget; + rate_limit?: RateLimit; +} + +export interface UpdateProviderGovernanceRequest { + budget?: UpdateBudgetRequest; + rate_limit?: UpdateRateLimitRequest; +} + +export interface GetProviderGovernanceResponse { + providers: ProviderGovernance[]; + count: number; +}