first commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user