clean up providers ui

This commit is contained in:
miloschwartz
2026-08-03 16:06:28 -04:00
parent 0d9a01a066
commit 209272f69c
17 changed files with 1412 additions and 962 deletions
+11 -6
View File
@@ -1632,7 +1632,7 @@
"sidebarInvitations": "Invitations",
"sidebarRoles": "Roles",
"sidebarShareableLinks": "Shareable Links",
"sidebarAi": "AI",
"sidebarAiGateway": "AI Gateway",
"sidebarAiProviders": "Providers",
"commandAiProviders": "AI Providers",
"aiProvidersTitle": "AI Providers",
@@ -1648,7 +1648,11 @@
"aiProviderGeneral": "General",
"aiProviderGeneralDescription": "Basic settings for this provider",
"aiProviderConfiguration": "Configuration",
"aiProviderConfigurationDescription": "Upstream URL, routing, authentication, and TLS settings",
"aiProviderConfigurationDescription": "Network routing and authentication for this provider",
"aiProviderNetworkSettings": "Network Settings",
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
"aiProviderAuthSettings": "Authentication",
"aiProviderAuthSettingsDescription": "Credentials used for both upstream URL and Pangolin target routing",
"aiProviderType": "Provider Type",
"aiProviderTypeSearch": "Search providers...",
"aiProviderTypeNotFound": "No provider type found",
@@ -1675,18 +1679,19 @@
"aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider",
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
"aiProviderApiKey": "API Key",
"aiProviderApiKeyDescription": "Stored encrypted. Leave blank on edit to keep the existing key.",
"aiProviderApiKeyDescription": "API key used to authenticate requests to this provider",
"aiProviderApiKeyLastChars": "API Key",
"aiProviderAuthType": "Auth Type",
"aiProviderAuthTypeBearer": "Bearer",
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
"aiProviderRoutingMode": "Routing Mode",
"aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to Pangolin HTTPS targets",
"aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites",
"aiProviderRoutingModeUrl": "Upstream URL",
"aiProviderRoutingModeUrlDescription": "Call a public or private API base URL",
"aiProviderRoutingModeTarget": "Pangolin Targets",
"aiProviderRoutingModeTarget": "Site Targets",
"aiProviderRoutingModeTargetDescription": "Route through HTTPS targets on your sites",
"aiProviderRoutingModeTargetNote": "Target configuration will be available in a later update. You can still create this provider now.",
"aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.",
"aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.",
"aiProviderSkipTlsVerification": "Skip TLS Verification",
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
"aiProviderBudget": "Budget",
@@ -135,7 +135,9 @@ export async function createAiProvider(
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
data: {
provider: toPublicAiProvider(provider, { includeApiKey: true })
},
success: true,
error: false,
message: "AI provider created successfully",
+3 -1
View File
@@ -67,7 +67,9 @@ export async function getAiProvider(
}
return response<GetAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
data: {
provider: toPublicAiProvider(provider, { includeApiKey: true })
},
success: true,
error: false,
message: "AI provider retrieved successfully",
+22 -2
View File
@@ -6,8 +6,12 @@ import {
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
export type AiProviderPublic = Omit<AiProvider, "apiKey"> & {
/** Decrypted API key. Only included on get/create/update of a single provider. */
apiKey?: string | null;
effectiveUpstreamUrl: string | null;
effectiveAuthType: AiProviderAuthType | null;
};
@@ -36,8 +40,11 @@ export type CreateOrEditAiModelResponse = {
model: AiModel;
};
export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
const { apiKey: _apiKey, ...rest } = provider;
export function toPublicAiProvider(
provider: AiProvider,
options?: { includeApiKey?: boolean }
): AiProviderPublic {
const { apiKey: encryptedApiKey, ...rest } = provider;
const resolved = resolveAiProviderConfig({
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl,
@@ -45,8 +52,21 @@ export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
routingMode: provider.routingMode as AiProviderRoutingMode | null
});
let apiKey: string | null | undefined;
if (options?.includeApiKey) {
if (encryptedApiKey) {
apiKey = decrypt(
encryptedApiKey,
config.getRawConfig().server.secret!
);
} else {
apiKey = null;
}
}
return {
...rest,
...(options?.includeApiKey ? { apiKey } : {}),
effectiveUpstreamUrl: resolved.upstreamUrl,
effectiveAuthType: resolved.authType
};
+14 -2
View File
@@ -125,7 +125,10 @@ export async function updateAiProvider(
? body.upstreamUrl
: existing.upstreamUrl;
const nextAuthType =
body.authType !== undefined ? body.authType : existing.authType;
body.authType !== undefined
? body.authType
: (existing.authType ??
(providerType === "custom" ? "bearer" : null));
const validation = z
.object({
@@ -178,6 +181,13 @@ export async function updateAiProvider(
}
if (body.authType !== undefined) {
updateData.authType = body.authType;
} else if (
providerType === "custom" &&
!existing.authType &&
nextAuthType
) {
// Backfill required authType for custom providers created without one
updateData.authType = nextAuthType;
}
if (body.apiKey !== undefined) {
@@ -193,7 +203,9 @@ export async function updateAiProvider(
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: { provider: toPublicAiProvider(provider) },
data: {
provider: toPublicAiProvider(provider, { includeApiKey: true })
},
success: true,
error: false,
message: "AI provider updated successfully",
+1 -1
View File
@@ -75,7 +75,7 @@ export function refineProviderUpstreamFields(
});
}
if (data.type === "custom" && routingMode === "url" && !data.authType) {
if (data.type === "custom" && !data.authType) {
ctx.addIssue({
code: "custom",
message: "authType is required for custom providers",
@@ -0,0 +1,235 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
aiProviderFormSchema,
toAiProviderAuthPayload,
type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
export default function AiProviderAuthenticationPage() {
const { provider, updateProvider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(aiProviderFormSchema),
defaultValues: {
name: provider.name,
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: provider.apiKey ?? "",
authType: (provider.authType as "bearer" | null) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
skipTlsVerification: provider.skipTlsVerification,
budgetAmount: provider.budgetAmount,
budgetUnit: provider.budgetUnit as "usd" | "tokens" | null,
enabled: provider.enabled
}
});
const showAuthType = provider.type === "custom";
async function onSubmit(values: AiProviderFormValues) {
setSaveLoading(true);
try {
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(
`/ai-provider/${provider.providerId}`,
toAiProviderAuthPayload({
...values,
type: provider.type as AiProviderType
})
);
const updated = res.data.data.provider;
updateProvider(updated);
form.reset({
name: updated.name,
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: updated.apiKey ?? "",
authType: (updated.authType as "bearer" | null) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
skipTlsVerification: updated.skipTlsVerification,
budgetAmount: updated.budgetAmount,
budgetUnit: updated.budgetUnit as "usd" | "tokens" | null,
enabled: updated.enabled
});
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderAuthSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderAuthSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-auth-form"
>
<SettingsFormGrid>
{showAuthType && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderAuthType"
)}
</FormLabel>
<Select
value={
field.value ??
"bearer"
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bearer">
{t(
"aiProviderAuthTypeBearer"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderApiKey")}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderApiKeyDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="ai-provider-auth-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,426 +1,12 @@
"use client";
import { redirect } from "next/navigation";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
aiProviderFormSchema,
showsUpstreamUrlField,
toAiProviderConfigurationPayload,
upstreamUrlRequired,
type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
type Props = {
params: Promise<{ orgId: string; providerId: string }>;
};
export default function AiProviderConfigurationPage() {
const { provider, updateProvider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(aiProviderFormSchema),
defaultValues: {
name: provider.name,
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: "",
authType: (provider.authType as "bearer" | null) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
skipTlsVerification: provider.skipTlsVerification,
budgetAmount: provider.budgetAmount,
budgetUnit: provider.budgetUnit as "usd" | "tokens" | null,
enabled: provider.enabled
}
});
const providerType = form.watch("type");
const routingMode = form.watch("routingMode");
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
const showRoutingMode = providerType === "custom";
const showAuthType =
providerType === "custom" && (routingMode ?? "url") === "url";
const showTargetNote =
providerType === "custom" && routingMode === "target";
async function onSubmit(values: AiProviderFormValues) {
setSaveLoading(true);
try {
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(
`/ai-provider/${provider.providerId}`,
toAiProviderConfigurationPayload({
...values,
type: provider.type as AiProviderType
})
);
const updated = res.data.data.provider;
updateProvider(updated);
form.reset({
name: updated.name,
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: "",
authType: (updated.authType as "bearer" | null) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
skipTlsVerification: updated.skipTlsVerification,
budgetAmount: updated.budgetAmount,
budgetUnit: updated.budgetUnit as "usd" | "tokens" | null,
enabled: updated.enabled
});
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderConfiguration")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-configuration-form"
>
<SettingsFormGrid>
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<StrategySelect
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value ??
"url"
}
onChange={(
value
) => {
field.onChange(
value
);
if (
value ===
"target"
) {
form.setValue(
"upstreamUrl",
""
);
}
}}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderRoutingModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showTargetNote && (
<SettingsFormCell span="full">
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle>
{t(
"aiProviderRoutingModeTarget"
)}
</AlertTitle>
<AlertDescription>
{t(
"aiProviderRoutingModeTargetNote"
)}
</AlertDescription>
</Alert>
</SettingsFormCell>
)}
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderUpstreamUrl"
)}
{requireUpstream
? ""
: " (optional)"}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="https://"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{requireUpstream
? t(
"aiProviderUpstreamUrlDescription"
)
: t(
"aiProviderUpstreamUrlOptionalDescription"
)}
</FormDescription>
{provider.effectiveUpstreamUrl && (
<FormDescription>
{t(
"aiProviderEffectiveUpstreamUrl"
)}
{": "}
<span className="font-mono">
{
provider.effectiveUpstreamUrl
}
</span>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showAuthType && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderAuthType"
)}
</FormLabel>
<Select
value={
field.value ??
"bearer"
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bearer">
{t(
"aiProviderAuthTypeBearer"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderApiKey")}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{provider.apiKeyLastChars
? `••••${provider.apiKeyLastChars}. ${t("aiProviderApiKeyDescription")}`
: t(
"aiProviderApiKeyDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="edit-skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="ai-provider-configuration-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
export default async function AiProviderConfigurationRedirect({
params
}: Props) {
const { orgId, providerId } = await params;
redirect(`/${orgId}/settings/ai-providers/${providerId}/network`);
}
@@ -66,8 +66,12 @@ export default async function AiProviderLayout({ children, params }: Props) {
href: "/{orgId}/settings/ai-providers/{providerId}/general"
},
{
title: t("aiProviderConfiguration"),
href: "/{orgId}/settings/ai-providers/{providerId}/configuration"
title: t("aiProviderNetworkSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/network"
},
{
title: t("aiProviderAuthSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
}
];
@@ -0,0 +1,362 @@
"use client";
import {
ProxyResourceTargetsForm,
type ProxyResourceTargetsFormHandle
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
aiProviderFormSchema,
showsUpstreamUrlField,
toAiProviderNetworkPayload,
upstreamUrlRequired,
type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema";
import { aiProviderQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
export default function AiProviderNetworkPage() {
const { provider, updateProvider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const params = useParams();
const orgId = params.orgId as string;
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null);
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(aiProviderFormSchema),
defaultValues: {
name: provider.name,
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: "",
authType: (provider.authType as "bearer" | null) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
skipTlsVerification: provider.skipTlsVerification,
budgetAmount: provider.budgetAmount,
budgetUnit: provider.budgetUnit as "usd" | "tokens" | null,
enabled: provider.enabled
}
});
const providerType = form.watch("type");
const routingMode = form.watch("routingMode");
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
const showRoutingMode = providerType === "custom";
const isTargetModeSelected = routingMode === "target";
const isTargetModeSaved =
provider.type === "custom" && provider.routingMode === "target";
const showTargetsForm = showRoutingMode && isTargetModeSelected;
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery({
...aiProviderQueries.providerTargets({
providerId: provider.providerId
}),
enabled: isTargetModeSaved
});
async function onSubmit(values: AiProviderFormValues) {
setSaveLoading(true);
try {
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(
`/ai-provider/${provider.providerId}`,
toAiProviderNetworkPayload({
...values,
type: provider.type as AiProviderType
})
);
const updated = res.data.data.provider;
updateProvider(updated);
form.reset({
name: updated.name,
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: "",
authType: (updated.authType as "bearer" | null) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
skipTlsVerification: updated.skipTlsVerification,
budgetAmount: updated.budgetAmount,
budgetUnit: updated.budgetUnit as "usd" | "tokens" | null,
enabled: updated.enabled
});
if (values.routingMode === "target" && targetsFormRef.current) {
const targetsSaved = await targetsFormRef.current.save({
silent: true
});
if (!targetsSaved) {
return;
}
}
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderNetworkSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderNetworkSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-network-form"
>
<SettingsFormGrid>
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<StrategySelect
cols={2}
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value ??
"url"
}
onChange={(
value
) => {
field.onChange(
value
);
if (
value ===
"target"
) {
form.setValue(
"upstreamUrl",
""
);
}
}}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderRoutingModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderUpstreamUrl"
)}
{requireUpstream
? ""
: " (optional)"}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="https://"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{requireUpstream
? t(
"aiProviderUpstreamUrlDescription"
)
: t(
"aiProviderUpstreamUrlOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="edit-skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
{showTargetsForm &&
(!isTargetModeSaved || !isLoadingTargets) && (
<div className="mt-6 space-y-4">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("targets")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("targetsDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
<ProxyResourceTargetsForm
ref={targetsFormRef}
orgId={orgId}
isHttp
providerId={provider.providerId}
initialTargets={
isTargetModeSaved ? remoteTargets : []
}
allowedMethods={["http", "https"]}
emptyMessage={t("aiProviderTargetNoOne")}
embedded
hideSaveButton
/>
</div>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="ai-provider-network-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,5 +1,9 @@
"use client";
import {
ProxyResourceTargetsForm,
type LocalTarget
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
SettingsContainer,
SettingsFormCell,
@@ -7,16 +11,17 @@ import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
Form,
@@ -39,7 +44,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
aiProviderFormSchema,
aiProviderCreateFormSchema,
emptyUpstreamForType,
showsUpstreamUrlField,
toAiProviderCreatePayload,
@@ -49,10 +54,9 @@ import {
import { zodResolver } from "@hookform/resolvers/zod";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
export default function CreateAiProviderPage() {
@@ -63,9 +67,10 @@ export default function CreateAiProviderPage() {
const router = useRouter();
const t = useTranslations();
const [loading, setLoading] = useState(false);
const targetsRef = useRef<LocalTarget[]>([]);
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(aiProviderFormSchema),
resolver: zodResolver(aiProviderCreateFormSchema),
defaultValues: {
name: "",
type: "openai",
@@ -86,26 +91,101 @@ export default function CreateAiProviderPage() {
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
const showRoutingMode = providerType === "custom";
const showAuthType =
providerType === "custom" && (routingMode ?? "url") === "url";
const showTargetNote =
providerType === "custom" && routingMode === "target";
const showAuthType = providerType === "custom";
const showTargets = providerType === "custom" && routingMode === "target";
async function createTargets(
providerId: number,
localTargets: LocalTarget[]
) {
for (const target of localTargets) {
const data = {
ip: target.ip,
port: target.port,
method: target.method,
enabled: target.enabled,
siteId: target.siteId,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath || null,
hcMethod: target.hcMethod || null,
hcInterval: target.hcInterval || null,
hcTimeout: target.hcTimeout || null,
hcHeaders: target.hcHeaders || null,
hcScheme: target.hcScheme || null,
hcHostname: target.hcHostname || null,
hcPort: target.hcPort || null,
hcFollowRedirects: target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName,
hcHealthyThreshold: target.hcHealthyThreshold || null,
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
};
await api.put(`/ai-provider/${providerId}/target`, data);
}
}
async function onSubmit(values: AiProviderFormValues) {
const targets = targetsRef.current;
if (showTargets) {
const invalidTargets = targets.filter(
(target) =>
!target.ip ||
target.ip.trim() === "" ||
!target.port ||
target.port <= 0 ||
isNaN(target.port)
);
if (invalidTargets.length > 0) {
toast({
variant: "destructive",
title: t("targetErrorInvalidIp"),
description: t("targetErrorInvalidIpDescription")
});
return;
}
}
setLoading(true);
try {
const res = await api.put<
AxiosResponse<CreateOrEditAiProviderResponse>
>(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values));
const providerId = res.data.data.provider.providerId;
if (showTargets && targets.length > 0) {
try {
await createTargets(providerId, targets);
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorCreate"),
description: formatAxiosError(
e,
t("aiProviderErrorCreate")
)
});
router.push(
`/${orgId}/settings/ai-providers/${providerId}/network`
);
return;
}
}
toast({
title: t("success"),
description: t("aiProviderCreated")
});
router.push(
`/${orgId}/settings/ai-providers/${res.data.data.provider.providerId}`
);
router.push(`/${orgId}/settings/ai-providers/${providerId}`);
} catch (e) {
toast({
variant: "destructive",
@@ -134,91 +214,143 @@ export default function CreateAiProviderPage() {
</Button>
</div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<Form {...form}>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="create-ai-provider-form"
>
<SettingsFormGrid>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderType")}
</FormLabel>
<FormControl>
<AiProviderTypeSelect
value={field.value}
onChange={(
value
) => {
field.onChange(
value
);
form.setValue(
"upstreamUrl",
emptyUpstreamForType(
value
)
);
if (
value !==
"custom"
) {
form.setValue(
"routingMode",
"url"
);
targetsRef.current =
[];
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderNetworkSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderNetworkSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="enabled"
label={t(
"aiProviderEnabled"
)}
description={t(
"aiProviderEnabledDescription"
)}
checked={
field.value ??
true
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="type"
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderType"
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<AiProviderTypeSelect
<StrategySelect
cols={2}
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value
field.value ??
"url"
}
onChange={(
value
@@ -226,223 +358,52 @@ export default function CreateAiProviderPage() {
field.onChange(
value
);
form.setValue(
"upstreamUrl",
emptyUpstreamForType(
value
)
);
if (
value !==
"custom"
value ===
"target"
) {
form.setValue(
"routingMode",
"url"
"upstreamUrl",
""
);
} else {
targetsRef.current =
[];
}
}}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderRoutingModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<StrategySelect
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value ??
"url"
}
onChange={(
value
) => {
field.onChange(
value
);
if (
value ===
"target"
) {
form.setValue(
"upstreamUrl",
""
);
}
}}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderRoutingModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showTargetNote && (
<SettingsFormCell span="full">
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle>
{t(
"aiProviderRoutingModeTarget"
)}
</AlertTitle>
<AlertDescription>
{t(
"aiProviderRoutingModeTargetNote"
)}
</AlertDescription>
</Alert>
</SettingsFormCell>
)}
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderUpstreamUrl"
)}
{requireUpstream
? ""
: " (optional)"}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="https://"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{requireUpstream
? t(
"aiProviderUpstreamUrlDescription"
)
: t(
"aiProviderUpstreamUrlOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showAuthType && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderAuthType"
)}
</FormLabel>
<Select
value={
field.value ??
"bearer"
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bearer">
{t(
"aiProviderAuthTypeBearer"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderApiKey"
"aiProviderUpstreamUrl"
)}
{requireUpstream
? ""
: " (optional)"}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
autoComplete="off"
placeholder="https://"
value={
field.value ??
""
@@ -453,8 +414,131 @@ export default function CreateAiProviderPage() {
/>
</FormControl>
<FormDescription>
{requireUpstream
? t(
"aiProviderUpstreamUrlDescription"
)
: t(
"aiProviderUpstreamUrlOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
{showTargets && (
<div className="mt-6 space-y-4">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("targets")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("targetsDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
<ProxyResourceTargetsForm
orgId={orgId}
isHttp
onChange={(nextTargets) => {
targetsRef.current = nextTargets;
}}
allowedMethods={["http", "https"]}
emptyMessage={t(
"aiProviderTargetNoOne"
)}
embedded
hideSaveButton
/>
</div>
)}
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderAuthSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderAuthSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
{showAuthType && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderApiKeyDescription"
"aiProviderAuthType"
)}
</FormLabel>
<Select
value={
field.value ??
"bearer"
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bearer">
{t(
"aiProviderAuthTypeBearer"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
@@ -462,53 +546,68 @@ export default function CreateAiProviderPage() {
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loading}
disabled={loading}
form="create-ai-provider-form"
>
{t("create")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderApiKey")}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderApiKeyDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() =>
router.push(`/${orgId}/settings/ai-providers`)
}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={loading}
disabled={loading}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("create")}
</Button>
</div>
</Form>
</>
);
}
@@ -42,7 +42,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { type GetResourceResponse } from "@server/routers/resource";
import { CreateTargetResponse } from "@server/routers/target";
@@ -63,9 +63,11 @@ import { ExternalLink, Info, Plus } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import {
forwardRef,
useActionState,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState
} from "react";
@@ -80,27 +82,52 @@ export type LocalTarget = Omit<
"protocol"
>;
interface ProxyResourceTargetsFormProps {
export type ProxyResourceTargetsFormHandle = {
save: (options?: { silent?: boolean }) => Promise<boolean>;
};
type ProxyResourceTargetsFormProps = {
orgId: string;
isHttp: boolean;
initialTargets?: LocalTarget[];
/** Edit mode: when provided, shows a save button and polls for health status */
/** Edit mode for a public resource: save button + health polling */
resource?: GetResourceResponse;
/** Edit mode for an AI provider: save button + health polling */
providerId?: number;
updateResource?: ResourceContextType["updateResource"];
/** Create mode: called whenever the targets list changes */
onChange?: (targets: LocalTarget[]) => void;
}
/** HTTP method options for address selector. Defaults to http/https/h2c. */
allowedMethods?: ("http" | "https" | "h2c")[];
emptyMessage?: string;
/** Render table without its own SettingsSection wrapper */
embedded?: boolean;
/** Hide the built-in save button (use ref.save from parent) */
hideSaveButton?: boolean;
};
export function ProxyResourceTargetsForm({
orgId,
isHttp,
initialTargets = [],
resource,
updateResource,
onChange
}: ProxyResourceTargetsFormProps) {
export const ProxyResourceTargetsForm = forwardRef<
ProxyResourceTargetsFormHandle,
ProxyResourceTargetsFormProps
>(function ProxyResourceTargetsForm(
{
orgId,
isHttp,
initialTargets = [],
resource,
providerId,
updateResource,
onChange,
allowedMethods = ["http", "https", "h2c"],
emptyMessage,
embedded = false,
hideSaveButton = false
},
ref
) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const isEditMode = !!resource || !!providerId;
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
@@ -111,7 +138,7 @@ export function ProxyResourceTargetsForm({
}, [targets]);
// Poll health status only in edit mode
const { data: polledTargets } = useQuery({
const { data: polledResourceTargets } = useQuery({
...resourceQueries.resourceTargets({
resourceId: resource?.resourceId ?? 0
}),
@@ -119,6 +146,18 @@ export function ProxyResourceTargetsForm({
enabled: !!resource
});
const { data: polledProviderTargets } = useQuery({
...aiProviderQueries.providerTargets({
providerId: providerId ?? 0
}),
refetchInterval: 10_000,
enabled: !!providerId
});
const polledTargets = providerId
? polledProviderTargets
: polledResourceTargets;
useEffect(() => {
if (!polledTargets) return;
setTargets((prev) =>
@@ -427,6 +466,7 @@ export function ProxyResourceTargetsForm({
isHttp={isHttp}
proxyTarget={row.original}
updateTarget={updateTarget}
allowedMethods={allowedMethods}
/>
);
},
@@ -576,16 +616,22 @@ export function ProxyResourceTargetsForm({
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
allowedMethods,
t
]);
function addNewTarget() {
const defaultMethod = providerId
? (allowedMethods[0] ?? "https")
: isHttp
? "http"
: null;
const newTarget: LocalTarget = {
targetId: -Date.now(),
ip: "",
mode: ((resource?.mode as LocalTarget["mode"]) ??
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
method: isHttp ? "http" : null,
method: defaultMethod,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
siteName: sites.length > 0 ? sites[0].name : "",
@@ -595,8 +641,8 @@ export function ProxyResourceTargetsForm({
rewritePathType: null,
priority: 100,
enabled: true,
resourceId: resource?.resourceId ?? 0,
providerId: null,
resourceId: resource?.resourceId ?? null,
providerId: providerId ?? null,
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -671,10 +717,16 @@ export function ProxyResourceTargetsForm({
}
}, [isAdvancedMode]);
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
const [, formAction, isSubmitting] = useActionState(
async () => {
await saveTargets();
return null;
},
null
);
const addTargetButton = (
<Button onClick={addNewTarget} variant="outline">
<Button type="button" onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
@@ -682,8 +734,8 @@ export function ProxyResourceTargetsForm({
const hasTargets = targets.length > 0;
async function saveTargets() {
if (!resource) return;
async function saveTargets(options?: { silent?: boolean }) {
if (!isEditMode) return true;
const targetsWithInvalidFields = targets.filter(
(target) =>
@@ -699,7 +751,7 @@ export function ProxyResourceTargetsForm({
title: t("targetErrorInvalidIp"),
description: t("targetErrorInvalidIpDescription")
});
return;
return false;
}
try {
@@ -743,9 +795,12 @@ export function ProxyResourceTargetsForm({
}
if (target.new) {
const createPath = providerId
? `/ai-provider/${providerId}/target`
: `/resource/${resource!.resourceId}/target`;
const res = await api.put<
AxiosResponse<CreateTargetResponse>
>(`/resource/${resource.resourceId}/target`, data);
>(createPath, data);
target.targetId = res.data.data.targetId;
target.new = false;
} else if (target.updated) {
@@ -754,24 +809,33 @@ export function ProxyResourceTargetsForm({
}
}
toast({
title:
targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description:
targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
if (!options?.silent) {
toast({
title:
targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description:
targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
}
setTargetsToRemove([]);
router.refresh();
await queryClient.invalidateQueries(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
})
);
if (providerId) {
await queryClient.invalidateQueries(
aiProviderQueries.providerTargets({ providerId })
);
} else if (resource) {
await queryClient.invalidateQueries(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
})
);
}
return true;
} catch (err) {
console.error(err);
toast({
@@ -782,151 +846,164 @@ export function ProxyResourceTargetsForm({
t("settingsErrorUpdateDescription")
)
});
return false;
}
}
useImperativeHandle(ref, () => ({
save: saveTargets
}));
const advancedModeToggleId = providerId
? `advanced-mode-toggle-provider-${providerId}`
: resource
? `advanced-mode-toggle-resource-${resource.resourceId}`
: "advanced-mode-toggle";
const targetsTable = (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn =
header.column.id === "actions";
const isSiteColumn =
header.column.id === "site";
return (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => {
const isActionsColumn =
cell.column.id === "actions";
const isSiteColumn =
cell.column.id === "site";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={emptyMessage ?? t("targetNoOne")}
action={addTargetButton}
/>
)}
</TableBody>
</Table>
</div>
{hasTargets && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{addTargetButton}
<div className="flex items-center gap-2">
<Switch
id={advancedModeToggleId}
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor={advancedModeToggleId}
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
</div>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</>
);
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("targets")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("targetsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn =
header.column.id === "actions";
const isSiteColumn =
header.column.id === "site";
return (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column.id ===
"actions";
const isSiteColumn =
cell.column.id ===
"site";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("targetNoOne")}
action={addTargetButton}
/>
)}
</TableBody>
</Table>
</div>
{hasTargets && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{addTargetButton}
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
</div>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>
{embedded ? (
<div className="space-y-4">{targetsTable}</div>
) : (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("targets")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("targetsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>{targetsTable}</SettingsSectionBody>
{/* Save button — only shown in edit mode */}
{resource && (
<form className="self-end mt-4" action={formAction}>
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveResourceTargets")}
</Button>
</form>
)}
</SettingsSection>
{isEditMode && !hideSaveButton && (
<form className="self-end mt-4" action={formAction}>
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveResourceTargets")}
</Button>
</form>
)}
</SettingsSection>
)}
{selectedTargetForHealthCheck && (
<HealthCheckCredenza
@@ -987,4 +1064,4 @@ export function ProxyResourceTargetsForm({
)}
</>
);
}
});
+4 -3
View File
@@ -31,6 +31,7 @@ import {
TicketCheck,
Unplug,
User,
UserCheck,
UserCog,
Users,
Waypoints
@@ -176,7 +177,7 @@ export const orgNavSections = (
{
title: "sidebarApprovals",
href: "/{orgId}/settings/access/approvals",
icon: <UserCog className="size-4 flex-none" />
icon: <UserCheck className="size-4 flex-none" />
}
]
: []),
@@ -188,7 +189,7 @@ export const orgNavSections = (
]
},
{
heading: "sidebarAi",
heading: "sidebarAiGateway",
items: [
{
title: "sidebarAiProviders",
@@ -483,7 +484,7 @@ export const commandBarNavSections = (
]
},
{
heading: "sidebarAi",
heading: "sidebarAiGateway",
items: [
{
title: "commandAiProviders",
+5 -11
View File
@@ -83,20 +83,14 @@ export function AiProviderTypeSelect({
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal h-auto min-h-10 py-2",
"w-full justify-between",
!selected && "text-muted-foreground",
className
)}
>
<div className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left">
<span className="truncate">
{selected?.title ?? t("noneSelected")}
</span>
{selected?.description && (
<span className="text-muted-foreground text-xs leading-snug truncate w-full">
{selected.description}
</span>
)}
</div>
<span className="truncate text-left">
{selected?.title ?? t("noneSelected")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -133,12 +133,14 @@ export type ResourceTargetAddressItemProps = {
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
proxyTarget: LocalTarget;
isHttp: boolean;
allowedMethods?: ("http" | "https" | "h2c")[];
};
export function ResourceTargetAddressItem({
updateTarget,
proxyTarget,
isHttp
isHttp,
allowedMethods = ["http", "https", "h2c"]
}: ResourceTargetAddressItemProps) {
return (
<div className="flex items-center w-full" key={proxyTarget.targetId}>
@@ -157,9 +159,15 @@ export function ResourceTargetAddressItem({
{proxyTarget.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
{allowedMethods.includes("http") && (
<SelectItem value="http">http</SelectItem>
)}
{allowedMethods.includes("https") && (
<SelectItem value="https">https</SelectItem>
)}
{allowedMethods.includes("h2c") && (
<SelectItem value="h2c">h2c</SelectItem>
)}
</SelectContent>
</Select>
)}
+32 -3
View File
@@ -71,7 +71,7 @@ export const aiProviderFormSchema = z
});
}
if (data.type === "custom" && routingMode === "url" && !data.authType) {
if (data.type === "custom" && !data.authType) {
ctx.addIssue({
code: "custom",
message: "authType is required for custom providers",
@@ -96,6 +96,18 @@ export const aiProviderFormSchema = z
export type AiProviderFormValues = z.infer<typeof aiProviderFormSchema>;
export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine(
(data, ctx) => {
if (!data.apiKey?.trim()) {
ctx.addIssue({
code: "custom",
message: "API key is required",
path: ["apiKey"]
});
}
}
);
export function emptyUpstreamForType(type: AiProviderType): string {
if (type === "custom") {
return "";
@@ -145,7 +157,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) {
upstreamUrl,
apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined,
authType:
values.type === "custom" && routingMode === "url"
values.type === "custom"
? (values.authType ?? "bearer")
: (values.authType ?? undefined),
skipTlsVerification: values.skipTlsVerification,
@@ -176,7 +188,7 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
routingMode: values.type === "custom" ? routingMode : "url",
upstreamUrl,
authType:
values.type === "custom" && routingMode === "url"
values.type === "custom"
? (values.authType ?? "bearer")
: (values.authType ?? null),
skipTlsVerification: values.skipTlsVerification ?? false,
@@ -192,6 +204,23 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
return payload;
}
export function toAiProviderNetworkPayload(values: AiProviderFormValues) {
const full = toAiProviderUpdatePayload(values);
return {
routingMode: full.routingMode,
upstreamUrl: full.upstreamUrl,
skipTlsVerification: full.skipTlsVerification
};
}
export function toAiProviderAuthPayload(values: AiProviderFormValues) {
const full = toAiProviderUpdatePayload(values);
return {
authType: full.authType,
...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {})
};
}
export function toAiProviderConfigurationPayload(values: AiProviderFormValues) {
const {
name: _name,
+14
View File
@@ -1163,6 +1163,20 @@ export const logQueries = {
})
};
export const aiProviderQueries = {
providerTargets: ({ providerId }: { providerId: number }) =>
queryOptions({
queryKey: ["AI_PROVIDERS", providerId, "TARGETS"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListTargetsResponse>
>(`/ai-provider/${providerId}/targets`, { signal });
return res.data.data.targets;
}
})
};
export const resourceQueries = {
resourceUsers: ({ resourceId }: { resourceId: number }) =>
queryOptions({