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