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,244 @@
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { getErrorMessage, useCreateProviderMutation } from "@/lib/store";
import { BaseProvider, ModelProviderName } from "@/lib/types/config";
import { allowedRequestsSchema } from "@/lib/types/schemas";
import { cleanPathOverrides } from "@/lib/utils/validation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AllowedRequestsFields } from "../fragments/allowedRequestsFields";
const formSchema = z.object({
name: z.string().min(1),
baseFormat: z.string().min(1),
base_url: z.string().min(1, "Base URL is required").url("Must be a valid URL"),
allowed_requests: allowedRequestsSchema,
request_path_overrides: z.record(z.string(), z.string().optional()).optional(),
is_key_less: z.boolean().optional(),
});
type FormData = z.infer<typeof formSchema>;
export interface AddCustomProviderSheetContentProps {
show?: boolean;
onSave: (id: string) => void;
onClose: () => void;
}
interface Props extends AddCustomProviderSheetContentProps {
show: boolean;
}
export function AddCustomProviderSheetContent({ show = true, onClose, onSave }: AddCustomProviderSheetContentProps) {
const [addProvider, { isLoading: isAddingProvider }] = useCreateProviderMutation();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
baseFormat: "",
base_url: "",
allowed_requests: {
text_completion: true,
text_completion_stream: true,
chat_completion: true,
chat_completion_stream: true,
responses: true,
responses_stream: true,
embedding: true,
speech: true,
speech_stream: true,
transcription: true,
transcription_stream: true,
image_generation: true,
image_generation_stream: true,
image_edit: true,
image_edit_stream: true,
image_variation: true,
rerank: true,
ocr: true,
ocr_stream: true,
video_generation: true,
video_retrieve: true,
video_download: true,
video_delete: true,
video_list: true,
video_remix: true,
count_tokens: true,
list_models: true,
websocket_responses: true,
realtime: false,
},
request_path_overrides: undefined,
is_key_less: false,
},
});
useEffect(() => {
if (show) {
form.clearErrors();
}
}, [show]);
const onSubmit = (data: FormData) => {
const payload = {
provider: data.name as ModelProviderName,
custom_provider_config: {
base_provider_type: data.baseFormat as BaseProvider,
allowed_requests: data.allowed_requests,
request_path_overrides: cleanPathOverrides(data.request_path_overrides),
is_key_less: data.is_key_less ?? false,
},
network_config: {
base_url: data.base_url,
default_request_timeout_in_seconds: 30,
max_retries: 0,
retry_backoff_initial: 500,
retry_backoff_max: 5000,
},
};
addProvider(payload)
.unwrap()
.then((provider) => {
onSave(provider.name);
form.reset();
})
.catch((err) => {
toast.error("Failed to add provider", {
description: getErrorMessage(err),
});
});
};
const baseFormat = form.watch("baseFormat") as BaseProvider;
const isKeyLessDisabled = baseFormat === "bedrock";
return (
<>
<SheetHeader className="flex shrink-0 flex-col items-start">
<SheetTitle>Add Custom Provider</SheetTitle>
<SheetDescription>Enter the details of your custom provider.</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="custom-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex flex-col gap-3">
<FormLabel className="text-right">Name</FormLabel>
<div className="col-span-3">
<FormControl>
<Input placeholder="Name" data-testid="custom-provider-name" {...field} />
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseFormat"
render={({ field }) => (
<FormItem className="flex flex-col gap-3">
<FormLabel>Base Format</FormLabel>
<div>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full" data-testid="base-provider-select">
<SelectValue placeholder="Select base format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="cohere">Cohere</SelectItem>
<SelectItem value="bedrock">AWS Bedrock</SelectItem>
<SelectItem value="replicate">Replicate</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem className="flex flex-col gap-3">
<FormLabel>Base URL</FormLabel>
<div>
<FormControl>
<Input
placeholder={"https://api.your-provider.com"}
data-testid="base-url-input"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
)}
/>
{!isKeyLessDisabled && (
<FormField
control={form.control}
name="is_key_less"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-3">
<div className="space-y-0.5">
<label htmlFor="drop-excess-requests" className="text-sm font-medium">
Is Keyless?
</label>
<p className="text-muted-foreground text-sm">Whether the custom provider requires a key</p>
</div>
<Switch
id="drop-excess-requests"
size="md"
checked={field.value}
onCheckedChange={field.onChange}
data-testid="custom-provider-keyless-switch"
/>
</div>
</FormItem>
)}
/>
)}
{/* Allowed Requests Configuration */}
<AllowedRequestsFields control={form.control} providerType={form.watch("baseFormat") as BaseProvider} />
<div className="align-end mt-10 ml-auto flex flex-row gap-2 border-t pt-4">
<Button type="button" variant="outline" onClick={onClose} className="ml-auto" data-testid="custom-provider-cancel-btn">
Cancel
</Button>
<Button type="submit" isLoading={isAddingProvider} data-testid="custom-provider-save-btn">
Add
</Button>
</div>
</div>
</form>
</Form>
</>
);
}
export default function AddCustomProviderSheet(props: Props) {
return (
<Sheet open={props.show} onOpenChange={(open) => !open && props.onClose()}>
<SheetContent className="custom-scrollbar flex flex-col p-8 sm:max-w-3xl" data-testid="custom-provider-sheet">
<AddCustomProviderSheetContent {...props} />
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,57 @@
import Provider from "@/components/provider";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { ModelProvider } from "@/lib/types/config";
import { toast } from "sonner";
import ProviderKeyForm from "../views/providerKeyForm";
interface Props {
show: boolean;
onCancel: () => void;
provider: ModelProvider;
keyId: string | null;
providerName?: string;
}
export default function AddNewKeySheet({ show, onCancel, provider, keyId, providerName }: Props) {
const isEditing = keyId !== null;
const resolvedProviderName = (providerName ?? provider.name).toLowerCase();
const isVLLM = resolvedProviderName === "vllm";
const isOllamaOrSGL = resolvedProviderName === "ollama" || resolvedProviderName === "sgl";
const entityLabel = isVLLM ? "model" : isOllamaOrSGL ? "server" : "key";
const EntityLabel = entityLabel.charAt(0).toUpperCase() + entityLabel.slice(1);
const dialogTitle = isEditing ? `Edit ${entityLabel}` : `Add new ${entityLabel}`;
const successMessage = isEditing ? `${EntityLabel} updated successfully` : `${EntityLabel} added successfully`;
return (
<Sheet
open={show}
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<SheetContent className="custom-scrollbar p-8" data-testid="key-form" onInteractOutside={(e) => e.preventDefault()}>
<SheetHeader className="flex flex-col items-start">
<SheetTitle>
<div className="font-lg flex items-center gap-2">
<div className={"flex items-center"}>
<Provider provider={provider.name} size={24} />:
</div>
{dialogTitle}
</div>
</SheetTitle>
</SheetHeader>
<div>
<ProviderKeyForm
provider={provider}
keyId={keyId}
onCancel={onCancel}
onSave={() => {
toast.success(successMessage);
onCancel();
}}
/>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,56 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
} from "@/components/ui/alertDialog";
import { getErrorMessage, useDeleteProviderMutation } from "@/lib/store";
import { ModelProvider } from "@/lib/types/config";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { AlertDialogTitle } from "@radix-ui/react-alert-dialog";
import { toast } from "sonner";
interface Props {
show: boolean;
onCancel: () => void;
onDelete: () => void;
provider: ModelProvider;
}
export default function ConfirmDeleteProviderDialog({ show, onCancel, onDelete, provider }: Props) {
const [deleteProvider, { isLoading: isDeletingProvider }] = useDeleteProviderMutation();
const hasDeleteAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Delete);
const onDeleteHandler = () => {
deleteProvider(provider.name)
.unwrap()
.then(() => {
onDelete();
})
.catch((err) => {
toast.error("Failed to delete provider", {
description: getErrorMessage(err),
});
});
};
return (
<AlertDialog open={show}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete this provider? This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDeleteHandler} disabled={isDeletingProvider || !hasDeleteAccess}>
{isDeletingProvider ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,39 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alertDialog";
interface Props {
show: boolean;
onContinue: () => void;
onCancel: () => void;
}
export default function ConfirmRedirectionDialog({ show, onContinue, onCancel }: Props) {
return (
<AlertDialog open={show}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Redirection</AlertDialogTitle>
<AlertDialogDescription>You have unsaved data. Are you sure you want to continue?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onContinue();
}}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,155 @@
import Provider from "@/components/provider";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ModelProvider } from "@/lib/types/config";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { useEffect, useMemo, useState } from "react";
import {
ApiStructureFormFragment,
BetaHeadersFormFragment,
GovernanceFormFragment,
OpenAIConfigFormFragment,
ProxyFormFragment,
} from "../fragments";
import { DebuggingFormFragment } from "../fragments/debuggingFormFragment";
import { NetworkFormFragment } from "../fragments/networkFormFragment";
import { PerformanceFormFragment } from "../fragments/performanceFormFragment";
interface Props {
show: boolean;
onCancel: () => void;
provider: ModelProvider;
}
const ANTHROPIC_FAMILY_PROVIDERS = ["anthropic", "vertex", "bedrock", "azure"];
const availableTabs = (hasCustomProviderConfig: boolean, hasGovernanceAccess: boolean, isOpenAI: boolean, isAnthropicFamily: boolean) => {
const tabs = [];
if (hasCustomProviderConfig) {
tabs.push({
id: "api-structure",
label: "API Structure",
});
}
tabs.push({
id: "network",
label: "Network",
});
tabs.push({
id: "proxy",
label: "Proxy",
});
tabs.push({
id: "performance",
label: "Performance",
});
if (hasGovernanceAccess) {
tabs.push({
id: "governance",
label: "Governance",
});
}
if (isAnthropicFamily) {
tabs.push({
id: "beta-headers",
label: "Beta Headers",
});
}
tabs.push({
id: "debugging",
label: "Debugging",
});
if (isOpenAI) {
tabs.push({
id: "openai-config",
label: "OpenAI Config",
});
}
return tabs;
};
export default function ProviderConfigSheet({ show, onCancel, provider }: Props) {
const [selectedTab, setSelectedTab] = useState<string | undefined>(undefined);
const hasGovernanceAccess = useRbac(RbacResource.Governance, RbacOperation.View);
const hasCustomProviderConfig = !!provider.custom_provider_config;
const isOpenAI = provider.name === "openai";
const isAnthropicFamily = ANTHROPIC_FAMILY_PROVIDERS.includes(provider.name.toLowerCase());
const tabs = useMemo(() => {
return availableTabs(hasCustomProviderConfig, hasGovernanceAccess, isOpenAI, isAnthropicFamily);
}, [hasCustomProviderConfig, hasGovernanceAccess, isOpenAI, isAnthropicFamily]);
useEffect(() => {
setSelectedTab((previousTab) => {
if (previousTab && tabs.some((tab) => tab.id === previousTab)) {
return previousTab;
}
return tabs[0]?.id;
});
}, [tabs]);
return (
<Sheet
open={show}
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<SheetContent className="custom-scrollbar p-8 sm:max-w-[50%]">
<SheetHeader className="flex flex-col items-start">
<SheetTitle>
<div className="font-lg flex items-center gap-2">
<div className="flex items-center">
<Provider provider={provider.name} size={24} />
</div>
Provider configuration
</div>
</SheetTitle>
</SheetHeader>
<div className="w-full rounded-sm border">
<Tabs defaultValue={tabs[0]?.id} value={selectedTab} onValueChange={setSelectedTab} className="space-y-6">
<div className="custom-scrollbar mb-4 w-full overflow-x-auto">
<TabsList className="h-10 w-max min-w-full justify-start rounded-tl-sm rounded-tr-sm rounded-br-none rounded-bl-none">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
data-testid={`provider-tab-${tab.id}`}
className="flex-none px-3 whitespace-nowrap"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value="api-structure">
<ApiStructureFormFragment provider={provider} />
</TabsContent>
<TabsContent value="openai-config">
<OpenAIConfigFormFragment provider={provider} />
</TabsContent>
<TabsContent value="network">
<NetworkFormFragment provider={provider} />
</TabsContent>
<TabsContent value="proxy">
<ProxyFormFragment provider={provider} />
</TabsContent>
<TabsContent value="performance">
<PerformanceFormFragment provider={provider} />
</TabsContent>
<TabsContent value="governance">
<GovernanceFormFragment provider={provider} />
</TabsContent>
<TabsContent value="beta-headers">
<BetaHeadersFormFragment provider={provider} />
</TabsContent>
<TabsContent value="debugging">
<DebuggingFormFragment provider={provider} />
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,195 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { BaseProvider, RequestType } from "@/lib/types/config";
import { isRequestTypeDisabled } from "@/lib/utils/validation";
import { Settings2 } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Control, useFormContext } from "react-hook-form";
interface AllowedRequestsFieldsProps {
control: Control<any>;
namePrefix?: string;
pathOverridesPrefix?: string;
providerType?: BaseProvider;
disabled?: boolean;
}
// Provider-specific endpoint paths
const ProviderEndpoints: Partial<Record<BaseProvider, Partial<Record<RequestType, string>>>> = {
openai: {
list_models: "/v1/models",
text_completion: "/v1/completions",
text_completion_stream: "/v1/completions",
chat_completion: "/v1/chat/completions",
chat_completion_stream: "/v1/chat/completions",
responses: "/v1/responses",
responses_stream: "/v1/responses",
embedding: "/v1/embeddings",
speech: "/v1/audio/speech",
speech_stream: "/v1/audio/speech",
transcription: "/v1/audio/transcriptions",
transcription_stream: "/v1/audio/transcriptions",
image_generation: "/v1/images/generations",
image_generation_stream: "/v1/images/generations",
image_edit: "/v1/images/edits",
image_edit_stream: "/v1/images/edits",
image_variation: "/v1/images/variations",
count_tokens: "/v1/responses/tokens",
},
anthropic: {
chat_completion: "/v1/messages",
chat_completion_stream: "/v1/messages",
responses: "/v1/messages",
responses_stream: "/v1/messages",
},
cohere: {
chat_completion: "/v2/chat",
chat_completion_stream: "/v2/chat",
responses: "/v2/chat",
responses_stream: "/v2/chat",
embedding: "/v2/embed",
},
};
// Helper function to get the appropriate placeholder
const getPlaceholder = (providerType: BaseProvider | undefined, requestKey: RequestType): string => {
if (providerType && ProviderEndpoints[providerType]?.[requestKey]) {
return ProviderEndpoints[providerType][requestKey]!;
}
return ProviderEndpoints["openai"]?.[requestKey] ?? "";
};
const RequestTypes: Array<{ key: RequestType; label: string }> = [
{ key: "list_models", label: "List Models" },
{ key: "text_completion", label: "Text Completion" },
{ key: "text_completion_stream", label: "Text Completion Stream" },
{ key: "chat_completion", label: "Chat Completion" },
{ key: "chat_completion_stream", label: "Chat Completion Stream" },
{ key: "responses", label: "Responses" },
{ key: "responses_stream", label: "Responses Stream" },
{ key: "embedding", label: "Embedding" },
{ key: "speech", label: "Speech" },
{ key: "speech_stream", label: "Speech Stream" },
{ key: "transcription", label: "Transcription" },
{ key: "transcription_stream", label: "Transcription Stream" },
{ key: "image_generation", label: "Image Generation" },
{ key: "image_generation_stream", label: "Image Generation Stream" },
{ key: "image_edit", label: "Image Edit" },
{ key: "image_edit_stream", label: "Image Edit Stream" },
{ key: "image_variation", label: "Image Variation" },
{ key: "count_tokens", label: "Count Tokens" },
];
export function AllowedRequestsFields({
control,
namePrefix = "allowed_requests",
pathOverridesPrefix = "request_path_overrides",
providerType,
disabled = false,
}: AllowedRequestsFieldsProps) {
const leftColumn = RequestTypes.slice(0, RequestTypes.length / 2);
const rightColumn = RequestTypes.slice(RequestTypes.length / 2);
const { getValues, setValue } = useFormContext();
// Reset disabled fields when providerType changes
useEffect(() => {
RequestTypes.forEach(({ key }) => {
const fieldName = `${namePrefix}.${key}`;
setValue(fieldName, !isRequestTypeDisabled(providerType, key), { shouldDirty: true });
});
}, [providerType, namePrefix, setValue, getValues]);
const isPathOverrideDisabled = useMemo(() => providerType === "gemini" || providerType === "bedrock", [providerType]);
const renderRequestField = (requestType: { key: RequestType; label: string }) => {
const isDisabled = isRequestTypeDisabled(providerType, requestType.key);
const placeholder = getPlaceholder(providerType, requestType.key);
return (
<FormField
key={requestType.key}
control={control}
name={`${namePrefix}.${requestType.key}`}
render={({ field: allowedField }) => (
<FormItem
className={`flex flex-row items-center justify-between rounded-lg border p-3 ${isDisabled ? "bg-muted/30 opacity-60" : ""}`}
>
<div className="space-y-0.5">
<FormLabel className={isDisabled ? "cursor-not-allowed" : ""}>{requestType.label}</FormLabel>
</div>
<div className="flex items-center gap-2">
{/* Settings icon for path override - only show when enabled */}
{allowedField.value && !isDisabled && !isPathOverrideDisabled && !disabled && (
<FormField
control={control}
name={`${pathOverridesPrefix}.${requestType.key}`}
render={({ field: pathField }) => (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Customize endpoint path"
>
<Settings2 className="h-4 w-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="space-y-2">
<h4 className="text-sm font-medium">Custom Path or URL</h4>
<p className="text-muted-foreground text-xs">
Override with a path (e.g., /v1/chat) or a full URL (e.g., https://api.example.com/chat) to bypass base_url
</p>
<Input placeholder={placeholder} {...pathField} value={pathField.value || ""} className="h-9" />
</div>
</PopoverContent>
</Popover>
)}
/>
)}
<FormControl>
{isDisabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch checked={isDisabled ? false : allowedField.value} disabled={true} size="md" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Not supported by {providerType}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Switch checked={allowedField.value} onCheckedChange={allowedField.onChange} size="md" disabled={disabled} />
)}
</FormControl>
</div>
</FormItem>
)}
/>
);
};
return (
<div className="space-y-4">
<div>
<div className="text-sm font-medium">Allowed Request Types</div>
<p className="text-muted-foreground text-xs">
Select which request types this custom provider can handle.{" "}
{!isPathOverrideDisabled ? "Click the settings icon to customize endpoint paths or use full URLs." : ""}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-3">{leftColumn.map(renderRequestField)}</div>
<div className="space-y-3">{rightColumn.map(renderRequestField)}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,967 @@
import { EnvVarInput } from "@/components/ui/envVarInput";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { HeadersTable, type CellRenderParams } from "@/components/ui/headersTable";
import { Input } from "@/components/ui/input";
import { ModelMultiselect } from "@/components/ui/modelMultiselect";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagInput } from "@/components/ui/tagInput";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { isRedacted } from "@/lib/utils/validation";
import { Info } from "lucide-react";
import { useEffect, useState } from "react";
import { Control, UseFormReturn } from "react-hook-form";
// Providers that support batch APIs
const BATCH_SUPPORTED_PROVIDERS = ["openai", "bedrock", "anthropic", "gemini", "azure"];
/** Normalize form value (object or legacy JSON string) for the alias map editor. */
function normalizeAliasesValue(v: Record<string, string> | string | undefined | null): Record<string, string> {
if (v == null) {
return {};
}
if (typeof v === "string") {
const t = v.trim();
if (!t) {
return {};
}
try {
const p = JSON.parse(t) as unknown;
if (typeof p === "object" && p !== null && !Array.isArray(p)) {
return Object.fromEntries(Object.entries(p as Record<string, unknown>).map(([k, val]) => [k, String(val ?? "")]));
}
} catch {
return {};
}
return {};
}
if (typeof v === "object" && !Array.isArray(v)) {
return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, typeof val === "string" ? val : String(val ?? "")]));
}
return {};
}
interface Props {
control: Control<any>;
providerName: string;
form: UseFormReturn<any>;
}
// Batch API form field for all providers
function BatchAPIFormField({ control }: { control: Control<any>; form: UseFormReturn<any> }) {
return (
<FormField
control={control}
name={`key.use_for_batch_api`}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-sm border p-2">
<div className="space-y-1.5">
<FormLabel>Use for Batch APIs</FormLabel>
<FormDescription>
Enable this key for batch API operations. Only keys with this enabled will be used for batch requests.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
);
}
export function ApiKeyFormFragment({ control, providerName, form }: Props) {
const isBedrock = providerName === "bedrock";
const isVertex = providerName === "vertex";
const isAzure = providerName === "azure";
const isReplicate = providerName === "replicate";
const isVLLM = providerName === "vllm";
const isOllama = providerName === "ollama";
const isSGL = providerName === "sgl";
const isKeylessProvider = isOllama || isSGL;
const supportsBatchAPI = BATCH_SUPPORTED_PROVIDERS.includes(providerName);
// Auth type state for Azure: 'api_key', 'entra_id', or 'default_credential'
const [azureAuthType, setAzureAuthType] = useState<"api_key" | "entra_id" | "default_credential">("api_key");
// Auth type state for Bedrock: 'iam_role', 'explicit', or 'api_key'
const [bedrockAuthType, setBedrockAuthType] = useState<"iam_role" | "explicit" | "api_key">("iam_role");
// Auth type state for Vertex: 'service_account', 'service_account_json', or 'api_key'
const [vertexAuthType, setVertexAuthType] = useState<"service_account" | "service_account_json" | "api_key">("service_account");
// Detect auth type from existing form values when editing
useEffect(() => {
if (form.formState.isDirty) return;
if (isAzure) {
const clientId = form.getValues("key.azure_key_config.client_id");
const clientSecret = form.getValues("key.azure_key_config.client_secret");
const tenantId = form.getValues("key.azure_key_config.tenant_id");
const apiKey = form.getValues("key.value");
const hasEntraField =
clientId?.value || clientId?.env_var || clientSecret?.value || clientSecret?.env_var || tenantId?.value || tenantId?.env_var;
const hasApiKey = apiKey?.value || apiKey?.env_var;
let detected: "api_key" | "entra_id" | "default_credential" = "api_key";
if (hasEntraField) {
detected = "entra_id";
} else if (!hasApiKey) {
detected = "default_credential";
}
setAzureAuthType(detected);
form.setValue("key.azure_key_config._auth_type", detected);
}
}, [isAzure, form]);
useEffect(() => {
if (form.formState.isDirty) return;
if (isVertex) {
const authCredentials = form.getValues("key.vertex_key_config.auth_credentials")?.value;
const authCredentialsEnv = form.getValues("key.vertex_key_config.auth_credentials")?.env_var;
const apiKey = form.getValues("key.value")?.value;
const apiKeyEnv = form.getValues("key.value")?.env_var;
let detected: "service_account" | "service_account_json" | "api_key" = "service_account";
if (authCredentials || authCredentialsEnv) {
detected = "service_account_json";
} else if (apiKey || apiKeyEnv) {
detected = "api_key";
}
setVertexAuthType(detected);
form.setValue("key.vertex_key_config._auth_type", detected);
}
}, [isVertex, form]);
useEffect(() => {
if (form.formState.isDirty) return;
if (isBedrock) {
const accessKey = form.getValues("key.bedrock_key_config.access_key");
const secretKey = form.getValues("key.bedrock_key_config.secret_key");
const apiKey = form.getValues("key.value");
const hasExplicitCreds = accessKey?.value || accessKey?.env_var || secretKey?.value || secretKey?.env_var;
const hasApiKey = apiKey?.value || apiKey?.env_var;
let detected: "iam_role" | "explicit" | "api_key" = "iam_role";
if (hasExplicitCreds) {
detected = "explicit";
} else if (hasApiKey) {
detected = "api_key";
}
setBedrockAuthType(detected);
form.setValue("key.bedrock_key_config._auth_type", detected);
}
}, [isBedrock, form]);
return (
<div data-tab="api-keys" className="space-y-4 overflow-hidden">
<div className="flex items-start gap-4">
<div className="flex-1">
<FormField
control={control}
name={`key.name`}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Production Key" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name={`key.weight`}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Weight</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Info className="text-muted-foreground h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Determines traffic distribution between keys. Higher weights receive more requests.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="1.0"
className="w-[260px]"
value={field.value === undefined || field.value === null ? "" : String(field.value)}
onChange={(e) => {
// Keep as string during typing to allow partial input
field.onChange(e.target.value === "" ? "" : e.target.value);
}}
onBlur={(e) => {
const v = e.target.value.trim();
if (v !== "") {
const num = parseFloat(v);
if (!isNaN(num)) {
field.onChange(num);
}
}
field.onBlur();
}}
name={field.name}
ref={field.ref}
type="text"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Hide API Key field for providers with dedicated auth tabs */}
{!isAzure && !isBedrock && !isVertex && (
<FormField
control={control}
name={`key.value`}
render={({ field }) => (
<FormItem>
<FormLabel>API Key {isVLLM ? "(Optional)" : ""}</FormLabel>
<FormControl>
<EnvVarInput placeholder="API Key or env.MY_KEY" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{!isVLLM && (
<>
<FormField
control={control}
name={`key.models`}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Allowed Models</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Info className="text-muted-foreground h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Select specific models this key applies to, or choose "Allow All Models" to allow all. Leave empty to deny all.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<ModelMultiselect
data-testid="api-keys-models-multiselect"
provider={providerName}
allowAllOption={true}
value={field.value || []}
onChange={(models: string[]) => {
const hadStar = (field.value || []).includes("*");
const hasStar = models.includes("*");
if (!hadStar && hasStar) {
field.onChange(["*"]);
} else if (hadStar && hasStar && models.length > 1) {
field.onChange(models.filter((m: string) => m !== "*"));
} else {
field.onChange(models);
}
}}
placeholder={
(field.value || []).includes("*")
? "All models allowed"
: (field.value || []).length === 0
? "No models (deny all)"
: "Search models..."
}
unfiltered={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.blacklisted_models`}
render={({ field }) => (
<FormItem data-testid="apikey-blacklisted-models-field">
<div className="flex items-center gap-2">
<FormLabel>Blocked Models</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Info className="text-muted-foreground h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-sm">
<p>
Models this key must never serve. The denylist always wins if a model appears in both Allowed Models and here,
it is blocked. Select "All Models" to block every model on this key.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<ModelMultiselect
data-testid="api-keys-blocked-models-multiselect"
provider={providerName}
allowAllOption={true}
value={field.value || []}
onChange={(models: string[]) => {
const hadStar = (field.value || []).includes("*");
const hasStar = models.includes("*");
if (!hadStar && hasStar) {
field.onChange(["*"]);
} else if (hadStar && hasStar && models.length > 1) {
field.onChange(models.filter((m: string) => m !== "*"));
} else {
field.onChange(models);
}
}}
placeholder={
(field.value || []).includes("*")
? "All models blocked"
: (field.value || []).length === 0
? "No models blocked"
: "Search models..."
}
unfiltered={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.aliases`}
render={({ field }) => (
<FormItem data-testid="apikey-aliases-field">
<FormLabel>Aliases (Optional)</FormLabel>
<FormDescription>
Map each request model name to the provider&apos;s identifier (deployment name, inference profile ID, fine-tuned endpoint
ID, etc.) or just a custom name, e.g. &quot;claude-sonnet-4-5&quot; -&gt; &quot;custom-claude-4.5-sonnet&quot;.
</FormDescription>
<FormControl>
<div data-testid="apikey-aliases-table">
<HeadersTable
label=""
value={normalizeAliasesValue(field.value)}
onChange={(next) => {
form.clearErrors("key.aliases");
field.onChange(Object.keys(next).length > 0 ? next : {});
}}
keyPlaceholder="Request model name"
valuePlaceholder="Deployment / profile / resource ID"
renderValueInput={({ value: cellValue, onChange, placeholder, disabled }: CellRenderParams) => (
<ModelMultiselect
isSingleSelect
provider={providerName}
value={cellValue}
onChange={onChange}
placeholder={placeholder ?? "Deployment / profile / resource ID"}
disabled={disabled}
unfiltered={true}
/>
)}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{supportsBatchAPI && !isBedrock && !isAzure && <BatchAPIFormField control={control} form={form} />}
{isAzure && (
<div className="space-y-4">
<Separator className="my-6" />
<div className="space-y-2">
<FormLabel>Authentication Method</FormLabel>
<Tabs
value={azureAuthType}
onValueChange={(v) => {
setAzureAuthType(v as "api_key" | "entra_id" | "default_credential");
form.setValue("key.azure_key_config._auth_type", v, { shouldDirty: true, shouldValidate: true });
if (v === "entra_id" || v === "default_credential") {
// Clear API key when switching away from API Key
form.setValue("key.value", undefined, { shouldDirty: true });
}
if (v === "api_key" || v === "default_credential") {
// Clear Entra ID fields when switching away from Entra ID
form.setValue("key.azure_key_config.client_id", undefined, { shouldDirty: true });
form.setValue("key.azure_key_config.client_secret", undefined, { shouldDirty: true });
form.setValue("key.azure_key_config.tenant_id", undefined, { shouldDirty: true });
form.setValue("key.azure_key_config.scopes", undefined, { shouldDirty: true });
}
}}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger data-testid="apikey-azure-default-credential-tab" value="default_credential">
Default Credential
</TabsTrigger>
<TabsTrigger data-testid="apikey-azure-api-key-tab" value="api_key">
API Key
</TabsTrigger>
<TabsTrigger data-testid="apikey-azure-entra-id-tab" value="entra_id">
Entra ID (Service Principal)
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{azureAuthType === "api_key" && (
<FormField
control={control}
name={`key.value`}
render={({ field }) => (
<FormItem>
<FormLabel>
API Key {isVertex ? "(Supported only for gemini and fine-tuned models)" : isVLLM ? "(Optional)" : ""}
</FormLabel>
<FormControl>
<EnvVarInput placeholder="API Key or env.MY_KEY" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{azureAuthType === "default_credential" && (
<p className="text-muted-foreground text-sm">
Uses DefaultAzureCredential automatically detects managed identity on Azure VMs and containers, workload identity in AKS,
environment variables, and Azure CLI. No credentials required.
</p>
)}
<FormField
control={control}
name={`key.azure_key_config.endpoint`}
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="https://your-resource.openai.azure.com or env.AZURE_ENDPOINT" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.azure_key_config.api_version`}
render={({ field }) => (
<FormItem>
<FormLabel>API Version (Optional)</FormLabel>
<FormControl>
<EnvVarInput placeholder="2024-02-01 or env.AZURE_API_VERSION" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{azureAuthType === "entra_id" && (
<>
<FormField
control={control}
name={`key.azure_key_config.client_id`}
render={({ field }) => (
<FormItem>
<FormLabel>Client ID (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-client-id or env.AZURE_CLIENT_ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.azure_key_config.client_secret`}
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-client-secret or env.AZURE_CLIENT_SECRET" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.azure_key_config.tenant_id`}
render={({ field }) => (
<FormItem>
<FormLabel>Tenant ID (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-tenant-id or env.AZURE_TENANT_ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.azure_key_config.scopes`}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Scopes (Optional)</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Info className="text-muted-foreground h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Optional OAuth scopes for token requests. By default we use https://cognitiveservices.azure.com/.default - add
additional scopes here if your setup requires extra permissions.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<TagInput
data-testid="apikey-azure-scopes-input"
placeholder="Add scope (Enter or comma)"
value={field.value ?? []}
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{supportsBatchAPI && <BatchAPIFormField control={control} form={form} />}
</div>
)}
{isVertex && (
<div className="space-y-4">
<Separator className="my-6" />
<div className="space-y-2">
<FormLabel>Authentication Method</FormLabel>
<Tabs
value={vertexAuthType}
onValueChange={(v) => {
setVertexAuthType(v as "service_account" | "service_account_json" | "api_key");
form.setValue("key.vertex_key_config._auth_type", v, { shouldDirty: true, shouldValidate: true });
if (v === "service_account" || v === "api_key") {
// Clear auth credentials when switching away from service account JSON
form.setValue("key.vertex_key_config.auth_credentials", undefined, { shouldDirty: true });
}
if (v === "service_account" || v === "service_account_json") {
// Clear API key when switching away from API Key
form.setValue("key.value", undefined, { shouldDirty: true });
}
}}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger data-testid="apikey-vertex-service-account-tab" value="service_account">
Service Account (Attached)
</TabsTrigger>
<TabsTrigger data-testid="apikey-vertex-service-account-json-tab" value="service_account_json">
Service Account (JSON)
</TabsTrigger>
<TabsTrigger data-testid="apikey-vertex-api-key-tab" value="api_key">
API Key
</TabsTrigger>
</TabsList>
</Tabs>
{vertexAuthType === "service_account" && (
<p className="text-muted-foreground text-sm">
Uses the service account attached to your environment (GCE, GKE, Cloud Run). No credentials required.
</p>
)}
</div>
<FormField
control={control}
name={`key.vertex_key_config.project_id`}
render={({ field }) => (
<FormItem>
<FormLabel>Project ID (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-gcp-project-id or env.VERTEX_PROJECT_ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.vertex_key_config.project_number`}
render={({ field }) => (
<FormItem>
<FormLabel>Project Number (Required only for fine-tuned models)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-gcp-project-number or env.VERTEX_PROJECT_NUMBER" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.vertex_key_config.region`}
render={({ field }) => (
<FormItem>
<FormLabel>Region (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="us-central1 or env.VERTEX_REGION" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{vertexAuthType === "service_account_json" && (
<FormField
control={control}
name={`key.vertex_key_config.auth_credentials`}
render={({ field }) => (
<FormItem>
<FormLabel>Auth Credentials (Required)</FormLabel>
<FormDescription>Service account JSON object or env.VAR_NAME</FormDescription>
<FormControl>
<EnvVarInput
data-testid="apikey-vertex-auth-credentials-input"
variant="textarea"
rows={4}
placeholder='{"type":"service_account","project_id":"your-gcp-project",...} or env.VERTEX_CREDENTIALS'
inputClassName="font-mono text-sm"
{...field}
/>
</FormControl>
{isRedacted(field.value?.value ?? "") && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<Info className="h-3 w-3" />
<span>Credentials are stored securely. Edit to update.</span>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
)}
{vertexAuthType === "api_key" && (
<FormField
control={control}
name={`key.value`}
render={({ field }) => (
<FormItem>
<FormLabel>API Key (Supported only for gemini and fine-tuned models)</FormLabel>
<FormControl>
<EnvVarInput data-testid="apikey-vertex-api-key-input" placeholder="API Key or env.MY_KEY" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
{isReplicate && (
<div className="space-y-4">
<Separator className="my-6" />
<FormField
control={control}
name="key.replicate_key_config.use_deployments_endpoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-sm border p-2">
<div className="space-y-1.5">
<FormLabel>Use Deployments Endpoint</FormLabel>
<FormDescription>
Route requests through the Replicate deployments endpoint instead of the models endpoint.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
)}
{isVLLM && (
<div className="space-y-4">
<Separator className="my-6" />
<FormField
control={control}
name="key.vllm_key_config.url"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Required)</FormLabel>
<FormDescription>Base URL of the vLLM server (e.g. http://vllm-server:8000 or env.VLLM_URL)</FormDescription>
<FormControl>
<EnvVarInput data-testid="key-input-vllm-url" placeholder="http://vllm-server:8000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="key.vllm_key_config.model_name"
render={({ field }) => (
<FormItem>
<FormLabel>Model Name (Required)</FormLabel>
<FormDescription>Exact model name served on this vLLM instance</FormDescription>
<FormControl>
<Input data-testid="key-input-vllm-model-name" placeholder="meta-llama/Llama-3-70b-hf" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{isKeylessProvider && (
<div className="space-y-4">
<FormField
control={control}
name={`key.${isOllama ? "ollama_key_config" : "sgl_key_config"}.url`}
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Required)</FormLabel>
<FormDescription>
Base URL of the {isOllama ? "Ollama" : "SGLang"} server (e.g.{" "}
{isOllama ? "http://localhost:11434" : "http://localhost:30000"} or {isOllama ? "env.OLLAMA_URL" : "env.SGL_URL"})
</FormDescription>
<FormControl>
<EnvVarInput
data-testid={`key-input-${isOllama ? "ollama" : "sgl"}-url`}
placeholder={isOllama ? "http://localhost:11434" : "http://localhost:30000"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{isBedrock && (
<div className="space-y-4">
<Separator className="my-6" />
<div className="space-y-2">
<FormLabel>Authentication Method</FormLabel>
<Tabs
value={bedrockAuthType}
onValueChange={(v) => {
setBedrockAuthType(v as "iam_role" | "explicit" | "api_key");
form.setValue("key.bedrock_key_config._auth_type", v, { shouldDirty: true, shouldValidate: true });
if (v === "iam_role") {
// Clear explicit credentials and API key when switching to IAM Role
form.setValue("key.bedrock_key_config.access_key", undefined, { shouldDirty: true });
form.setValue("key.bedrock_key_config.secret_key", undefined, { shouldDirty: true });
form.setValue("key.bedrock_key_config.session_token", undefined, { shouldDirty: true });
form.setValue("key.value", undefined, { shouldDirty: true });
} else if (v === "explicit") {
// Clear API key when switching to Explicit Credentials
form.setValue("key.value", undefined, { shouldDirty: true });
} else if (v === "api_key") {
// Clear AWS credentials and assume-role fields when switching to API Key
form.setValue("key.bedrock_key_config.access_key", undefined, { shouldDirty: true });
form.setValue("key.bedrock_key_config.secret_key", undefined, { shouldDirty: true });
form.setValue("key.bedrock_key_config.session_token", undefined, { shouldDirty: true });
form.setValue("key.bedrock_key_config.role_arn", undefined, { shouldDirty: true });
form.setValue("key.bedrock_key_config.external_id", undefined, { shouldDirty: true });
form.setValue("key.bedrock_key_config.session_name", undefined, { shouldDirty: true });
}
}}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger data-testid="apikey-bedrock-iam-role-tab" value="iam_role">
IAM Role (Inherited)
</TabsTrigger>
<TabsTrigger data-testid="apikey-bedrock-explicit-credentials-tab" value="explicit">
Explicit Credentials
</TabsTrigger>
<TabsTrigger data-testid="apikey-bedrock-api-key-tab" value="api_key">
API Key
</TabsTrigger>
</TabsList>
</Tabs>
{bedrockAuthType === "iam_role" && (
<p className="text-muted-foreground text-sm">Uses IAM roles attached to your environment (EC2, Lambda, ECS, EKS).</p>
)}
{bedrockAuthType === "api_key" && (
<p className="text-muted-foreground text-sm">Uses a Bearer token for API key authentication.</p>
)}
</div>
{bedrockAuthType === "explicit" && (
<>
<FormField
control={control}
name={`key.bedrock_key_config.access_key`}
render={({ field }) => (
<FormItem>
<FormLabel>Access Key (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-aws-access-key or env.AWS_ACCESS_KEY_ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.bedrock_key_config.secret_key`}
render={({ field }) => (
<FormItem>
<FormLabel>Secret Key (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-aws-secret-key or env.AWS_SECRET_ACCESS_KEY" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.bedrock_key_config.session_token`}
render={({ field }) => (
<FormItem>
<FormLabel>Session Token (Optional)</FormLabel>
<FormControl>
<EnvVarInput placeholder="your-aws-session-token or env.AWS_SESSION_TOKEN" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{bedrockAuthType === "api_key" && (
<FormField
control={control}
name={`key.value`}
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<EnvVarInput
data-testid="apikey-bedrock-api-key-input"
placeholder="API Key or env.BEDROCK_API_KEY"
type="text"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={control}
name={`key.bedrock_key_config.region`}
render={({ field }) => (
<FormItem>
<FormLabel>Region (Required)</FormLabel>
<FormControl>
<EnvVarInput placeholder="us-east-1 or env.AWS_REGION" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{bedrockAuthType !== "api_key" && (
<>
<FormField
control={control}
name={`key.bedrock_key_config.role_arn`}
render={({ field }) => (
<FormItem>
<FormLabel>Assume Role ARN (Optional)</FormLabel>
<FormDescription>
Assume an IAM role before requests. Works with both explicit credentials and inherited IAM (EC2, ECS, EKS).
</FormDescription>
<FormControl>
<EnvVarInput
data-testid="apikey-bedrock-role-arn-input"
placeholder="arn:aws:iam::123456789:role/MyRole or env.AWS_ROLE_ARN"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.bedrock_key_config.external_id`}
render={({ field }) => (
<FormItem>
<FormLabel>External ID (Optional)</FormLabel>
<FormDescription>Required by the role's trust policy when using cross-account access</FormDescription>
<FormControl>
<EnvVarInput
data-testid="apikey-bedrock-external-id-input"
placeholder="external-id or env.AWS_EXTERNAL_ID"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`key.bedrock_key_config.session_name`}
render={({ field }) => (
<FormItem>
<FormLabel>Session Name (Optional)</FormLabel>
<FormDescription>AssumeRole session name (defaults to bifrost-session)</FormDescription>
<FormControl>
<EnvVarInput
data-testid="apikey-bedrock-session-name-input"
placeholder="bifrost-session or env.AWS_SESSION_NAME"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={control}
name={`key.bedrock_key_config.arn`}
render={({ field }) => (
<FormItem>
<FormLabel>ARN (Optional)</FormLabel>
<FormControl>
<EnvVarInput placeholder="arn:aws:bedrock:us-east-1:123:inference-profile or env.AWS_ARN" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{supportsBatchAPI && <BatchAPIFormField control={control} form={form} />}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
import { BaseProvider, ModelProvider } from "@/lib/types/config";
import { formCustomProviderConfigSchema } from "@/lib/types/schemas";
import { cleanPathOverrides } from "@/lib/utils/validation";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { buildProviderUpdatePayload } from "../views/utils";
import { AllowedRequestsFields } from "./allowedRequestsFields";
// Type for form data
type FormCustomProviderConfig = z.infer<typeof formCustomProviderConfigSchema>;
// Standalone usage (for provider configuration tabs)
interface Props {
provider: ModelProvider;
}
// Standalone component for provider configuration tabs
export function ApiStructureFormFragment({ provider }: Props) {
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const dispatch = useAppDispatch();
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
const form = useForm<FormCustomProviderConfig>({
resolver: zodResolver(formCustomProviderConfigSchema),
mode: "onChange",
defaultValues: {
base_provider_type: provider.custom_provider_config?.base_provider_type ?? "openai",
is_key_less: provider.custom_provider_config?.is_key_less ?? false,
allowed_requests: {
text_completion: provider.custom_provider_config?.allowed_requests?.text_completion ?? true,
text_completion_stream: provider.custom_provider_config?.allowed_requests?.text_completion_stream ?? true,
chat_completion: provider.custom_provider_config?.allowed_requests?.chat_completion ?? true,
chat_completion_stream: provider.custom_provider_config?.allowed_requests?.chat_completion_stream ?? true,
responses: provider.custom_provider_config?.allowed_requests?.responses ?? true,
responses_stream: provider.custom_provider_config?.allowed_requests?.responses_stream ?? true,
embedding: provider.custom_provider_config?.allowed_requests?.embedding ?? true,
speech: provider.custom_provider_config?.allowed_requests?.speech ?? true,
speech_stream: provider.custom_provider_config?.allowed_requests?.speech_stream ?? true,
transcription: provider.custom_provider_config?.allowed_requests?.transcription ?? true,
transcription_stream: provider.custom_provider_config?.allowed_requests?.transcription_stream ?? true,
count_tokens: provider.custom_provider_config?.allowed_requests?.count_tokens ?? true,
list_models: provider.custom_provider_config?.allowed_requests?.list_models ?? true,
ocr: provider.custom_provider_config?.allowed_requests?.ocr ?? true,
ocr_stream: provider.custom_provider_config?.allowed_requests?.ocr_stream ?? true,
},
request_path_overrides: provider.custom_provider_config?.request_path_overrides ?? undefined,
},
});
useEffect(() => {
dispatch(setProviderFormDirtyState(form.formState.isDirty));
}, [form.formState.isDirty]);
useEffect(() => {
form.reset(provider.custom_provider_config);
}, [form, provider.name, provider.custom_provider_config]);
const onSubmit = (data: FormCustomProviderConfig) => {
// Create updated provider configuration
updateProvider(
buildProviderUpdatePayload(provider, {
custom_provider_config: {
base_provider_type: data.base_provider_type as unknown as BaseProvider,
is_key_less: data.is_key_less ?? false,
allowed_requests: data.allowed_requests,
request_path_overrides: cleanPathOverrides(data.request_path_overrides),
},
}),
)
.unwrap()
.then(() => {
toast.success("Provider configuration updated successfully");
form.reset(data);
})
.catch((err) => {
toast.error("Failed to update provider configuration", {
description: getErrorMessage(err),
});
});
};
const isKeyLessDisabled = useMemo(
() => provider.custom_provider_config?.base_provider_type === "bedrock",
[provider.custom_provider_config?.base_provider_type],
);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6">
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="base_provider_type"
render={({ field }) => (
<FormItem>
<FormLabel>Base Provider Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger disabled={true}>
<SelectValue placeholder="Select base provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="bedrock">AWS Bedrock</SelectItem>
<SelectItem value="cohere">Cohere</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="replicate">Replicate</SelectItem>
</SelectContent>
</Select>
<FormDescription>The underlying provider this custom provider will use</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isKeyLessDisabled && (
<FormField
control={form.control}
name="is_key_less"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-3">
<div className="space-y-0.5">
<label htmlFor="drop-excess-requests" className="text-sm font-medium">
Is Keyless?
</label>
<p className="text-muted-foreground text-sm">Whether the custom provider requires a key</p>
</div>
<Switch
id="drop-excess-requests"
size="md"
checked={field.value}
onCheckedChange={field.onChange}
disabled={!hasUpdateProviderAccess}
/>
</div>
</FormItem>
)}
/>
)}
</div>
{/* Allowed Requests Configuration */}
<AllowedRequestsFields
control={form.control}
providerType={form.watch("base_provider_type") as BaseProvider}
disabled={!hasUpdateProviderAccess}
/>
{/* Form Actions */}
<div className="flex justify-end space-x-2 py-2">
<Button type="button" variant="outline" onClick={() => form.reset()} disabled={!hasUpdateProviderAccess}>
Reset
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || !hasUpdateProviderAccess}
isLoading={isUpdatingProvider}
>
Save API Structure Configuration
</Button>
</TooltipTrigger>
{!form.formState.isValid && (
<TooltipContent>
<p>{form.formState.errors.root?.message || "Please fix validation errors"}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,418 @@
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
import { ModelProvider, NetworkConfig } from "@/lib/types/config";
import { buildProviderUpdatePayload } from "@/app/workspace/providers/views/utils";
import { betaHeadersFormSchema, type BetaHeadersFormSchema } from "@/lib/types/schemas";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { toast } from "sonner";
// Known beta headers with their prefixes, descriptions, and default support per provider.
// This mirrors the Go ProviderFeatures map in core/providers/anthropic/types.go.
const KNOWN_BETA_HEADERS = [
{
prefix: "computer-use-",
label: "Computer Use",
description: "Computer use client tool",
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
},
{
prefix: "structured-outputs-",
label: "Structured Outputs",
description: "Strict tool validation and output_format",
defaults: { anthropic: true, vertex: false, bedrock: true, azure: true },
},
{
prefix: "advanced-tool-use-",
label: "Advanced Tool Use",
description: "defer_loading, input_examples, allowed_callers",
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
},
{
prefix: "mcp-client-",
label: "MCP Client",
description: "MCP connector support",
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
},
{
prefix: "prompt-caching-scope-",
label: "Prompt Caching Scope",
description: "Prompt caching scope control",
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
},
{
prefix: "compact-",
label: "Compaction",
description: "Server-side context compaction",
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
},
{
prefix: "context-management-",
label: "Context Management",
description: "Context editing (clear_tool_uses, clear_thinking)",
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
},
{
prefix: "files-api-",
label: "Files API",
description: "Files API support",
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
},
{
prefix: "interleaved-thinking-",
label: "Interleaved Thinking",
description: "Interleaved thinking between tool calls",
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
},
{
prefix: "skills-",
label: "Skills",
description: "Agent Skills",
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
},
{
prefix: "context-1m-",
label: "Context 1M",
description: "1M context window (beta for Sonnet 4.5/4)",
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
},
{
prefix: "fast-mode-",
label: "Fast Mode",
description: "Fast mode (Opus 4.6 research preview)",
defaults: { anthropic: true, vertex: false, bedrock: false, azure: false },
},
{
prefix: "redact-thinking-",
label: "Redact Thinking",
description: "Redact thinking blocks in responses",
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
},
] as const;
const KNOWN_PREFIXES = new Set<string>(KNOWN_BETA_HEADERS.map((h) => h.prefix));
type ProviderKey = "anthropic" | "vertex" | "bedrock" | "azure";
const ANTHROPIC_FAMILY_PROVIDERS: ProviderKey[] = ["anthropic", "vertex", "bedrock", "azure"];
function getProviderKey(providerName: string): ProviderKey | null {
const name = providerName.toLowerCase();
if (ANTHROPIC_FAMILY_PROVIDERS.includes(name as ProviderKey)) {
return name as ProviderKey;
}
return null;
}
interface BetaHeadersFormFragmentProps {
provider: ModelProvider;
}
export function BetaHeadersFormFragment({ provider }: BetaHeadersFormFragmentProps) {
const dispatch = useAppDispatch();
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
const providerKey = getProviderKey(provider.name);
const [newPrefix, setNewPrefix] = useState("");
const [newPrefixError, setNewPrefixError] = useState<string | null>(null);
const form = useForm<BetaHeadersFormSchema, any, BetaHeadersFormSchema>({
resolver: zodResolver(betaHeadersFormSchema) as Resolver<BetaHeadersFormSchema, any, BetaHeadersFormSchema>,
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
beta_header_overrides: provider.network_config?.beta_header_overrides ?? {},
},
});
useEffect(() => {
form.reset({
beta_header_overrides: provider.network_config?.beta_header_overrides ?? {},
});
}, [form, provider.name, provider.network_config?.beta_header_overrides]);
const overrides = form.watch("beta_header_overrides") ?? {};
// Manual dirty tracking — RHF's deep equality on records is unreliable with setValue
const savedOverrides = provider.network_config?.beta_header_overrides ?? {};
const isManuallyDirty = useMemo(() => {
const currentKeys = Object.keys(overrides);
const savedKeys = Object.keys(savedOverrides);
if (currentKeys.length !== savedKeys.length) return true;
return currentKeys.some((key) => overrides[key] !== savedOverrides[key]);
}, [overrides, savedOverrides]);
useEffect(() => {
dispatch(setProviderFormDirtyState(isManuallyDirty));
}, [isManuallyDirty, dispatch]);
// Custom prefixes are overrides that don't match any known prefix
const customPrefixes = useMemo(() => {
return Object.keys(overrides).filter((prefix) => !KNOWN_PREFIXES.has(prefix));
}, [overrides]);
const headerRows = useMemo(() => {
if (!providerKey) return [];
return KNOWN_BETA_HEADERS.map((header) => {
const defaultSupported = header.defaults[providerKey];
const override = overrides[header.prefix];
return { ...header, defaultSupported, override };
});
}, [providerKey, overrides]);
const onSubmit = (data: BetaHeadersFormSchema) => {
const cleanedOverrides: Record<string, boolean> = {};
if (data.beta_header_overrides) {
for (const [prefix, value] of Object.entries(data.beta_header_overrides)) {
cleanedOverrides[prefix] = value;
}
}
updateProvider(
buildProviderUpdatePayload(provider, {
network_config: {
...(provider.network_config ?? ({} as NetworkConfig)),
beta_header_overrides: Object.keys(cleanedOverrides).length > 0 ? cleanedOverrides : undefined,
},
}),
)
.unwrap()
.then(() => {
toast.success("Beta header configuration updated successfully");
form.reset(data);
})
.catch((err) => {
toast.error("Failed to update beta header configuration", {
description: getErrorMessage(err),
});
});
};
const setOverride = useCallback(
(prefix: string, value: "default" | "enabled" | "disabled") => {
const current = form.getValues("beta_header_overrides") ?? {};
const updated = { ...current };
if (value === "default") {
delete updated[prefix];
} else {
updated[prefix] = value === "enabled";
}
form.setValue("beta_header_overrides", updated, { shouldDirty: true });
},
[form],
);
const removeCustomPrefix = useCallback(
(prefix: string) => {
const current = form.getValues("beta_header_overrides") ?? {};
const updated = { ...current };
delete updated[prefix];
form.setValue("beta_header_overrides", updated, { shouldDirty: true });
},
[form],
);
const addCustomPrefix = useCallback(() => {
let prefix = newPrefix.trim().toLowerCase();
if (!prefix) return;
// Ensure prefix ends with "-"
if (!prefix.endsWith("-")) {
prefix = prefix + "-";
}
// Validate
if (KNOWN_PREFIXES.has(prefix)) {
setNewPrefixError("This is a known header — use the override dropdown above instead");
return;
}
if (overrides[prefix] !== undefined) {
setNewPrefixError("This prefix already exists");
return;
}
if (!/^[a-z0-9-]+$/.test(prefix)) {
setNewPrefixError("Prefix must contain only lowercase letters, numbers, and hyphens");
return;
}
const current = form.getValues("beta_header_overrides") ?? {};
form.setValue("beta_header_overrides", { ...current, [prefix]: true }, { shouldDirty: true });
setNewPrefix("");
setNewPrefixError(null);
}, [newPrefix, overrides, form]);
const getSelectValue = (prefix: string): string => {
const override = overrides[prefix];
if (override === undefined) return "default";
return override ? "enabled" : "disabled";
};
if (!providerKey) return null;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6" data-testid="provider-config-beta-headers-content">
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Configure which Anthropic beta headers are allowed for this provider. Override the defaults when a provider adds or removes
support for a beta feature.
</p>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="px-3 py-2 text-left font-medium">Beta Header</th>
<th className="px-3 py-2 text-left font-medium">Default</th>
<th className="w-[180px] px-3 py-2 text-left font-medium">Override</th>
</tr>
</thead>
<tbody>
{headerRows.map((row) => (
<tr key={row.prefix} className="border-b last:border-b-0">
<td className="px-3 py-2">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs">{row.prefix}*</span>
<span className="text-muted-foreground text-xs">{row.description}</span>
</div>
</td>
<td className="px-3 py-2">
<Badge variant={row.defaultSupported ? "default" : "secondary"} className="text-xs">
{row.defaultSupported ? "Supported" : "Unsupported"}
</Badge>
</td>
<td className="w-[180px] px-3 py-2">
<Select
value={getSelectValue(row.prefix)}
onValueChange={(val) => setOverride(row.prefix, val as "default" | "enabled" | "disabled")}
disabled={!hasUpdateProviderAccess}
>
<SelectTrigger
className="h-8 text-xs"
data-testid={`provider-beta-override-select-${row.prefix.replace(/-/g, "")}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="enabled">Supported</SelectItem>
<SelectItem value="disabled">Unsupported</SelectItem>
</SelectContent>
</Select>
</td>
</tr>
))}
{customPrefixes.map((prefix) => (
<tr key={prefix} className="border-b last:border-b-0">
<td className="px-3 py-2">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs">{prefix}*</span>
<span className="text-muted-foreground text-xs">Custom header</span>
</div>
</td>
<td className="px-3 py-2">
<Badge variant="outline" className="text-xs">
Custom
</Badge>
</td>
<td className="w-[180px] px-3 py-2">
<div className="flex items-center gap-1">
<Select
value={overrides[prefix] ? "enabled" : "disabled"}
onValueChange={(val) => setOverride(prefix, val as "enabled" | "disabled")}
disabled={!hasUpdateProviderAccess}
>
<SelectTrigger
className="h-8 text-xs"
data-testid={`provider-beta-custom-override-select-${prefix.replace(/-/g, "")}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Supported</SelectItem>
<SelectItem value="disabled">Unsupported</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
disabled={!hasUpdateProviderAccess}
onClick={() => removeCustomPrefix(prefix)}
data-testid={`provider-beta-remove-prefix-btn-${prefix.replace(/-/g, "")}`}
aria-label={`Remove custom prefix ${prefix}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-start gap-2 pt-2">
<div className="flex-1">
<Input
placeholder="Add custom beta header prefix (e.g. new-feature-)"
value={newPrefix}
onChange={(e) => {
setNewPrefix(e.target.value);
setNewPrefixError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomPrefix();
}
}}
disabled={!hasUpdateProviderAccess}
className="h-8 text-xs"
data-testid="provider-beta-custom-prefix-input"
aria-label="Custom beta header prefix"
aria-describedby={newPrefixError ? "custom-prefix-error" : undefined}
/>
{newPrefixError && (
<p className="text-destructive mt-1 text-xs" id="custom-prefix-error">
{newPrefixError}
</p>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={!hasUpdateProviderAccess || !newPrefix.trim()}
onClick={addCustomPrefix}
data-testid="provider-beta-add-prefix-btn"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add
</Button>
</div>
</div>
<div className="flex justify-end space-x-2 pb-6">
<Button
type="submit"
disabled={!isManuallyDirty || !hasUpdateProviderAccess || isUpdatingProvider}
isLoading={isUpdatingProvider}
data-testid="provider-beta-save-btn"
>
Save Beta Header Configuration
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,214 @@
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
import { ModelProvider } from "@/lib/types/config";
import { debuggingFormSchema, type DebuggingFormSchema } from "@/lib/types/schemas";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import { useEffect } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { buildProviderUpdatePayload } from "../views/utils";
interface DebuggingFormFragmentProps {
provider: ModelProvider;
}
export function DebuggingFormFragment({ provider }: DebuggingFormFragmentProps) {
const dispatch = useAppDispatch();
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
const form = useForm<DebuggingFormSchema, any, DebuggingFormSchema>({
resolver: zodResolver(debuggingFormSchema) as Resolver<DebuggingFormSchema, any, DebuggingFormSchema>,
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
send_back_raw_request: provider.send_back_raw_request ?? false,
send_back_raw_response: provider.send_back_raw_response ?? false,
store_raw_request_response: provider.store_raw_request_response ?? false,
},
});
const sendBackRawRequest = form.watch("send_back_raw_request");
const sendBackRawResponse = form.watch("send_back_raw_response");
const storeRawRequestResponse = form.watch("store_raw_request_response");
useEffect(() => {
dispatch(setProviderFormDirtyState(form.formState.isDirty));
}, [form.formState.isDirty, dispatch]);
useEffect(() => {
form.reset({
send_back_raw_request: provider.send_back_raw_request ?? false,
send_back_raw_response: provider.send_back_raw_response ?? false,
store_raw_request_response: provider.store_raw_request_response ?? false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider.name, provider.send_back_raw_request, provider.send_back_raw_response, provider.store_raw_request_response]);
const onSubmit = (data: DebuggingFormSchema) => {
const updatedProvider = buildProviderUpdatePayload(provider, {
send_back_raw_request: data.send_back_raw_request,
send_back_raw_response: data.send_back_raw_response,
store_raw_request_response: data.store_raw_request_response,
});
updateProvider(updatedProvider)
.unwrap()
.then(() => {
toast.success("Debugging configuration updated successfully");
form.reset(data);
})
.catch((err) => {
toast.error("Failed to update debugging configuration", {
description: getErrorMessage(err),
});
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6" data-testid="provider-config-debugging-content">
<div className="space-y-4">
{/* Send Back Raw Request */}
<FormField
control={form.control}
name="send_back_raw_request"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<FormLabel>Send Back Raw Request</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild data-testid="provider-debugging-send-back-raw-request-tooltip-trigger">
<Info className="text-muted-foreground h-3 w-3 cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
Override per-request with header: <code>x-bf-send-back-raw-request: {String(!sendBackRawRequest)}</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-muted-foreground text-xs">
Include the raw provider request alongside the parsed request in the API response.
</p>
</div>
<FormControl>
<Switch
size="md"
checked={field.value}
disabled={!hasUpdateProviderAccess}
onCheckedChange={(checked) => {
field.onChange(checked);
form.trigger("send_back_raw_request");
}}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
{/* Send Back Raw Response */}
<FormField
control={form.control}
name="send_back_raw_response"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<FormLabel>Send Back Raw Response</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild data-testid="provider-debugging-send-back-raw-response-tooltip-trigger">
<Info className="text-muted-foreground h-3 w-3 cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
Override per-request with header: <code>x-bf-send-back-raw-response: {String(!sendBackRawResponse)}</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-muted-foreground text-xs">
Include the raw provider response alongside the parsed response in the API response.
</p>
</div>
<FormControl>
<Switch
size="md"
checked={field.value}
disabled={!hasUpdateProviderAccess}
onCheckedChange={(checked) => {
field.onChange(checked);
form.trigger("send_back_raw_response");
}}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
{/* Store Raw Request/Response */}
<FormField
control={form.control}
name="store_raw_request_response"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<FormLabel>Store Raw Request/Response</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild data-testid="provider-debugging-store-raw-request-response-tooltip-trigger">
<Info className="text-muted-foreground h-3 w-3 cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
Override per-request with header:{" "}
<code>x-bf-store-raw-request-response: {String(!storeRawRequestResponse)}</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-muted-foreground text-xs">Persist raw request and response payloads in log records.</p>
</div>
<FormControl>
<Switch
data-testid="provider-debugging-store-raw-request-response-switch"
size="md"
checked={field.value}
disabled={!hasUpdateProviderAccess}
onCheckedChange={(checked) => {
field.onChange(checked);
form.trigger("store_raw_request_response");
}}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end space-x-2 pb-6">
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || !hasUpdateProviderAccess || isUpdatingProvider}
isLoading={isUpdatingProvider}
>
Save Debugging Configuration
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,299 @@
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 { resetDurationOptions } from "@/lib/constants/governance";
import {
getErrorMessage,
useDeleteProviderGovernanceMutation,
useGetProviderGovernanceQuery,
useUpdateProviderGovernanceMutation,
} from "@/lib/store";
import { ModelProvider } from "@/lib/types/config";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } 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.number().nonnegative().optional(),
budgetResetDuration: z.string().optional(),
// Token limits
tokenMaxLimit: z.number().int().nonnegative().optional(),
tokenResetDuration: z.string().optional(),
// Request limits
requestMaxLimit: z.number().int().nonnegative().optional(),
requestResetDuration: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
const DEFAULT_GOVERNANCE_FORM_VALUES: FormData = {
budgetMaxLimit: undefined,
budgetResetDuration: "1M",
tokenMaxLimit: undefined,
tokenResetDuration: "1h",
requestMaxLimit: undefined,
requestResetDuration: "1h",
};
export function GovernanceFormFragment({ provider }: GovernanceFormFragmentProps) {
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const hasViewAccess = useRbac(RbacResource.Governance, RbacOperation.View);
const { data: providerGovernanceData } = useGetProviderGovernanceQuery(undefined, {
skip: !hasViewAccess,
pollingInterval: 5000,
});
const [updateProviderGovernance, { isLoading: isUpdating }] = useUpdateProviderGovernanceMutation();
const [deleteProviderGovernance, { isLoading: isDeleting }] = useDeleteProviderGovernanceMutation();
// 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<FormData>({
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 ?? undefined,
budgetResetDuration: providerGovernance.budget?.reset_duration || "1M",
tokenMaxLimit: providerGovernance.rate_limit?.token_max_limit ?? undefined,
tokenResetDuration: providerGovernance.rate_limit?.token_reset_duration || "1h",
requestMaxLimit: providerGovernance.rate_limit?.request_max_limit ?? undefined,
requestResetDuration: providerGovernance.rate_limit?.request_reset_duration || "1h",
});
}
}, [providerGovernance, form]);
// Reset form when provider changes
useEffect(() => {
// 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 ?? undefined,
budgetResetDuration: newProvGov?.budget?.reset_duration || "1M",
tokenMaxLimit: newProvGov?.rate_limit?.token_max_limit ?? undefined,
tokenResetDuration: newProvGov?.rate_limit?.token_reset_duration || "1h",
requestMaxLimit: newProvGov?.rate_limit?.request_max_limit ?? undefined,
requestResetDuration: newProvGov?.rate_limit?.request_reset_duration || "1h",
});
}, [provider.name, form]);
const onSubmit = async (data: FormData) => {
try {
// Determine if we need to send empty objects to signal removal
const hadBudget = !!providerGovernance?.budget;
const hasBudget = data.budgetMaxLimit !== undefined;
const hadRateLimit = !!providerGovernance?.rate_limit;
const hasRateLimit = data.tokenMaxLimit !== undefined || data.requestMaxLimit !== undefined;
let budgetPayload: { max_limit?: number; reset_duration?: string } | undefined;
if (hasBudget) {
budgetPayload = {
max_limit: data.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: data.tokenMaxLimit ?? null,
token_reset_duration: data.tokenMaxLimit !== undefined ? data.tokenResetDuration || "1h" : null,
request_max_limit: data.requestMaxLimit ?? null,
request_reset_duration: data.requestMaxLimit !== undefined ? data.requestResetDuration || "1h" : null,
};
} else if (hadRateLimit) {
rateLimitPayload = {};
}
await updateProviderGovernance({
provider: provider.name,
data: {
budget: budgetPayload,
rate_limit: rateLimitPayload,
},
}).unwrap();
toast.success("Governance configuration saved successfully");
// Reset form with the saved values to update the initial state for change detection
form.reset(data);
} 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");
form.reset(DEFAULT_GOVERNANCE_FORM_VALUES);
} catch (error) {
toast.error("Failed to remove governance", {
description: getErrorMessage(error),
});
}
};
// Always show the form
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6">
{/* Budget Configuration */}
<div className="space-y-4">
<Label className="text-sm font-medium">Budget Configuration</Label>
<FormField
control={form.control}
name="budgetMaxLimit"
render={({ field }) => (
<FormItem>
<NumberAndSelect
id="providerBudgetMaxLimit"
labelClassName="font-normal"
label="Maximum Spend (USD)"
value={field.value}
selectValue={form.watch("budgetResetDuration") || "1M"}
onChangeNumber={(value) => field.onChange(value)}
onChangeSelect={(value) => form.setValue("budgetResetDuration", value, { shouldDirty: true })}
options={resetDurationOptions}
/>
</FormItem>
)}
/>
</div>
<DottedSeparator />
{/* Rate Limiting Configuration */}
<div className="space-y-4">
<Label className="text-sm font-medium">Rate Limiting Configuration</Label>
<FormField
control={form.control}
name="tokenMaxLimit"
render={({ field }) => (
<FormItem>
<NumberAndSelect
id="providerTokenMaxLimit"
labelClassName="font-normal"
label="Maximum Tokens"
value={field.value}
selectValue={form.watch("tokenResetDuration") || "1h"}
onChangeNumber={(value) => field.onChange(value)}
onChangeSelect={(value) => form.setValue("tokenResetDuration", value, { shouldDirty: true })}
options={resetDurationOptions}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="requestMaxLimit"
render={({ field }) => (
<FormItem>
<NumberAndSelect
id="providerRequestMaxLimit"
labelClassName="font-normal"
label="Maximum Requests"
value={field.value}
selectValue={form.watch("requestResetDuration") || "1h"}
onChangeNumber={(value) => field.onChange(value)}
onChangeSelect={(value) => form.setValue("requestResetDuration", value, { shouldDirty: true })}
options={resetDurationOptions}
/>
</FormItem>
)}
/>
</div>
{/* Current Usage Display - only when editing existing */}
{hasExistingGovernance && (providerGovernance?.budget || providerGovernance?.rate_limit) && (
<>
<DottedSeparator />
<div className="space-y-4">
<Label className="text-sm font-medium">Current Usage</Label>
<div className="bg-muted/50 grid grid-cols-2 gap-4 rounded-lg p-4">
{providerGovernance?.budget && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Budget Usage</p>
<p className="text-sm font-medium">
${providerGovernance.budget.current_usage.toFixed(2)} / ${providerGovernance.budget.max_limit.toFixed(2)}
</p>
</div>
)}
{providerGovernance?.rate_limit?.token_max_limit && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Token Usage</p>
<p className="text-sm font-medium">
{providerGovernance.rate_limit.token_current_usage.toLocaleString()} /{" "}
{providerGovernance.rate_limit.token_max_limit.toLocaleString()}
</p>
</div>
)}
{providerGovernance?.rate_limit?.request_max_limit && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">Request Usage</p>
<p className="text-sm font-medium">
{providerGovernance.rate_limit.request_current_usage.toLocaleString()} /{" "}
{providerGovernance.rate_limit.request_max_limit.toLocaleString()}
</p>
</div>
)}
</div>
</div>
</>
)}
{/* Form Actions */}
<div className="flex justify-end space-x-2 pb-6">
<Button
type="button"
variant="outline"
onClick={handleDelete}
disabled={!hasUpdateProviderAccess || isDeleting || !hasExistingGovernance}
>
Remove configuration
</Button>
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || !hasUpdateProviderAccess || isUpdating}
isLoading={isUpdating}
>
Save Governance Configuration
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,11 @@
export { AllowedRequestsFields } from "./allowedRequestsFields";
export { BetaHeadersFormFragment } from "./betaHeadersFormFragment";
export { ApiKeyFormFragment } from "./apiKeysFormFragment";
export { ApiStructureFormFragment } from "./apiStructureFormFragment";
export { DebuggingFormFragment } from "./debuggingFormFragment";
export { GovernanceFormFragment } from "./governanceFormFragment";
export { OpenAIConfigFormFragment } from "./openaiConfigFormFragment";
export { NetworkFormFragment } from "./networkFormFragment";
export { PerformanceFormFragment } from "./performanceFormFragment";
export { PerformanceFormFragment as PerformanceTab } from "./performanceFormFragment";
export { ProxyFormFragment } from "./proxyFormFragment";

View File

@@ -0,0 +1,522 @@
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { HeadersTable } from "@/components/ui/headersTable";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { DefaultNetworkConfig } from "@/lib/constants/config";
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
import { ModelProvider, isKnownProvider } from "@/lib/types/config";
import { networkOnlyFormSchema, type NetworkOnlyFormSchema } from "@/lib/types/schemas";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { buildProviderUpdatePayload } from "../views/utils";
interface NetworkFormFragmentProps {
provider: ModelProvider;
}
// seconds to human readable time
const secondsToHumanReadable = (seconds: number) => {
// Handle edge cases
if (!seconds || seconds < 0 || isNaN(seconds)) {
return "0 seconds";
}
seconds = Math.floor(seconds);
if (seconds < 60) {
return `${seconds} ${seconds === 1 ? "second" : "seconds"}`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
}
// For >= 1 day, only show non-zero components
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
const parts: string[] = [];
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
if (hours > 0) parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
if (minutes > 0) parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
if (remainingSeconds > 0) parts.push(`${remainingSeconds} ${remainingSeconds === 1 ? "second" : "seconds"}`);
return parts.join(" ");
};
export function NetworkFormFragment({ provider }: NetworkFormFragmentProps) {
const dispatch = useAppDispatch();
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
const isCustomProvider = !isKnownProvider(provider.name as string);
const form = useForm<NetworkOnlyFormSchema, any, NetworkOnlyFormSchema>({
resolver: zodResolver(networkOnlyFormSchema) as Resolver<NetworkOnlyFormSchema, any, NetworkOnlyFormSchema>,
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
network_config: {
base_url: provider.network_config?.base_url || undefined,
extra_headers: provider.network_config?.extra_headers,
default_request_timeout_in_seconds:
provider.network_config?.default_request_timeout_in_seconds ?? DefaultNetworkConfig.default_request_timeout_in_seconds,
max_retries: provider.network_config?.max_retries ?? DefaultNetworkConfig.max_retries,
retry_backoff_initial: provider.network_config?.retry_backoff_initial ?? DefaultNetworkConfig.retry_backoff_initial,
retry_backoff_max: provider.network_config?.retry_backoff_max ?? DefaultNetworkConfig.retry_backoff_max,
insecure_skip_verify: provider.network_config?.insecure_skip_verify ?? DefaultNetworkConfig.insecure_skip_verify,
ca_cert_pem: provider.network_config?.ca_cert_pem ?? DefaultNetworkConfig.ca_cert_pem,
stream_idle_timeout_in_seconds:
provider.network_config?.stream_idle_timeout_in_seconds ?? DefaultNetworkConfig.stream_idle_timeout_in_seconds,
max_conns_per_host: provider.network_config?.max_conns_per_host ?? DefaultNetworkConfig.max_conns_per_host,
enforce_http2: provider.network_config?.enforce_http2 ?? DefaultNetworkConfig.enforce_http2,
},
},
});
useEffect(() => {
dispatch(setProviderFormDirtyState(form.formState.isDirty));
}, [form.formState.isDirty, dispatch]);
const onSubmit = (data: NetworkOnlyFormSchema) => {
const requiresBaseUrl = isCustomProvider;
if (requiresBaseUrl && (data.network_config?.base_url ?? "").trim() === "") {
if ((provider.network_config?.base_url ?? "").trim() !== "") {
toast.error("You can't remove network configuration for this provider.");
} else {
toast.error("Base URL is required for this provider.");
}
return;
}
// Create updated provider configuration
const updatedProvider = buildProviderUpdatePayload(provider, {
network_config: {
...provider.network_config,
base_url: data.network_config?.base_url || undefined,
extra_headers: data.network_config?.extra_headers || undefined,
default_request_timeout_in_seconds: data.network_config?.default_request_timeout_in_seconds ?? 30,
max_retries: data.network_config?.max_retries ?? 0,
retry_backoff_initial: data.network_config?.retry_backoff_initial ?? 500,
retry_backoff_max: data.network_config?.retry_backoff_max ?? 10000,
insecure_skip_verify: data.network_config?.insecure_skip_verify ?? false,
ca_cert_pem: data.network_config?.ca_cert_pem?.trim() || undefined,
stream_idle_timeout_in_seconds:
data.network_config?.stream_idle_timeout_in_seconds ?? DefaultNetworkConfig.stream_idle_timeout_in_seconds,
max_conns_per_host: data.network_config?.max_conns_per_host ?? DefaultNetworkConfig.max_conns_per_host,
enforce_http2: data.network_config?.enforce_http2 ?? DefaultNetworkConfig.enforce_http2,
},
});
updateProvider(updatedProvider)
.unwrap()
.then(() => {
toast.success("Provider configuration updated successfully");
form.reset(data);
})
.catch((err) => {
toast.error("Failed to update provider configuration", {
description: getErrorMessage(err),
});
});
};
useEffect(() => {
// Reset form with new provider's network_config when provider.name changes
form.reset({
network_config: {
base_url: provider.network_config?.base_url || undefined,
extra_headers: provider.network_config?.extra_headers,
default_request_timeout_in_seconds:
provider.network_config?.default_request_timeout_in_seconds ?? DefaultNetworkConfig.default_request_timeout_in_seconds,
max_retries: provider.network_config?.max_retries ?? DefaultNetworkConfig.max_retries,
retry_backoff_initial: provider.network_config?.retry_backoff_initial ?? DefaultNetworkConfig.retry_backoff_initial,
retry_backoff_max: provider.network_config?.retry_backoff_max ?? DefaultNetworkConfig.retry_backoff_max,
insecure_skip_verify: provider.network_config?.insecure_skip_verify ?? DefaultNetworkConfig.insecure_skip_verify,
ca_cert_pem: provider.network_config?.ca_cert_pem ?? DefaultNetworkConfig.ca_cert_pem,
stream_idle_timeout_in_seconds:
provider.network_config?.stream_idle_timeout_in_seconds ?? DefaultNetworkConfig.stream_idle_timeout_in_seconds,
max_conns_per_host: provider.network_config?.max_conns_per_host ?? DefaultNetworkConfig.max_conns_per_host,
},
});
}, [form, provider.name, provider.network_config]);
const baseURLRequired = isCustomProvider;
const hideBaseURL = provider.name === "vllm" || provider.name === "ollama" || provider.name === "sgl";
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6">
{/* Network Configuration */}
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{!hideBaseURL && (
<FormField
control={form.control}
name="network_config.base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Base URL {baseURLRequired ? "(Required)" : "(Optional)"}</FormLabel>
<FormControl>
<Input
placeholder={isCustomProvider ? "https://api.your-provider.com" : "https://api.example.com"}
{...field}
value={field.value || ""}
disabled={!hasUpdateProviderAccess}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex w-full flex-row items-start gap-4">
<FormField
control={form.control}
name="network_config.default_request_timeout_in_seconds"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Timeout (seconds)</FormLabel>
<FormControl>
<Input
placeholder="30"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("network_config");
}}
/>
</FormControl>
<FormDescription>{secondsToHumanReadable(field.value)}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="network_config.stream_idle_timeout_in_seconds"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Stream Idle Timeout (seconds)</FormLabel>
<FormControl>
<Input
placeholder="60"
data-testid="network-config-stream-idle-timeout-input"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("network_config");
}}
/>
</FormControl>
<FormDescription>
{field.value ? secondsToHumanReadable(field.value) : ""} Max time to wait for next chunk before closing a stalled
stream
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="network_config.max_retries"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Max Retries</FormLabel>
<FormControl>
<Input
placeholder="0"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("network_config");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full flex-row items-start gap-4">
<FormField
control={form.control}
name="network_config.retry_backoff_initial"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Initial Backoff (ms)</FormLabel>
<FormControl>
<Input
placeholder="e.g 500"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("network_config");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="network_config.retry_backoff_max"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Max Backoff (ms)</FormLabel>
<FormControl>
<Input
placeholder="e.g 10000"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("network_config");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full flex-row items-start gap-4">
<FormField
control={form.control}
name="network_config.max_conns_per_host"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Max Connections Per Host</FormLabel>
<FormControl>
<Input
data-testid="network-config-max-conns-per-host-input"
placeholder="5000"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("network_config");
}}
/>
</FormControl>
<FormDescription>
Max TCP connections per provider host. For HTTP/2 providers (e.g. Bedrock), each connection supports ~100 concurrent
streams.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="network_config.enforce_http2"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Enforce HTTP/2</FormLabel>
<FormDescription>
Force HTTP/2 on provider connections. Relevant for net/http-based providers (e.g. Bedrock) where each HTTP/2
connection supports ~100 concurrent streams.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
disabled={!hasUpdateProviderAccess}
data-testid="network-config-enforce-http2"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="network_config.extra_headers"
render={({ field }) => (
<FormItem>
<FormControl>
<HeadersTable
value={field.value || {}}
onChange={field.onChange}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
label="Extra Headers"
disabled={!hasUpdateProviderAccess}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4 rounded-lg border p-4">
<h4 className="text-sm font-medium">TLS / Certificate</h4>
<FormField
control={form.control}
name="network_config.insecure_skip_verify"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Skip TLS verification</FormLabel>
<FormDescription>
Disable TLS certificate verification for provider connections. This bypasses server certificate validation and
should be used only as a last resort when a trusted CA chain cannot be configured. Prefer ca_cert_pem for
self-signed or private CA deployments.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? false}
onCheckedChange={field.onChange}
disabled={!hasUpdateProviderAccess}
data-testid="network-config-insecure-skip-verify"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="network_config.ca_cert_pem"
render={({ field }) => (
<FormItem>
<FormLabel>CA Certificate (PEM) (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
className="font-mono text-xs"
rows={6}
{...field}
value={field.value || ""}
disabled={!hasUpdateProviderAccess}
data-testid="network-config-ca-cert-pem"
/>
</FormControl>
<FormDescription>
PEM-encoded CA certificate to trust for provider endpoint connections (e.g. self-signed or internal CA)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-2 py-2">
{!hideBaseURL && (
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
network_config: undefined,
});
onSubmit(form.getValues());
}}
disabled={
!hasUpdateProviderAccess ||
isUpdatingProvider ||
!provider.network_config ||
!provider.network_config.base_url ||
provider.network_config.base_url.trim() === ""
}
>
Remove configuration
</Button>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || !hasUpdateProviderAccess}
isLoading={isUpdatingProvider}
>
Save Network Configuration
</Button>
</TooltipTrigger>
{(!form.formState.isDirty || !form.formState.isValid) && (
<TooltipContent>
<p>
{!form.formState.isDirty && !form.formState.isValid
? "No changes made and validation errors present"
: !form.formState.isDirty
? "No changes made"
: "Please fix validation errors"}
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,111 @@
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
import type { ModelProvider } from "@/lib/types/config";
import { openaiConfigFormSchema, type OpenAIConfigFormSchema } from "@/lib/types/schemas";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { buildProviderUpdatePayload } from "../views/utils";
interface OpenAIConfigFormFragmentProps {
provider: ModelProvider;
}
export function OpenAIConfigFormFragment({ provider }: OpenAIConfigFormFragmentProps) {
const dispatch = useAppDispatch();
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
const form = useForm<OpenAIConfigFormSchema, any, OpenAIConfigFormSchema>({
resolver: zodResolver(openaiConfigFormSchema) as Resolver<OpenAIConfigFormSchema, any, OpenAIConfigFormSchema>,
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
disable_store: provider.openai_config?.disable_store ?? false,
},
});
useEffect(() => {
dispatch(setProviderFormDirtyState(form.formState.isDirty));
}, [form.formState.isDirty, dispatch]);
useEffect(() => {
form.reset({
disable_store: provider.openai_config?.disable_store ?? false,
});
}, [form, provider.name, provider.openai_config?.disable_store]);
const onSubmit = (data: OpenAIConfigFormSchema) => {
updateProvider(
buildProviderUpdatePayload(provider, {
openai_config: {
disable_store: data.disable_store,
},
}),
)
.unwrap()
.then(() => {
toast.success("OpenAI configuration updated successfully");
form.reset(data);
})
.catch((err) => {
toast.error("Failed to update OpenAI configuration", {
description: getErrorMessage(err),
});
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6" data-testid="provider-config-openai-content">
<div className="space-y-4">
<FormField
control={form.control}
name="disable_store"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<FormLabel>Disable Store</FormLabel>
<p className="text-muted-foreground text-xs">
With the Responses API, store defaults to true, and when it is on, the generated response is stored for later
retrieval via API. OpenAI exposes endpoints to retrieve and delete stored responses, so your response IDs become
durable server-side objects instead of one-shot IDs.
</p>
</div>
<FormControl>
<Switch
data-testid="provider-openai-disable-store-switch"
size="md"
checked={field.value}
disabled={!hasUpdateProviderAccess}
onCheckedChange={(checked) => {
field.onChange(checked);
form.trigger("disable_store");
}}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end space-x-2 pb-6">
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || !hasUpdateProviderAccess || isUpdatingProvider}
isLoading={isUpdatingProvider}
>
Save OpenAI Configuration
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,159 @@
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { DefaultPerformanceConfig } from "@/lib/constants/config";
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
import { ModelProvider } from "@/lib/types/config";
import { performanceFormSchema, type PerformanceFormSchema } from "@/lib/types/schemas";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { buildProviderUpdatePayload } from "../views/utils";
interface PerformanceFormFragmentProps {
provider: ModelProvider;
}
export function PerformanceFormFragment({ provider }: PerformanceFormFragmentProps) {
const dispatch = useAppDispatch();
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
const form = useForm<PerformanceFormSchema, any, PerformanceFormSchema>({
resolver: zodResolver(performanceFormSchema) as Resolver<PerformanceFormSchema, any, PerformanceFormSchema>,
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
concurrency_and_buffer_size: {
concurrency: provider.concurrency_and_buffer_size?.concurrency ?? DefaultPerformanceConfig.concurrency,
buffer_size: provider.concurrency_and_buffer_size?.buffer_size ?? DefaultPerformanceConfig.buffer_size,
},
},
});
useEffect(() => {
dispatch(setProviderFormDirtyState(form.formState.isDirty));
}, [form.formState.isDirty]);
useEffect(() => {
// Reset form with new provider's concurrency_and_buffer_size when provider changes
form.reset({
concurrency_and_buffer_size: {
concurrency: provider.concurrency_and_buffer_size?.concurrency ?? DefaultPerformanceConfig.concurrency,
buffer_size: provider.concurrency_and_buffer_size?.buffer_size ?? DefaultPerformanceConfig.buffer_size,
},
});
}, [form, provider.name, provider.concurrency_and_buffer_size]);
const onSubmit = (data: PerformanceFormSchema) => {
// Create updated provider configuration (raw request/response are in Debugging tab)
const updatedProvider = buildProviderUpdatePayload(provider, {
concurrency_and_buffer_size: {
concurrency: data.concurrency_and_buffer_size.concurrency,
buffer_size: data.concurrency_and_buffer_size.buffer_size,
},
});
updateProvider(updatedProvider)
.unwrap()
.then(() => {
toast.success("Provider configuration updated successfully");
form.reset(data);
})
.catch((err) => {
toast.error("Failed to update provider configuration", {
description: getErrorMessage(err),
});
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6">
{/* Performance Configuration */}
<div className="space-y-4">
<div className="flex flex-row gap-4">
<div className="flex-1">
<FormField
control={form.control}
name="concurrency_and_buffer_size.concurrency"
render={({ field }) => (
<FormItem>
<FormLabel>Concurrency</FormLabel>
<FormControl>
<Input
type="number"
placeholder="10"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number.parseInt(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("concurrency_and_buffer_size");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex-1">
<FormField
control={form.control}
name="concurrency_and_buffer_size.buffer_size"
render={({ field }) => (
<FormItem>
<FormLabel>Buffer Size</FormLabel>
<FormControl>
<Input
type="number"
placeholder="10"
{...field}
value={field.value === undefined || Number.isNaN(field.value) ? "" : field.value}
disabled={!hasUpdateProviderAccess}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
return;
}
const parsed = Number.parseInt(value);
if (!Number.isNaN(parsed)) {
field.onChange(parsed);
}
form.trigger("concurrency_and_buffer_size");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-2 pb-6">
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || !hasUpdateProviderAccess || isUpdatingProvider}
isLoading={isUpdatingProvider}
>
Save Performance Configuration
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,226 @@
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
import { ModelProvider } from "@/lib/types/config";
import { proxyOnlyFormSchema, type ProxyOnlyFormSchema } from "@/lib/types/schemas";
import { cn } from "@/lib/utils";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { buildProviderUpdatePayload } from "../views/utils";
interface ProxyFormFragmentProps {
provider: ModelProvider;
}
export function ProxyFormFragment({ provider }: ProxyFormFragmentProps) {
const dispatch = useAppDispatch();
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
const form = useForm<ProxyOnlyFormSchema>({
resolver: zodResolver(proxyOnlyFormSchema),
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
proxy_config: {
type: provider.proxy_config?.type,
url: provider.proxy_config?.url || "",
username: provider.proxy_config?.username || "",
password: provider.proxy_config?.password || "",
ca_cert_pem: provider.proxy_config?.ca_cert_pem || "",
},
},
});
useEffect(() => {
dispatch(setProviderFormDirtyState(form.formState.isDirty));
}, [form.formState.isDirty]);
useEffect(() => {
form.reset({
proxy_config: {
type: provider.proxy_config?.type,
url: provider.proxy_config?.url || "",
username: provider.proxy_config?.username || "",
password: provider.proxy_config?.password || "",
ca_cert_pem: provider.proxy_config?.ca_cert_pem || "",
},
});
}, [form, provider.name, provider.proxy_config]);
const watchedProxyType = form.watch("proxy_config.type");
const onSubmit = (data: ProxyOnlyFormSchema) => {
updateProvider(
buildProviderUpdatePayload(provider, {
proxy_config: {
type: data.proxy_config?.type ?? "none",
url: data.proxy_config?.url || undefined,
username: data.proxy_config?.username || undefined,
password: data.proxy_config?.password || undefined,
ca_cert_pem: data.proxy_config?.ca_cert_pem || undefined,
},
}),
)
.unwrap()
.then(() => {
toast.success("Provider configuration updated successfully");
form.reset(data);
})
.catch((err) => {
toast.error("Failed to update provider configuration", {
description: getErrorMessage(err),
});
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6">
{/* Proxy Configuration */}
<div className="space-y-4">
<div className="space-y-4">
<FormField
control={form.control}
name="proxy_config.type"
render={({ field }) => (
<FormItem>
<FormLabel>Proxy Type</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value === "none" ? "" : field.value}
disabled={!hasUpdateProviderAccess}
>
<FormControl>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="environment">Environment</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div
className={cn(
"block transition-all duration-200",
(!watchedProxyType || watchedProxyType === "none" || watchedProxyType === "environment") && "hidden",
)}
>
<div className="space-y-4 pt-2">
<FormField
control={form.control}
name="proxy_config.url"
render={({ field }) => (
<FormItem>
<FormLabel>Proxy URL</FormLabel>
<FormControl>
<Input
placeholder="http://proxy.example.com"
{...field}
value={field.value || ""}
disabled={!hasUpdateProviderAccess}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="proxy_config.username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Proxy username" {...field} value={field.value || ""} disabled={!hasUpdateProviderAccess} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxy_config.password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Proxy password"
{...field}
value={field.value || ""}
disabled={!hasUpdateProviderAccess}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="proxy_config.ca_cert_pem"
render={({ field }) => (
<FormItem>
<FormLabel>CA Certificate (PEM) (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
className="font-mono text-xs"
rows={6}
{...field}
value={field.value || ""}
disabled={!hasUpdateProviderAccess}
/>
</FormControl>
<FormDescription>
PEM-encoded CA certificate to trust for TLS connections through SSL-intercepting proxies
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-2 pb-6">
<Button
type="button"
variant="outline"
onClick={() => {
onSubmit({ proxy_config: { type: "none", url: "" } });
}}
disabled={!hasUpdateProviderAccess || isUpdatingProvider || !provider.proxy_config || provider.proxy_config.type === "none"}
>
Remove configuration
</Button>
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || !hasUpdateProviderAccess || isUpdatingProvider}
isLoading={isUpdatingProvider}
>
Save Proxy Configuration
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,17 @@
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
import { NoPermissionView } from "@/components/noPermissionView";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import Providers from "./page";
function RouteComponent() {
const hasProvidersAccess = useRbac(RbacResource.ModelProvider, RbacOperation.View);
const childMatches = useChildMatches();
if (!hasProvidersAccess) {
return <NoPermissionView entity="model providers" />;
}
return childMatches.length === 0 ? <Providers /> : <Outlet />;
}
export const Route = createFileRoute("/workspace/providers")({
component: RouteComponent,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ProvidersModelLimitsPage from "./page";
export const Route = createFileRoute("/workspace/providers/model-limits")({
component: ProvidersModelLimitsPage,
});

View File

@@ -0,0 +1,5 @@
import ModelLimitsView from "@/app/workspace/model-limits/views/modelLimitsView";
export default function ProvidersModelLimitsPage() {
return <ModelLimitsView />;
}

View File

@@ -0,0 +1,337 @@
import ModelProviderConfig from "@/app/workspace/providers/views/modelProviderConfig";
import FullPageLoader from "@/components/fullPageLoader";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { DefaultNetworkConfig, DefaultPerformanceConfig } from "@/lib/constants/config";
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import { ProviderLabels, ProviderNames } from "@/lib/constants/logs";
import {
getErrorMessage,
setSelectedProvider,
useAppDispatch,
useAppSelector,
useCreateProviderMutation,
useGetProvidersQuery,
useLazyGetProviderQuery,
} from "@/lib/store";
import { KnownProvider, ModelProviderName, ProviderStatus } from "@/lib/types/config";
import { cn } from "@/lib/utils";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { AlertCircle } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useQueryState } from "nuqs";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import AddCustomProviderSheet from "./dialogs/addNewCustomProviderSheet";
import ConfirmDeleteProviderDialog from "./dialogs/confirmDeleteProviderDialog";
import ConfirmRedirectionDialog from "./dialogs/confirmRedirection";
import { AddProviderDropdown } from "./views/addProviderDropdown";
import { ProvidersEmptyState } from "./views/providersEmptyState";
export default function Providers() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const hasProvidersAccess = useRbac(RbacResource.ModelProvider, RbacOperation.View);
const hasSettingsOnly = useRbac(RbacResource.Settings, RbacOperation.View);
const hasProviderCreateAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Create);
// Redirect Settings-only users to Custom pricing tab
useEffect(() => {
if (!hasProvidersAccess && hasSettingsOnly) {
navigate({ to: "/workspace/custom-pricing", replace: true });
}
}, [hasProvidersAccess, hasSettingsOnly, navigate]);
const selectedProvider = useAppSelector((state) => state.provider.selectedProvider);
const providerFormIsDirty = useAppSelector((state) => state.provider.isDirty);
const [showRedirectionDialog, setShowRedirectionDialog] = useState(false);
const [showDeleteProviderDialog, setShowDeleteProviderDialog] = useState(false);
const [pendingRedirection, setPendingRedirection] = useState<string | undefined>(undefined);
const [showCustomProviderSheet, setShowCustomProviderSheet] = useState(false);
const [provider, setProvider] = useQueryState("provider");
const { data: savedProviders, isLoading: isLoadingProviders } = useGetProvidersQuery();
const [getProvider, { isLoading: isLoadingProvider }] = useLazyGetProviderQuery();
const [createProvider] = useCreateProviderMutation();
const configuredProviders = (savedProviders ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
const configuredProviderNamesArr = configuredProviders.map((p) => p.name);
const configuredProviderNamesKey = JSON.stringify(configuredProviderNamesArr);
const existingInSidebarNames = new Set(configuredProviders.map((p) => p.name));
const knownProviders = ProviderNames.map((name) => ({ name }));
useEffect(() => {
if (!provider) return;
const newSelectedProvider = configuredProviders.find((p) => p.name === provider);
if (newSelectedProvider) {
dispatch(setSelectedProvider(newSelectedProvider));
}
getProvider(provider)
.unwrap()
.then((providerInfo) => {
dispatch(setSelectedProvider(providerInfo));
})
.catch((err) => {
if (err.status === 404) {
dispatch(
setSelectedProvider({
name: provider as ModelProviderName,
concurrency_and_buffer_size: DefaultPerformanceConfig,
network_config: DefaultNetworkConfig,
custom_provider_config: undefined,
proxy_config: undefined,
send_back_raw_request: undefined,
send_back_raw_response: undefined,
provider_status: "error",
}),
);
return;
}
toast.error("Something went wrong", {
description: `We encountered an error while getting provider config: ${getErrorMessage(err)}`,
});
});
}, [provider, isLoadingProviders]);
useEffect(() => {
if (selectedProvider || configuredProviders.length === 0 || provider) return;
setProvider(configuredProviders[0].name);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProvider, configuredProviderNamesKey]);
// When current provider is no longer configured (e.g. all keys deleted), switch to another configured provider
useEffect(() => {
if (!provider || configuredProviderNamesArr.length === 0) return;
const isCurrentConfigured = configuredProviderNamesArr.includes(provider as ModelProviderName);
if (!isCurrentConfigured) {
setProvider(configuredProviderNamesArr[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, configuredProviderNamesKey]);
if (!hasProvidersAccess && hasSettingsOnly) {
return <FullPageLoader />;
}
if (isLoadingProviders) {
return <FullPageLoader />;
}
const handleSelectKnownProvider = async (name: string) => {
try {
await createProvider({ provider: name as ModelProviderName }).unwrap();
setProvider(name);
} catch (err: any) {
if (err?.status === 409) {
setProvider(name);
return;
}
toast.error("Failed to add provider", {
description: getErrorMessage(err),
});
}
};
if (configuredProviders.length === 0) {
return (
<div className="mx-auto w-full max-w-7xl">
<ProvidersEmptyState
addProviderDropdown={
<AddProviderDropdown
disabled={!hasProviderCreateAccess}
existingInSidebar={existingInSidebarNames}
knownProviders={knownProviders}
onSelectKnownProvider={handleSelectKnownProvider}
onAddCustomProvider={() => setShowCustomProviderSheet(true)}
variant="empty"
/>
}
/>
<AddCustomProviderSheet
show={showCustomProviderSheet}
onClose={() => setShowCustomProviderSheet(false)}
onSave={(providerName) => {
setTimeout(() => setProvider(providerName), 300);
setShowCustomProviderSheet(false);
}}
/>
</div>
);
}
return (
<div className="flex h-full w-full flex-row gap-4">
<ConfirmDeleteProviderDialog
provider={selectedProvider!}
show={showDeleteProviderDialog}
onCancel={() => setShowDeleteProviderDialog(false)}
onDelete={() => {
const next = configuredProviders.filter((p) => p.name !== selectedProvider?.name)[0];
setProvider(next?.name ?? null);
setShowDeleteProviderDialog(false);
}}
/>
<ConfirmRedirectionDialog
show={showRedirectionDialog}
onCancel={() => setShowRedirectionDialog(false)}
onContinue={() => {
setShowRedirectionDialog(false);
if (pendingRedirection) setProvider(pendingRedirection);
setPendingRedirection(undefined);
}}
/>
<AddCustomProviderSheet
show={showCustomProviderSheet}
onClose={() => setShowCustomProviderSheet(false)}
onSave={(providerName) => {
setTimeout(() => setProvider(providerName), 300);
setShowCustomProviderSheet(false);
}}
/>
<div className="flex flex-col" style={{ maxHeight: "calc(100vh - 70px)", width: "300px" }}>
<TooltipProvider>
<div className="custom-scrollbar flex-1 overflow-y-auto">
<div className="rounded-md bg-zinc-50/50 p-4 dark:bg-zinc-800/20">
{/* Configured Providers (standard with keys + custom) */}
{configuredProviders.length > 0 && (
<div className="mb-4">
<div className="text-muted-foreground mb-2 text-xs font-medium">Configured Providers</div>
{configuredProviders.map((p) => {
const isCustom = !ProviderNames.includes(p.name as KnownProvider);
const label = isCustom ? p.name : ProviderLabels[p.name as keyof typeof ProviderLabels];
return (
<div
key={p.name}
data-testid={`provider-item-${p.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`}
className={cn(
"mb-1 flex h-8 w-full min-w-0 cursor-pointer items-center gap-2 rounded-sm border px-3 text-sm",
selectedProvider?.name === p.name
? "bg-secondary opacity-100 hover:opacity-100"
: "hover:bg-secondary cursor-pointer border-transparent opacity-100 hover:border",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (providerFormIsDirty) {
setPendingRedirection(p.name);
setShowRedirectionDialog(true);
return;
}
setProvider(p.name);
}}
>
<RenderProviderIcon
provider={(isCustom ? p.custom_provider_config?.base_provider_type : p.name) as ProviderIconType}
size="sm"
className="h-4 w-4 shrink-0"
/>
<TruncatedName name={label} />
<KeyDiscoveryFailedBadge provider={p} />
<ProviderStatusBadge status={p.provider_status} />
{isCustom && (
<Badge variant="secondary" className="text-muted-foreground ml-auto shrink-0 px-1.5 py-0.5 text-[10px] font-bold">
CUSTOM
</Badge>
)}
</div>
);
})}
</div>
)}
<div className="pb-4">
<AddProviderDropdown
disabled={!hasProviderCreateAccess}
existingInSidebar={existingInSidebarNames}
knownProviders={knownProviders}
onSelectKnownProvider={handleSelectKnownProvider}
onAddCustomProvider={() => setShowCustomProviderSheet(true)}
/>
</div>
</div>
</div>
</TooltipProvider>
</div>
{isLoadingProvider && (
<div className="bg-muted/10 flex w-full items-center justify-center rounded-md" style={{ maxHeight: "calc(100vh - 300px)" }}>
<FullPageLoader />
</div>
)}
{!selectedProvider && (
<div className="bg-muted/10 flex w-full items-center justify-center rounded-md" style={{ maxHeight: "calc(100vh - 300px)" }}>
<div className="text-muted-foreground text-sm">Select a provider</div>
</div>
)}
{!isLoadingProvider && selectedProvider && (
<ModelProviderConfig provider={selectedProvider} onRequestDelete={() => setShowDeleteProviderDialog(true)} />
)}
</div>
);
}
function TruncatedName({ name }: { name: string }) {
const textRef = useRef<HTMLDivElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const checkTruncation = useCallback(() => {
const el = textRef.current;
if (el) {
setIsTruncated(el.scrollWidth > el.clientWidth);
}
}, []);
useEffect(() => {
checkTruncation();
window.addEventListener("resize", checkTruncation);
return () => window.removeEventListener("resize", checkTruncation);
}, [checkTruncation, name]);
const inner = (
<div ref={textRef} className="min-w-0 flex-1 truncate text-sm">
{name}
</div>
);
if (!isTruncated) return inner;
return (
<Tooltip>
<TooltipTrigger asChild>{inner}</TooltipTrigger>
<TooltipContent side="right">{name}</TooltipContent>
</Tooltip>
);
}
function ProviderStatusBadge({ status }: { status: ProviderStatus }) {
return status != "active" ? (
<Tooltip>
<TooltipTrigger>
<AlertCircle className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent>{status === "error" ? "Provider could not be initialized" : "Provider is deleted"}</TooltipContent>
</Tooltip>
) : null;
}
function KeyDiscoveryFailedBadge({
provider,
}: {
provider: {
status?: string;
description?: string;
};
}) {
const providerFailed = provider.status === "list_models_failed";
if (!providerFailed) return null;
return (
<Tooltip>
<TooltipTrigger>
<AlertCircle className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent>{provider.description || "Provider model discovery failed."}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import ProvidersRoutingRulesPage from "./page";
export const Route = createFileRoute("/workspace/providers/routing-rules")({
component: ProvidersRoutingRulesPage,
});

View File

@@ -0,0 +1,9 @@
import { RoutingRulesView } from "@/app/workspace/routing-rules/views/routingRulesView";
export default function ProvidersRoutingRulesPage() {
return (
<div className="mx-auto w-full max-w-7xl">
<RoutingRulesView />
</div>
);
}

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