522 lines
19 KiB
TypeScript
522 lines
19 KiB
TypeScript
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>
|
|
);
|
|
} |