mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-04 11:41:14 +02:00
clean up providers ui
This commit is contained in:
+11
-6
@@ -1632,7 +1632,7 @@
|
|||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Shareable Links",
|
"sidebarShareableLinks": "Shareable Links",
|
||||||
"sidebarAi": "AI",
|
"sidebarAiGateway": "AI Gateway",
|
||||||
"sidebarAiProviders": "Providers",
|
"sidebarAiProviders": "Providers",
|
||||||
"commandAiProviders": "AI Providers",
|
"commandAiProviders": "AI Providers",
|
||||||
"aiProvidersTitle": "AI Providers",
|
"aiProvidersTitle": "AI Providers",
|
||||||
@@ -1648,7 +1648,11 @@
|
|||||||
"aiProviderGeneral": "General",
|
"aiProviderGeneral": "General",
|
||||||
"aiProviderGeneralDescription": "Basic settings for this provider",
|
"aiProviderGeneralDescription": "Basic settings for this provider",
|
||||||
"aiProviderConfiguration": "Configuration",
|
"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",
|
"aiProviderType": "Provider Type",
|
||||||
"aiProviderTypeSearch": "Search providers...",
|
"aiProviderTypeSearch": "Search providers...",
|
||||||
"aiProviderTypeNotFound": "No provider type found",
|
"aiProviderTypeNotFound": "No provider type found",
|
||||||
@@ -1675,18 +1679,19 @@
|
|||||||
"aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider",
|
"aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider",
|
||||||
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
|
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
|
||||||
"aiProviderApiKey": "API Key",
|
"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",
|
"aiProviderApiKeyLastChars": "API Key",
|
||||||
"aiProviderAuthType": "Auth Type",
|
"aiProviderAuthType": "Auth Type",
|
||||||
"aiProviderAuthTypeBearer": "Bearer",
|
"aiProviderAuthTypeBearer": "Bearer",
|
||||||
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
|
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
|
||||||
"aiProviderRoutingMode": "Routing Mode",
|
"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",
|
"aiProviderRoutingModeUrl": "Upstream URL",
|
||||||
"aiProviderRoutingModeUrlDescription": "Call a public or private API base URL",
|
"aiProviderRoutingModeUrlDescription": "Call a public or private API base URL",
|
||||||
"aiProviderRoutingModeTarget": "Pangolin Targets",
|
"aiProviderRoutingModeTarget": "Site Targets",
|
||||||
"aiProviderRoutingModeTargetDescription": "Route through HTTPS targets on your sites",
|
"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",
|
"aiProviderSkipTlsVerification": "Skip TLS Verification",
|
||||||
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
|
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
|
||||||
"aiProviderBudget": "Budget",
|
"aiProviderBudget": "Budget",
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ export async function createAiProvider(
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return response<CreateOrEditAiProviderResponse>(res, {
|
return response<CreateOrEditAiProviderResponse>(res, {
|
||||||
data: { provider: toPublicAiProvider(provider) },
|
data: {
|
||||||
|
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "AI provider created successfully",
|
message: "AI provider created successfully",
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ export async function getAiProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response<GetAiProviderResponse>(res, {
|
return response<GetAiProviderResponse>(res, {
|
||||||
data: { provider: toPublicAiProvider(provider) },
|
data: {
|
||||||
|
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "AI provider retrieved successfully",
|
message: "AI provider retrieved successfully",
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import {
|
|||||||
type AiProviderRoutingMode,
|
type AiProviderRoutingMode,
|
||||||
type AiProviderType
|
type AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export type AiProviderPublic = Omit<AiProvider, "apiKey"> & {
|
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;
|
effectiveUpstreamUrl: string | null;
|
||||||
effectiveAuthType: AiProviderAuthType | null;
|
effectiveAuthType: AiProviderAuthType | null;
|
||||||
};
|
};
|
||||||
@@ -36,8 +40,11 @@ export type CreateOrEditAiModelResponse = {
|
|||||||
model: AiModel;
|
model: AiModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
|
export function toPublicAiProvider(
|
||||||
const { apiKey: _apiKey, ...rest } = provider;
|
provider: AiProvider,
|
||||||
|
options?: { includeApiKey?: boolean }
|
||||||
|
): AiProviderPublic {
|
||||||
|
const { apiKey: encryptedApiKey, ...rest } = provider;
|
||||||
const resolved = resolveAiProviderConfig({
|
const resolved = resolveAiProviderConfig({
|
||||||
type: provider.type as AiProviderType,
|
type: provider.type as AiProviderType,
|
||||||
upstreamUrl: provider.upstreamUrl,
|
upstreamUrl: provider.upstreamUrl,
|
||||||
@@ -45,8 +52,21 @@ export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
|
|||||||
routingMode: provider.routingMode as AiProviderRoutingMode | null
|
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 {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
...(options?.includeApiKey ? { apiKey } : {}),
|
||||||
effectiveUpstreamUrl: resolved.upstreamUrl,
|
effectiveUpstreamUrl: resolved.upstreamUrl,
|
||||||
effectiveAuthType: resolved.authType
|
effectiveAuthType: resolved.authType
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,7 +125,10 @@ export async function updateAiProvider(
|
|||||||
? body.upstreamUrl
|
? body.upstreamUrl
|
||||||
: existing.upstreamUrl;
|
: existing.upstreamUrl;
|
||||||
const nextAuthType =
|
const nextAuthType =
|
||||||
body.authType !== undefined ? body.authType : existing.authType;
|
body.authType !== undefined
|
||||||
|
? body.authType
|
||||||
|
: (existing.authType ??
|
||||||
|
(providerType === "custom" ? "bearer" : null));
|
||||||
|
|
||||||
const validation = z
|
const validation = z
|
||||||
.object({
|
.object({
|
||||||
@@ -178,6 +181,13 @@ export async function updateAiProvider(
|
|||||||
}
|
}
|
||||||
if (body.authType !== undefined) {
|
if (body.authType !== undefined) {
|
||||||
updateData.authType = body.authType;
|
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) {
|
if (body.apiKey !== undefined) {
|
||||||
@@ -193,7 +203,9 @@ export async function updateAiProvider(
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return response<CreateOrEditAiProviderResponse>(res, {
|
return response<CreateOrEditAiProviderResponse>(res, {
|
||||||
data: { provider: toPublicAiProvider(provider) },
|
data: {
|
||||||
|
provider: toPublicAiProvider(provider, { includeApiKey: true })
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "AI provider updated successfully",
|
message: "AI provider updated successfully",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function refineProviderUpstreamFields(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === "custom" && routingMode === "url" && !data.authType) {
|
if (data.type === "custom" && !data.authType) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "authType is required for custom providers",
|
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 {
|
type Props = {
|
||||||
SettingsContainer,
|
params: Promise<{ orgId: string; providerId: string }>;
|
||||||
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";
|
|
||||||
|
|
||||||
export default function AiProviderConfigurationPage() {
|
export default async function AiProviderConfigurationRedirect({
|
||||||
const { provider, updateProvider } = useAiProviderContext();
|
params
|
||||||
const { env } = useEnvContext();
|
}: Props) {
|
||||||
const api = createApiClient({ env });
|
const { orgId, providerId } = await params;
|
||||||
const router = useRouter();
|
redirect(`/${orgId}/settings/ai-providers/${providerId}/network`);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,12 @@ export default async function AiProviderLayout({ children, params }: Props) {
|
|||||||
href: "/{orgId}/settings/ai-providers/{providerId}/general"
|
href: "/{orgId}/settings/ai-providers/{providerId}/general"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("aiProviderConfiguration"),
|
title: t("aiProviderNetworkSettings"),
|
||||||
href: "/{orgId}/settings/ai-providers/{providerId}/configuration"
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ProxyResourceTargetsForm,
|
||||||
|
type LocalTarget
|
||||||
|
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsFormCell,
|
SettingsFormCell,
|
||||||
@@ -7,16 +11,17 @@ import {
|
|||||||
SettingsSection,
|
SettingsSection,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionFooter,
|
|
||||||
SettingsSectionForm,
|
SettingsSectionForm,
|
||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
|
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -39,7 +44,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import {
|
import {
|
||||||
aiProviderFormSchema,
|
aiProviderCreateFormSchema,
|
||||||
emptyUpstreamForType,
|
emptyUpstreamForType,
|
||||||
showsUpstreamUrlField,
|
showsUpstreamUrlField,
|
||||||
toAiProviderCreatePayload,
|
toAiProviderCreatePayload,
|
||||||
@@ -49,10 +54,9 @@ import {
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { InfoIcon } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
export default function CreateAiProviderPage() {
|
export default function CreateAiProviderPage() {
|
||||||
@@ -63,9 +67,10 @@ export default function CreateAiProviderPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const targetsRef = useRef<LocalTarget[]>([]);
|
||||||
|
|
||||||
const form = useForm<AiProviderFormValues>({
|
const form = useForm<AiProviderFormValues>({
|
||||||
resolver: zodResolver(aiProviderFormSchema),
|
resolver: zodResolver(aiProviderCreateFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
type: "openai",
|
type: "openai",
|
||||||
@@ -86,26 +91,101 @@ export default function CreateAiProviderPage() {
|
|||||||
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
|
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
|
||||||
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
|
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
|
||||||
const showRoutingMode = providerType === "custom";
|
const showRoutingMode = providerType === "custom";
|
||||||
const showAuthType =
|
const showAuthType = providerType === "custom";
|
||||||
providerType === "custom" && (routingMode ?? "url") === "url";
|
const showTargets = providerType === "custom" && routingMode === "target";
|
||||||
const showTargetNote =
|
|
||||||
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) {
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.put<
|
const res = await api.put<
|
||||||
AxiosResponse<CreateOrEditAiProviderResponse>
|
AxiosResponse<CreateOrEditAiProviderResponse>
|
||||||
>(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values));
|
>(`/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({
|
toast({
|
||||||
title: t("success"),
|
title: t("success"),
|
||||||
description: t("aiProviderCreated")
|
description: t("aiProviderCreated")
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(
|
router.push(`/${orgId}/settings/ai-providers/${providerId}`);
|
||||||
`/${orgId}/settings/ai-providers/${res.data.data.provider.providerId}`
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -134,91 +214,143 @@ export default function CreateAiProviderPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsContainer>
|
<Form {...form}>
|
||||||
<SettingsSection>
|
<SettingsContainer>
|
||||||
<SettingsSectionHeader>
|
<SettingsSection>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionHeader>
|
||||||
{t("aiProviderGeneral")}
|
<SettingsSectionTitle>
|
||||||
</SettingsSectionTitle>
|
{t("aiProviderGeneral")}
|
||||||
<SettingsSectionDescription>
|
</SettingsSectionTitle>
|
||||||
{t("aiProviderGeneralDescription")}
|
<SettingsSectionDescription>
|
||||||
</SettingsSectionDescription>
|
{t("aiProviderGeneralDescription")}
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
<Form {...form}>
|
<SettingsFormGrid>
|
||||||
<form
|
<SettingsFormCell span="half">
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
<FormField
|
||||||
id="create-ai-provider-form"
|
control={form.control}
|
||||||
>
|
name="name"
|
||||||
<SettingsFormGrid>
|
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">
|
<SettingsFormCell span="full">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="enabled"
|
name="routingMode"
|
||||||
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"
|
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t(
|
{t(
|
||||||
"aiProviderType"
|
"aiProviderRoutingMode"
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<AiProviderTypeSelect
|
<StrategySelect
|
||||||
|
cols={2}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
id: "url",
|
||||||
|
title: t(
|
||||||
|
"aiProviderRoutingModeUrl"
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
t(
|
||||||
|
"aiProviderRoutingModeUrlDescription"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target",
|
||||||
|
title: t(
|
||||||
|
"aiProviderRoutingModeTarget"
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
t(
|
||||||
|
"aiProviderRoutingModeTargetDescription"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
value={
|
value={
|
||||||
field.value
|
field.value ??
|
||||||
|
"url"
|
||||||
}
|
}
|
||||||
onChange={(
|
onChange={(
|
||||||
value
|
value
|
||||||
@@ -226,223 +358,52 @@ export default function CreateAiProviderPage() {
|
|||||||
field.onChange(
|
field.onChange(
|
||||||
value
|
value
|
||||||
);
|
);
|
||||||
form.setValue(
|
|
||||||
"upstreamUrl",
|
|
||||||
emptyUpstreamForType(
|
|
||||||
value
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
value !==
|
value ===
|
||||||
"custom"
|
"target"
|
||||||
) {
|
) {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"routingMode",
|
"upstreamUrl",
|
||||||
"url"
|
""
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
targetsRef.current =
|
||||||
|
[];
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"aiProviderRoutingModeDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{showRoutingMode && (
|
{showUpstream && (
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsFormCell span="half">
|
<SettingsFormCell span="half">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="apiKey"
|
name="upstreamUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t(
|
{t(
|
||||||
"aiProviderApiKey"
|
"aiProviderUpstreamUrl"
|
||||||
)}
|
)}
|
||||||
|
{requireUpstream
|
||||||
|
? ""
|
||||||
|
: " (optional)"}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
autoComplete="off"
|
||||||
autoComplete="new-password"
|
placeholder="https://"
|
||||||
value={
|
value={
|
||||||
field.value ??
|
field.value ??
|
||||||
""
|
""
|
||||||
@@ -453,8 +414,131 @@ export default function CreateAiProviderPage() {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<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(
|
{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>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -462,53 +546,68 @@ export default function CreateAiProviderPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsFormCell span="half">
|
<SettingsFormCell span="half">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="skipTlsVerification"
|
name="apiKey"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>
|
||||||
<SwitchInput
|
{t("aiProviderApiKey")}
|
||||||
id="skip-tls"
|
</FormLabel>
|
||||||
label={t(
|
<FormControl>
|
||||||
"aiProviderSkipTlsVerification"
|
<Input
|
||||||
)}
|
type="password"
|
||||||
description={t(
|
autoComplete="new-password"
|
||||||
"aiProviderSkipTlsVerificationDescription"
|
value={
|
||||||
)}
|
field.value ??
|
||||||
checked={
|
""
|
||||||
field.value ??
|
}
|
||||||
false
|
onChange={
|
||||||
}
|
field.onChange
|
||||||
onCheckedChange={
|
}
|
||||||
field.onChange
|
/>
|
||||||
}
|
</FormControl>
|
||||||
/>
|
<FormDescription>
|
||||||
</FormControl>
|
{t(
|
||||||
<FormMessage />
|
"aiProviderApiKeyDescription"
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
</FormDescription>
|
||||||
/>
|
<FormMessage />
|
||||||
</SettingsFormCell>
|
</FormItem>
|
||||||
</SettingsFormGrid>
|
)}
|
||||||
</form>
|
/>
|
||||||
</Form>
|
</SettingsFormCell>
|
||||||
</SettingsSectionForm>
|
</SettingsFormGrid>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionForm>
|
||||||
<SettingsSectionFooter>
|
</SettingsSectionBody>
|
||||||
<Button
|
</SettingsSection>
|
||||||
type="submit"
|
</SettingsContainer>
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
form="create-ai-provider-form"
|
<Button
|
||||||
>
|
type="button"
|
||||||
{t("create")}
|
variant="outline"
|
||||||
</Button>
|
onClick={() =>
|
||||||
</SettingsSectionFooter>
|
router.push(`/${orgId}/settings/ai-providers`)
|
||||||
</SettingsSection>
|
}
|
||||||
</SettingsContainer>
|
>
|
||||||
|
{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 { createApiClient } from "@app/lib/api";
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
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 { build } from "@server/build";
|
||||||
import { type GetResourceResponse } from "@server/routers/resource";
|
import { type GetResourceResponse } from "@server/routers/resource";
|
||||||
import { CreateTargetResponse } from "@server/routers/target";
|
import { CreateTargetResponse } from "@server/routers/target";
|
||||||
@@ -63,9 +63,11 @@ import { ExternalLink, Info, Plus } from "lucide-react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
forwardRef,
|
||||||
useActionState,
|
useActionState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -80,27 +82,52 @@ export type LocalTarget = Omit<
|
|||||||
"protocol"
|
"protocol"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface ProxyResourceTargetsFormProps {
|
export type ProxyResourceTargetsFormHandle = {
|
||||||
|
save: (options?: { silent?: boolean }) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProxyResourceTargetsFormProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
isHttp: boolean;
|
isHttp: boolean;
|
||||||
initialTargets?: LocalTarget[];
|
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;
|
resource?: GetResourceResponse;
|
||||||
|
/** Edit mode for an AI provider: save button + health polling */
|
||||||
|
providerId?: number;
|
||||||
updateResource?: ResourceContextType["updateResource"];
|
updateResource?: ResourceContextType["updateResource"];
|
||||||
/** Create mode: called whenever the targets list changes */
|
/** Create mode: called whenever the targets list changes */
|
||||||
onChange?: (targets: LocalTarget[]) => void;
|
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({
|
export const ProxyResourceTargetsForm = forwardRef<
|
||||||
orgId,
|
ProxyResourceTargetsFormHandle,
|
||||||
isHttp,
|
ProxyResourceTargetsFormProps
|
||||||
initialTargets = [],
|
>(function ProxyResourceTargetsForm(
|
||||||
resource,
|
{
|
||||||
updateResource,
|
orgId,
|
||||||
onChange
|
isHttp,
|
||||||
}: ProxyResourceTargetsFormProps) {
|
initialTargets = [],
|
||||||
|
resource,
|
||||||
|
providerId,
|
||||||
|
updateResource,
|
||||||
|
onChange,
|
||||||
|
allowedMethods = ["http", "https", "h2c"],
|
||||||
|
emptyMessage,
|
||||||
|
embedded = false,
|
||||||
|
hideSaveButton = false
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const isEditMode = !!resource || !!providerId;
|
||||||
|
|
||||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||||
@@ -111,7 +138,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
}, [targets]);
|
}, [targets]);
|
||||||
|
|
||||||
// Poll health status only in edit mode
|
// Poll health status only in edit mode
|
||||||
const { data: polledTargets } = useQuery({
|
const { data: polledResourceTargets } = useQuery({
|
||||||
...resourceQueries.resourceTargets({
|
...resourceQueries.resourceTargets({
|
||||||
resourceId: resource?.resourceId ?? 0
|
resourceId: resource?.resourceId ?? 0
|
||||||
}),
|
}),
|
||||||
@@ -119,6 +146,18 @@ export function ProxyResourceTargetsForm({
|
|||||||
enabled: !!resource
|
enabled: !!resource
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: polledProviderTargets } = useQuery({
|
||||||
|
...aiProviderQueries.providerTargets({
|
||||||
|
providerId: providerId ?? 0
|
||||||
|
}),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
enabled: !!providerId
|
||||||
|
});
|
||||||
|
|
||||||
|
const polledTargets = providerId
|
||||||
|
? polledProviderTargets
|
||||||
|
: polledResourceTargets;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!polledTargets) return;
|
if (!polledTargets) return;
|
||||||
setTargets((prev) =>
|
setTargets((prev) =>
|
||||||
@@ -427,6 +466,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
isHttp={isHttp}
|
isHttp={isHttp}
|
||||||
proxyTarget={row.original}
|
proxyTarget={row.original}
|
||||||
updateTarget={updateTarget}
|
updateTarget={updateTarget}
|
||||||
|
allowedMethods={allowedMethods}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -576,16 +616,22 @@ export function ProxyResourceTargetsForm({
|
|||||||
refreshContainersForSite,
|
refreshContainersForSite,
|
||||||
openHealthCheckDialog,
|
openHealthCheckDialog,
|
||||||
removeTarget,
|
removeTarget,
|
||||||
|
allowedMethods,
|
||||||
t
|
t
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function addNewTarget() {
|
function addNewTarget() {
|
||||||
|
const defaultMethod = providerId
|
||||||
|
? (allowedMethods[0] ?? "https")
|
||||||
|
: isHttp
|
||||||
|
? "http"
|
||||||
|
: null;
|
||||||
const newTarget: LocalTarget = {
|
const newTarget: LocalTarget = {
|
||||||
targetId: -Date.now(),
|
targetId: -Date.now(),
|
||||||
ip: "",
|
ip: "",
|
||||||
mode: ((resource?.mode as LocalTarget["mode"]) ??
|
mode: ((resource?.mode as LocalTarget["mode"]) ??
|
||||||
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
|
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
|
||||||
method: isHttp ? "http" : null,
|
method: defaultMethod,
|
||||||
port: 0,
|
port: 0,
|
||||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||||
siteName: sites.length > 0 ? sites[0].name : "",
|
siteName: sites.length > 0 ? sites[0].name : "",
|
||||||
@@ -595,8 +641,8 @@ export function ProxyResourceTargetsForm({
|
|||||||
rewritePathType: null,
|
rewritePathType: null,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resourceId: resource?.resourceId ?? 0,
|
resourceId: resource?.resourceId ?? null,
|
||||||
providerId: null,
|
providerId: providerId ?? null,
|
||||||
hcEnabled: false,
|
hcEnabled: false,
|
||||||
hcPath: null,
|
hcPath: null,
|
||||||
hcMethod: null,
|
hcMethod: null,
|
||||||
@@ -671,10 +717,16 @@ export function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
}, [isAdvancedMode]);
|
}, [isAdvancedMode]);
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
const [, formAction, isSubmitting] = useActionState(
|
||||||
|
async () => {
|
||||||
|
await saveTargets();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const addTargetButton = (
|
const addTargetButton = (
|
||||||
<Button onClick={addNewTarget} variant="outline">
|
<Button type="button" onClick={addNewTarget} variant="outline">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{t("addTarget")}
|
{t("addTarget")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -682,8 +734,8 @@ export function ProxyResourceTargetsForm({
|
|||||||
|
|
||||||
const hasTargets = targets.length > 0;
|
const hasTargets = targets.length > 0;
|
||||||
|
|
||||||
async function saveTargets() {
|
async function saveTargets(options?: { silent?: boolean }) {
|
||||||
if (!resource) return;
|
if (!isEditMode) return true;
|
||||||
|
|
||||||
const targetsWithInvalidFields = targets.filter(
|
const targetsWithInvalidFields = targets.filter(
|
||||||
(target) =>
|
(target) =>
|
||||||
@@ -699,7 +751,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
title: t("targetErrorInvalidIp"),
|
title: t("targetErrorInvalidIp"),
|
||||||
description: t("targetErrorInvalidIpDescription")
|
description: t("targetErrorInvalidIpDescription")
|
||||||
});
|
});
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -743,9 +795,12 @@ export function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (target.new) {
|
if (target.new) {
|
||||||
|
const createPath = providerId
|
||||||
|
? `/ai-provider/${providerId}/target`
|
||||||
|
: `/resource/${resource!.resourceId}/target`;
|
||||||
const res = await api.put<
|
const res = await api.put<
|
||||||
AxiosResponse<CreateTargetResponse>
|
AxiosResponse<CreateTargetResponse>
|
||||||
>(`/resource/${resource.resourceId}/target`, data);
|
>(createPath, data);
|
||||||
target.targetId = res.data.data.targetId;
|
target.targetId = res.data.data.targetId;
|
||||||
target.new = false;
|
target.new = false;
|
||||||
} else if (target.updated) {
|
} else if (target.updated) {
|
||||||
@@ -754,24 +809,33 @@ export function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
if (!options?.silent) {
|
||||||
title:
|
toast({
|
||||||
targets.length === 0
|
title:
|
||||||
? t("targetTargetsCleared")
|
targets.length === 0
|
||||||
: t("settingsUpdated"),
|
? t("targetTargetsCleared")
|
||||||
description:
|
: t("settingsUpdated"),
|
||||||
targets.length === 0
|
description:
|
||||||
? t("targetTargetsClearedDescription")
|
targets.length === 0
|
||||||
: t("settingsUpdatedDescription")
|
? t("targetTargetsClearedDescription")
|
||||||
});
|
: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTargetsToRemove([]);
|
setTargetsToRemove([]);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
await queryClient.invalidateQueries(
|
if (providerId) {
|
||||||
resourceQueries.resourceTargets({
|
await queryClient.invalidateQueries(
|
||||||
resourceId: resource.resourceId
|
aiProviderQueries.providerTargets({ providerId })
|
||||||
})
|
);
|
||||||
);
|
} else if (resource) {
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
resourceQueries.resourceTargets({
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast({
|
toast({
|
||||||
@@ -782,151 +846,164 @@ export function ProxyResourceTargetsForm({
|
|||||||
t("settingsErrorUpdateDescription")
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSection>
|
{embedded ? (
|
||||||
<SettingsSectionHeader>
|
<div className="space-y-4">{targetsTable}</div>
|
||||||
<SettingsSectionTitle>{t("targets")}</SettingsSectionTitle>
|
) : (
|
||||||
<SettingsSectionDescription>
|
<SettingsSection>
|
||||||
{t("targetsDescription")}
|
<SettingsSectionHeader>
|
||||||
</SettingsSectionDescription>
|
<SettingsSectionTitle>
|
||||||
</SettingsSectionHeader>
|
{t("targets")}
|
||||||
<SettingsSectionBody>
|
</SettingsSectionTitle>
|
||||||
<div className="overflow-x-auto">
|
<SettingsSectionDescription>
|
||||||
<Table>
|
{t("targetsDescription")}
|
||||||
<TableHeader>
|
</SettingsSectionDescription>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
</SettingsSectionHeader>
|
||||||
<TableRow key={headerGroup.id}>
|
<SettingsSectionBody>{targetsTable}</SettingsSectionBody>
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Save button — only shown in edit mode */}
|
{isEditMode && !hideSaveButton && (
|
||||||
{resource && (
|
<form className="self-end mt-4" action={formAction}>
|
||||||
<form className="self-end mt-4" action={formAction}>
|
<Button
|
||||||
<Button
|
disabled={isSubmitting}
|
||||||
disabled={isSubmitting}
|
loading={isSubmitting}
|
||||||
loading={isSubmitting}
|
type="submit"
|
||||||
type="submit"
|
>
|
||||||
>
|
{t("saveResourceTargets")}
|
||||||
{t("saveResourceTargets")}
|
</Button>
|
||||||
</Button>
|
</form>
|
||||||
</form>
|
)}
|
||||||
)}
|
</SettingsSection>
|
||||||
</SettingsSection>
|
)}
|
||||||
|
|
||||||
{selectedTargetForHealthCheck && (
|
{selectedTargetForHealthCheck && (
|
||||||
<HealthCheckCredenza
|
<HealthCheckCredenza
|
||||||
@@ -987,4 +1064,4 @@ export function ProxyResourceTargetsForm({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
TicketCheck,
|
TicketCheck,
|
||||||
Unplug,
|
Unplug,
|
||||||
User,
|
User,
|
||||||
|
UserCheck,
|
||||||
UserCog,
|
UserCog,
|
||||||
Users,
|
Users,
|
||||||
Waypoints
|
Waypoints
|
||||||
@@ -176,7 +177,7 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarApprovals",
|
title: "sidebarApprovals",
|
||||||
href: "/{orgId}/settings/access/approvals",
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarAiProviders",
|
title: "sidebarAiProviders",
|
||||||
@@ -483,7 +484,7 @@ export const commandBarNavSections = (
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "sidebarAi",
|
heading: "sidebarAiGateway",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "commandAiProviders",
|
title: "commandAiProviders",
|
||||||
|
|||||||
@@ -83,20 +83,14 @@ export function AiProviderTypeSelect({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between font-normal h-auto min-h-10 py-2",
|
"w-full justify-between",
|
||||||
|
!selected && "text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left">
|
<span className="truncate text-left">
|
||||||
<span className="truncate">
|
{selected?.title ?? t("noneSelected")}
|
||||||
{selected?.title ?? t("noneSelected")}
|
</span>
|
||||||
</span>
|
|
||||||
{selected?.description && (
|
|
||||||
<span className="text-muted-foreground text-xs leading-snug truncate w-full">
|
|
||||||
{selected.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -133,12 +133,14 @@ export type ResourceTargetAddressItemProps = {
|
|||||||
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
||||||
proxyTarget: LocalTarget;
|
proxyTarget: LocalTarget;
|
||||||
isHttp: boolean;
|
isHttp: boolean;
|
||||||
|
allowedMethods?: ("http" | "https" | "h2c")[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ResourceTargetAddressItem({
|
export function ResourceTargetAddressItem({
|
||||||
updateTarget,
|
updateTarget,
|
||||||
proxyTarget,
|
proxyTarget,
|
||||||
isHttp
|
isHttp,
|
||||||
|
allowedMethods = ["http", "https", "h2c"]
|
||||||
}: ResourceTargetAddressItemProps) {
|
}: ResourceTargetAddressItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center w-full" key={proxyTarget.targetId}>
|
<div className="flex items-center w-full" key={proxyTarget.targetId}>
|
||||||
@@ -157,9 +159,15 @@ export function ResourceTargetAddressItem({
|
|||||||
{proxyTarget.method || "http"}
|
{proxyTarget.method || "http"}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
{allowedMethods.includes("http") && (
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
)}
|
||||||
|
{allowedMethods.includes("https") && (
|
||||||
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
)}
|
||||||
|
{allowedMethods.includes("h2c") && (
|
||||||
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const aiProviderFormSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === "custom" && routingMode === "url" && !data.authType) {
|
if (data.type === "custom" && !data.authType) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "authType is required for custom providers",
|
message: "authType is required for custom providers",
|
||||||
@@ -96,6 +96,18 @@ export const aiProviderFormSchema = z
|
|||||||
|
|
||||||
export type AiProviderFormValues = z.infer<typeof aiProviderFormSchema>;
|
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 {
|
export function emptyUpstreamForType(type: AiProviderType): string {
|
||||||
if (type === "custom") {
|
if (type === "custom") {
|
||||||
return "";
|
return "";
|
||||||
@@ -145,7 +157,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) {
|
|||||||
upstreamUrl,
|
upstreamUrl,
|
||||||
apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined,
|
apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined,
|
||||||
authType:
|
authType:
|
||||||
values.type === "custom" && routingMode === "url"
|
values.type === "custom"
|
||||||
? (values.authType ?? "bearer")
|
? (values.authType ?? "bearer")
|
||||||
: (values.authType ?? undefined),
|
: (values.authType ?? undefined),
|
||||||
skipTlsVerification: values.skipTlsVerification,
|
skipTlsVerification: values.skipTlsVerification,
|
||||||
@@ -176,7 +188,7 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
|
|||||||
routingMode: values.type === "custom" ? routingMode : "url",
|
routingMode: values.type === "custom" ? routingMode : "url",
|
||||||
upstreamUrl,
|
upstreamUrl,
|
||||||
authType:
|
authType:
|
||||||
values.type === "custom" && routingMode === "url"
|
values.type === "custom"
|
||||||
? (values.authType ?? "bearer")
|
? (values.authType ?? "bearer")
|
||||||
: (values.authType ?? null),
|
: (values.authType ?? null),
|
||||||
skipTlsVerification: values.skipTlsVerification ?? false,
|
skipTlsVerification: values.skipTlsVerification ?? false,
|
||||||
@@ -192,6 +204,23 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
|
|||||||
return payload;
|
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) {
|
export function toAiProviderConfigurationPayload(values: AiProviderFormValues) {
|
||||||
const {
|
const {
|
||||||
name: _name,
|
name: _name,
|
||||||
|
|||||||
@@ -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 = {
|
export const resourceQueries = {
|
||||||
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
|
|||||||
Reference in New Issue
Block a user