diff --git a/messages/en-US.json b/messages/en-US.json index 4febc876a..d6759f822 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1632,7 +1632,7 @@ "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Shareable Links", - "sidebarAi": "AI", + "sidebarAiGateway": "AI Gateway", "sidebarAiProviders": "Providers", "commandAiProviders": "AI Providers", "aiProvidersTitle": "AI Providers", @@ -1648,7 +1648,11 @@ "aiProviderGeneral": "General", "aiProviderGeneralDescription": "Basic settings for this provider", "aiProviderConfiguration": "Configuration", - "aiProviderConfigurationDescription": "Upstream URL, routing, authentication, and TLS settings", + "aiProviderConfigurationDescription": "Network routing and authentication for this provider", + "aiProviderNetworkSettings": "Network Settings", + "aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider", + "aiProviderAuthSettings": "Authentication", + "aiProviderAuthSettingsDescription": "Credentials used for both upstream URL and Pangolin target routing", "aiProviderType": "Provider Type", "aiProviderTypeSearch": "Search providers...", "aiProviderTypeNotFound": "No provider type found", @@ -1675,18 +1679,19 @@ "aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider", "aiProviderEffectiveUpstreamUrl": "Effective Upstream URL", "aiProviderApiKey": "API Key", - "aiProviderApiKeyDescription": "Stored encrypted. Leave blank on edit to keep the existing key.", + "aiProviderApiKeyDescription": "API key used to authenticate requests to this provider", "aiProviderApiKeyLastChars": "API Key", "aiProviderAuthType": "Auth Type", "aiProviderAuthTypeBearer": "Bearer", "aiProviderAuthTypeDescription": "How the upstream API authenticates requests", "aiProviderRoutingMode": "Routing Mode", - "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to Pangolin HTTPS targets", + "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites", "aiProviderRoutingModeUrl": "Upstream URL", "aiProviderRoutingModeUrlDescription": "Call a public or private API base URL", - "aiProviderRoutingModeTarget": "Pangolin Targets", + "aiProviderRoutingModeTarget": "Site Targets", "aiProviderRoutingModeTargetDescription": "Route through HTTPS targets on your sites", - "aiProviderRoutingModeTargetNote": "Target configuration will be available in a later update. You can still create this provider now.", + "aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.", + "aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.", "aiProviderSkipTlsVerification": "Skip TLS Verification", "aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection", "aiProviderBudget": "Budget", diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 947810ee1..1d58344cb 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -135,7 +135,9 @@ export async function createAiProvider( .returning(); return response(res, { - data: { provider: toPublicAiProvider(provider) }, + data: { + provider: toPublicAiProvider(provider, { includeApiKey: true }) + }, success: true, error: false, message: "AI provider created successfully", diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts index 32a337c85..28a0152e1 100644 --- a/server/routers/aiProvider/getAiProvider.ts +++ b/server/routers/aiProvider/getAiProvider.ts @@ -67,7 +67,9 @@ export async function getAiProvider( } return response(res, { - data: { provider: toPublicAiProvider(provider) }, + data: { + provider: toPublicAiProvider(provider, { includeApiKey: true }) + }, success: true, error: false, message: "AI provider retrieved successfully", diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 2a6047e23..3fa966785 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -6,8 +6,12 @@ import { type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; export type AiProviderPublic = Omit & { + /** Decrypted API key. Only included on get/create/update of a single provider. */ + apiKey?: string | null; effectiveUpstreamUrl: string | null; effectiveAuthType: AiProviderAuthType | null; }; @@ -36,8 +40,11 @@ export type CreateOrEditAiModelResponse = { model: AiModel; }; -export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { - const { apiKey: _apiKey, ...rest } = provider; +export function toPublicAiProvider( + provider: AiProvider, + options?: { includeApiKey?: boolean } +): AiProviderPublic { + const { apiKey: encryptedApiKey, ...rest } = provider; const resolved = resolveAiProviderConfig({ type: provider.type as AiProviderType, upstreamUrl: provider.upstreamUrl, @@ -45,8 +52,21 @@ export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { routingMode: provider.routingMode as AiProviderRoutingMode | null }); + let apiKey: string | null | undefined; + if (options?.includeApiKey) { + if (encryptedApiKey) { + apiKey = decrypt( + encryptedApiKey, + config.getRawConfig().server.secret! + ); + } else { + apiKey = null; + } + } + return { ...rest, + ...(options?.includeApiKey ? { apiKey } : {}), effectiveUpstreamUrl: resolved.upstreamUrl, effectiveAuthType: resolved.authType }; diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 494e459f6..39787fb93 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -125,7 +125,10 @@ export async function updateAiProvider( ? body.upstreamUrl : existing.upstreamUrl; const nextAuthType = - body.authType !== undefined ? body.authType : existing.authType; + body.authType !== undefined + ? body.authType + : (existing.authType ?? + (providerType === "custom" ? "bearer" : null)); const validation = z .object({ @@ -178,6 +181,13 @@ export async function updateAiProvider( } if (body.authType !== undefined) { updateData.authType = body.authType; + } else if ( + providerType === "custom" && + !existing.authType && + nextAuthType + ) { + // Backfill required authType for custom providers created without one + updateData.authType = nextAuthType; } if (body.apiKey !== undefined) { @@ -193,7 +203,9 @@ export async function updateAiProvider( .returning(); return response(res, { - data: { provider: toPublicAiProvider(provider) }, + data: { + provider: toPublicAiProvider(provider, { includeApiKey: true }) + }, success: true, error: false, message: "AI provider updated successfully", diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index a0b0aa684..c830c08de 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -75,7 +75,7 @@ export function refineProviderUpstreamFields( }); } - if (data.type === "custom" && routingMode === "url" && !data.authType) { + if (data.type === "custom" && !data.authType) { ctx.addIssue({ code: "custom", message: "authType is required for custom providers", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx new file mode 100644 index 000000000..f054c7901 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -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({ + 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 + >( + `/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 ( + + + + + {t("aiProviderAuthSettings")} + + + {t("aiProviderAuthSettingsDescription")} + + + + + +
+ + + {showAuthType && ( + + ( + + + {t( + "aiProviderAuthType" + )} + + + + {t( + "aiProviderAuthTypeDescription" + )} + + + + )} + /> + + )} + + + ( + + + {t("aiProviderApiKey")} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + +
+ +
+
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx index 03a9ab48f..bf003b96e 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx @@ -1,426 +1,12 @@ -"use client"; +import { redirect } from "next/navigation"; -import { - SettingsContainer, - SettingsFormCell, - SettingsFormGrid, - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; -import { StrategySelect } from "@app/components/StrategySelect"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { useAiProviderContext } from "@app/hooks/useAiProviderContext"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { - aiProviderFormSchema, - showsUpstreamUrlField, - toAiProviderConfigurationPayload, - upstreamUrlRequired, - type AiProviderFormValues -} from "@app/lib/aiProviderFormSchema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import type { AiProviderType } from "@server/lib/aiProviderDefaults"; -import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; -import type { AxiosResponse } from "axios"; -import { InfoIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; +type Props = { + params: Promise<{ orgId: string; providerId: string }>; +}; -export default function AiProviderConfigurationPage() { - const { provider, updateProvider } = useAiProviderContext(); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - const router = useRouter(); - const t = useTranslations(); - const [saveLoading, setSaveLoading] = useState(false); - - const form = useForm({ - 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 - >( - `/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 ( - - - - - {t("aiProviderConfiguration")} - - - {t("aiProviderConfigurationDescription")} - - - - - -
- - - {showRoutingMode && ( - - ( - - - {t( - "aiProviderRoutingMode" - )} - - - { - field.onChange( - value - ); - if ( - value === - "target" - ) { - form.setValue( - "upstreamUrl", - "" - ); - } - }} - /> - - - {t( - "aiProviderRoutingModeDescription" - )} - - - - )} - /> - - )} - - {showTargetNote && ( - - - - - {t( - "aiProviderRoutingModeTarget" - )} - - - {t( - "aiProviderRoutingModeTargetNote" - )} - - - - )} - - {showUpstream && ( - - ( - - - {t( - "aiProviderUpstreamUrl" - )} - {requireUpstream - ? "" - : " (optional)"} - - - - - - {requireUpstream - ? t( - "aiProviderUpstreamUrlDescription" - ) - : t( - "aiProviderUpstreamUrlOptionalDescription" - )} - - {provider.effectiveUpstreamUrl && ( - - {t( - "aiProviderEffectiveUpstreamUrl" - )} - {": "} - - { - provider.effectiveUpstreamUrl - } - - - )} - - - )} - /> - - )} - - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - - - ( - - - {t("aiProviderApiKey")} - - - - - - {provider.apiKeyLastChars - ? `••••${provider.apiKeyLastChars}. ${t("aiProviderApiKeyDescription")}` - : t( - "aiProviderApiKeyDescription" - )} - - - - )} - /> - - - - ( - - - - - - - )} - /> - - -
- -
-
- - - -
-
- ); +export default async function AiProviderConfigurationRedirect({ + params +}: Props) { + const { orgId, providerId } = await params; + redirect(`/${orgId}/settings/ai-providers/${providerId}/network`); } diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx index b734f6084..5846ad86d 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx @@ -66,8 +66,12 @@ export default async function AiProviderLayout({ children, params }: Props) { href: "/{orgId}/settings/ai-providers/{providerId}/general" }, { - title: t("aiProviderConfiguration"), - href: "/{orgId}/settings/ai-providers/{providerId}/configuration" + title: t("aiProviderNetworkSettings"), + href: "/{orgId}/settings/ai-providers/{providerId}/network" + }, + { + title: t("aiProviderAuthSettings"), + href: "/{orgId}/settings/ai-providers/{providerId}/authentication" } ]; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx new file mode 100644 index 000000000..90ef74d10 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -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(null); + + const form = useForm({ + 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 + >( + `/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 ( + + + + + {t("aiProviderNetworkSettings")} + + + {t("aiProviderNetworkSettingsDescription")} + + + + + +
+ + + {showRoutingMode && ( + + ( + + + {t( + "aiProviderRoutingMode" + )} + + + { + field.onChange( + value + ); + if ( + value === + "target" + ) { + form.setValue( + "upstreamUrl", + "" + ); + } + }} + /> + + + {t( + "aiProviderRoutingModeDescription" + )} + + + + )} + /> + + )} + + {showUpstream && ( + + ( + + + {t( + "aiProviderUpstreamUrl" + )} + {requireUpstream + ? "" + : " (optional)"} + + + + + + {requireUpstream + ? t( + "aiProviderUpstreamUrlDescription" + ) + : t( + "aiProviderUpstreamUrlOptionalDescription" + )} + + + + )} + /> + + )} + + + ( + + + + + + + )} + /> + + +
+ +
+ + {showTargetsForm && + (!isTargetModeSaved || !isLoadingTargets) && ( +
+ + + {t("targets")} + + + {t("targetsDescription")} + + + +
+ )} +
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 9a9258d8d..ec6eee6c7 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -1,5 +1,9 @@ "use client"; +import { + ProxyResourceTargetsForm, + type LocalTarget +} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm"; import { SettingsContainer, SettingsFormCell, @@ -7,16 +11,17 @@ import { SettingsSection, SettingsSectionBody, SettingsSectionDescription, - SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, - SettingsSectionTitle + SettingsSectionTitle, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Form, @@ -39,7 +44,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderFormSchema, + aiProviderCreateFormSchema, emptyUpstreamForType, showsUpstreamUrlField, toAiProviderCreatePayload, @@ -49,10 +54,9 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; -import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; export default function CreateAiProviderPage() { @@ -63,9 +67,10 @@ export default function CreateAiProviderPage() { const router = useRouter(); const t = useTranslations(); const [loading, setLoading] = useState(false); + const targetsRef = useRef([]); const form = useForm({ - resolver: zodResolver(aiProviderFormSchema), + resolver: zodResolver(aiProviderCreateFormSchema), defaultValues: { name: "", type: "openai", @@ -86,26 +91,101 @@ export default function CreateAiProviderPage() { const showUpstream = showsUpstreamUrlField(providerType, routingMode); const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; - const showAuthType = - providerType === "custom" && (routingMode ?? "url") === "url"; - const showTargetNote = - providerType === "custom" && routingMode === "target"; + const showAuthType = providerType === "custom"; + const showTargets = providerType === "custom" && routingMode === "target"; + + async function createTargets( + providerId: number, + localTargets: LocalTarget[] + ) { + for (const target of localTargets) { + const data = { + ip: target.ip, + port: target.port, + method: target.method, + enabled: target.enabled, + siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcMethod: target.hcMethod || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcFollowRedirects: target.hcFollowRedirects || null, + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null, + hcTlsServerName: target.hcTlsServerName, + hcHealthyThreshold: target.hcHealthyThreshold || null, + hcUnhealthyThreshold: target.hcUnhealthyThreshold || null, + path: target.path, + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType, + priority: target.priority + }; + await api.put(`/ai-provider/${providerId}/target`, data); + } + } async function onSubmit(values: AiProviderFormValues) { + const targets = targetsRef.current; + + if (showTargets) { + const invalidTargets = targets.filter( + (target) => + !target.ip || + target.ip.trim() === "" || + !target.port || + target.port <= 0 || + isNaN(target.port) + ); + if (invalidTargets.length > 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + } + setLoading(true); try { const res = await api.put< AxiosResponse >(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values)); + const providerId = res.data.data.provider.providerId; + + if (showTargets && targets.length > 0) { + try { + await createTargets(providerId, targets); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorCreate"), + description: formatAxiosError( + e, + t("aiProviderErrorCreate") + ) + }); + router.push( + `/${orgId}/settings/ai-providers/${providerId}/network` + ); + return; + } + } + toast({ title: t("success"), description: t("aiProviderCreated") }); - router.push( - `/${orgId}/settings/ai-providers/${res.data.data.provider.providerId}` - ); + router.push(`/${orgId}/settings/ai-providers/${providerId}`); } catch (e) { toast({ variant: "destructive", @@ -134,91 +214,143 @@ export default function CreateAiProviderPage() { - - - - - {t("aiProviderGeneral")} - - - {t("aiProviderGeneralDescription")} - - +
+ + + + + {t("aiProviderGeneral")} + + + {t("aiProviderGeneralDescription")} + + - - - - - + + + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t("aiProviderType")} + + + { + field.onChange( + value + ); + form.setValue( + "upstreamUrl", + emptyUpstreamForType( + value + ) + ); + if ( + value !== + "custom" + ) { + form.setValue( + "routingMode", + "url" + ); + targetsRef.current = + []; + } + }} + /> + + + + )} + /> + + + + + + + + + + {t("aiProviderNetworkSettings")} + + + {t("aiProviderNetworkSettingsDescription")} + + + + + + + {showRoutingMode && ( ( - - - - - - - )} - /> - - - - ( - - - {t("name")} - - - - - - - )} - /> - - - - ( {t( - "aiProviderType" + "aiProviderRoutingMode" )} - + + {t( + "aiProviderRoutingModeDescription" + )} + )} /> + )} - {showRoutingMode && ( - - ( - - - {t( - "aiProviderRoutingMode" - )} - - - { - field.onChange( - value - ); - if ( - value === - "target" - ) { - form.setValue( - "upstreamUrl", - "" - ); - } - }} - /> - - - {t( - "aiProviderRoutingModeDescription" - )} - - - - )} - /> - - )} - - {showTargetNote && ( - - - - - {t( - "aiProviderRoutingModeTarget" - )} - - - {t( - "aiProviderRoutingModeTargetNote" - )} - - - - )} - - {showUpstream && ( - - ( - - - {t( - "aiProviderUpstreamUrl" - )} - {requireUpstream - ? "" - : " (optional)"} - - - - - - {requireUpstream - ? t( - "aiProviderUpstreamUrlDescription" - ) - : t( - "aiProviderUpstreamUrlOptionalDescription" - )} - - - - )} - /> - - )} - - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - + {showUpstream && ( ( {t( - "aiProviderApiKey" + "aiProviderUpstreamUrl" )} + {requireUpstream + ? "" + : " (optional)"} + {requireUpstream + ? t( + "aiProviderUpstreamUrlDescription" + ) + : t( + "aiProviderUpstreamUrlOptionalDescription" + )} + + + + )} + /> + + )} + + + ( + + + + + + + )} + /> + + + + + {showTargets && ( +
+ + + {t("targets")} + + + {t("targetsDescription")} + + + { + targetsRef.current = nextTargets; + }} + allowedMethods={["http", "https"]} + emptyMessage={t( + "aiProviderTargetNoOne" + )} + embedded + hideSaveButton + /> +
+ )} +
+
+ + + + + {t("aiProviderAuthSettings")} + + + {t("aiProviderAuthSettingsDescription")} + + + + + + + {showAuthType && ( + + ( + + {t( - "aiProviderApiKeyDescription" + "aiProviderAuthType" + )} + + + + {t( + "aiProviderAuthTypeDescription" )} @@ -462,53 +546,68 @@ export default function CreateAiProviderPage() { )} /> + )} - - ( - - - - - - - )} - /> - - - - - - - - - - -
+ + ( + + + {t("aiProviderApiKey")} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + + + +
+
+ +
+ + +
+ ); } diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 0db4cb156..db3bec315 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -42,7 +42,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { DockerManager, DockerState } from "@app/lib/docker"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { type GetResourceResponse } from "@server/routers/resource"; import { CreateTargetResponse } from "@server/routers/target"; @@ -63,9 +63,11 @@ import { ExternalLink, Info, Plus } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { + forwardRef, useActionState, useCallback, useEffect, + useImperativeHandle, useMemo, useState } from "react"; @@ -80,27 +82,52 @@ export type LocalTarget = Omit< "protocol" >; -interface ProxyResourceTargetsFormProps { +export type ProxyResourceTargetsFormHandle = { + save: (options?: { silent?: boolean }) => Promise; +}; + +type ProxyResourceTargetsFormProps = { orgId: string; isHttp: boolean; initialTargets?: LocalTarget[]; - /** Edit mode: when provided, shows a save button and polls for health status */ + /** Edit mode for a public resource: save button + health polling */ resource?: GetResourceResponse; + /** Edit mode for an AI provider: save button + health polling */ + providerId?: number; updateResource?: ResourceContextType["updateResource"]; /** Create mode: called whenever the targets list changes */ onChange?: (targets: LocalTarget[]) => void; -} + /** HTTP method options for address selector. Defaults to http/https/h2c. */ + allowedMethods?: ("http" | "https" | "h2c")[]; + emptyMessage?: string; + /** Render table without its own SettingsSection wrapper */ + embedded?: boolean; + /** Hide the built-in save button (use ref.save from parent) */ + hideSaveButton?: boolean; +}; -export function ProxyResourceTargetsForm({ - orgId, - isHttp, - initialTargets = [], - resource, - updateResource, - onChange -}: ProxyResourceTargetsFormProps) { +export const ProxyResourceTargetsForm = forwardRef< + ProxyResourceTargetsFormHandle, + ProxyResourceTargetsFormProps +>(function ProxyResourceTargetsForm( + { + orgId, + isHttp, + initialTargets = [], + resource, + providerId, + updateResource, + onChange, + allowedMethods = ["http", "https", "h2c"], + emptyMessage, + embedded = false, + hideSaveButton = false + }, + ref +) { const t = useTranslations(); const api = createApiClient(useEnvContext()); + const isEditMode = !!resource || !!providerId; const [targets, setTargets] = useState(initialTargets); const [targetsToRemove, setTargetsToRemove] = useState([]); @@ -111,7 +138,7 @@ export function ProxyResourceTargetsForm({ }, [targets]); // Poll health status only in edit mode - const { data: polledTargets } = useQuery({ + const { data: polledResourceTargets } = useQuery({ ...resourceQueries.resourceTargets({ resourceId: resource?.resourceId ?? 0 }), @@ -119,6 +146,18 @@ export function ProxyResourceTargetsForm({ enabled: !!resource }); + const { data: polledProviderTargets } = useQuery({ + ...aiProviderQueries.providerTargets({ + providerId: providerId ?? 0 + }), + refetchInterval: 10_000, + enabled: !!providerId + }); + + const polledTargets = providerId + ? polledProviderTargets + : polledResourceTargets; + useEffect(() => { if (!polledTargets) return; setTargets((prev) => @@ -427,6 +466,7 @@ export function ProxyResourceTargetsForm({ isHttp={isHttp} proxyTarget={row.original} updateTarget={updateTarget} + allowedMethods={allowedMethods} /> ); }, @@ -576,16 +616,22 @@ export function ProxyResourceTargetsForm({ refreshContainersForSite, openHealthCheckDialog, removeTarget, + allowedMethods, t ]); function addNewTarget() { + const defaultMethod = providerId + ? (allowedMethods[0] ?? "https") + : isHttp + ? "http" + : null; const newTarget: LocalTarget = { targetId: -Date.now(), ip: "", mode: ((resource?.mode as LocalTarget["mode"]) ?? (isHttp ? "http" : "tcp")) as LocalTarget["mode"], - method: isHttp ? "http" : null, + method: defaultMethod, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, siteName: sites.length > 0 ? sites[0].name : "", @@ -595,8 +641,8 @@ export function ProxyResourceTargetsForm({ rewritePathType: null, priority: 100, enabled: true, - resourceId: resource?.resourceId ?? 0, - providerId: null, + resourceId: resource?.resourceId ?? null, + providerId: providerId ?? null, hcEnabled: false, hcPath: null, hcMethod: null, @@ -671,10 +717,16 @@ export function ProxyResourceTargetsForm({ } }, [isAdvancedMode]); - const [, formAction, isSubmitting] = useActionState(saveTargets, null); + const [, formAction, isSubmitting] = useActionState( + async () => { + await saveTargets(); + return null; + }, + null + ); const addTargetButton = ( - @@ -682,8 +734,8 @@ export function ProxyResourceTargetsForm({ const hasTargets = targets.length > 0; - async function saveTargets() { - if (!resource) return; + async function saveTargets(options?: { silent?: boolean }) { + if (!isEditMode) return true; const targetsWithInvalidFields = targets.filter( (target) => @@ -699,7 +751,7 @@ export function ProxyResourceTargetsForm({ title: t("targetErrorInvalidIp"), description: t("targetErrorInvalidIpDescription") }); - return; + return false; } try { @@ -743,9 +795,12 @@ export function ProxyResourceTargetsForm({ } if (target.new) { + const createPath = providerId + ? `/ai-provider/${providerId}/target` + : `/resource/${resource!.resourceId}/target`; const res = await api.put< AxiosResponse - >(`/resource/${resource.resourceId}/target`, data); + >(createPath, data); target.targetId = res.data.data.targetId; target.new = false; } else if (target.updated) { @@ -754,24 +809,33 @@ export function ProxyResourceTargetsForm({ } } - toast({ - title: - targets.length === 0 - ? t("targetTargetsCleared") - : t("settingsUpdated"), - description: - targets.length === 0 - ? t("targetTargetsClearedDescription") - : t("settingsUpdatedDescription") - }); + if (!options?.silent) { + toast({ + title: + targets.length === 0 + ? t("targetTargetsCleared") + : t("settingsUpdated"), + description: + targets.length === 0 + ? t("targetTargetsClearedDescription") + : t("settingsUpdatedDescription") + }); + } setTargetsToRemove([]); router.refresh(); - await queryClient.invalidateQueries( - resourceQueries.resourceTargets({ - resourceId: resource.resourceId - }) - ); + if (providerId) { + await queryClient.invalidateQueries( + aiProviderQueries.providerTargets({ providerId }) + ); + } else if (resource) { + await queryClient.invalidateQueries( + resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }) + ); + } + return true; } catch (err) { console.error(err); toast({ @@ -782,151 +846,164 @@ export function ProxyResourceTargetsForm({ t("settingsErrorUpdateDescription") ) }); + return false; } } + useImperativeHandle(ref, () => ({ + save: saveTargets + })); + + const advancedModeToggleId = providerId + ? `advanced-mode-toggle-provider-${providerId}` + : resource + ? `advanced-mode-toggle-resource-${resource.resourceId}` + : "advanced-mode-toggle"; + + const targetsTable = ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + const isSiteColumn = + header.column.id === "site"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + const isSiteColumn = + cell.column.id === "site"; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + )} + +
+
+ {hasTargets && ( +
+
+ {addTargetButton} +
+ + +
+
+
+ )} + {build === "saas" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +

+ {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} + + {t("learnMore")} + + + . +

+ )} + + ); + return ( <> - - - {t("targets")} - - {t("targetsDescription")} - - - -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isActionsColumn = - header.column.id === "actions"; - const isSiteColumn = - header.column.id === "site"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef - .header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const isActionsColumn = - cell.column.id === - "actions"; - const isSiteColumn = - cell.column.id === - "site"; - return ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - )} - -
-
- {hasTargets && ( -
-
- {addTargetButton} -
- - -
-
-
- )} - {build === "saas" && - targets.length > 1 && - new Set(targets.map((t) => t.siteId)).size > 1 && ( -

- {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} - - {t("learnMore")} - - - . -

- )} -
+ {embedded ? ( +
{targetsTable}
+ ) : ( + + + + {t("targets")} + + + {t("targetsDescription")} + + + {targetsTable} - {/* Save button — only shown in edit mode */} - {resource && ( -
- -
- )} -
+ {isEditMode && !hideSaveButton && ( +
+ +
+ )} +
+ )} {selectedTargetForHealthCheck && ( ); -} +}); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index cb91bd734..e6971bc94 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -31,6 +31,7 @@ import { TicketCheck, Unplug, User, + UserCheck, UserCog, Users, Waypoints @@ -176,7 +177,7 @@ export const orgNavSections = ( { title: "sidebarApprovals", href: "/{orgId}/settings/access/approvals", - icon: + icon: } ] : []), @@ -188,7 +189,7 @@ export const orgNavSections = ( ] }, { - heading: "sidebarAi", + heading: "sidebarAiGateway", items: [ { title: "sidebarAiProviders", @@ -483,7 +484,7 @@ export const commandBarNavSections = ( ] }, { - heading: "sidebarAi", + heading: "sidebarAiGateway", items: [ { title: "commandAiProviders", diff --git a/src/components/AiProviderTypeSelect.tsx b/src/components/AiProviderTypeSelect.tsx index 72e076f53..ed0565f53 100644 --- a/src/components/AiProviderTypeSelect.tsx +++ b/src/components/AiProviderTypeSelect.tsx @@ -83,20 +83,14 @@ export function AiProviderTypeSelect({ aria-expanded={open} disabled={disabled} className={cn( - "w-full justify-between font-normal h-auto min-h-10 py-2", + "w-full justify-between", + !selected && "text-muted-foreground", className )} > -
- - {selected?.title ?? t("noneSelected")} - - {selected?.description && ( - - {selected.description} - - )} -
+ + {selected?.title ?? t("noneSelected")} + diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 68acec7f1..58ef3b33b 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -133,12 +133,14 @@ export type ResourceTargetAddressItemProps = { updateTarget: (targetId: number, data: Partial) => void; proxyTarget: LocalTarget; isHttp: boolean; + allowedMethods?: ("http" | "https" | "h2c")[]; }; export function ResourceTargetAddressItem({ updateTarget, proxyTarget, - isHttp + isHttp, + allowedMethods = ["http", "https", "h2c"] }: ResourceTargetAddressItemProps) { return (
@@ -157,9 +159,15 @@ export function ResourceTargetAddressItem({ {proxyTarget.method || "http"} - http - https - h2c + {allowedMethods.includes("http") && ( + http + )} + {allowedMethods.includes("https") && ( + https + )} + {allowedMethods.includes("h2c") && ( + h2c + )} )} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 184be14c9..8861dcdbf 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -71,7 +71,7 @@ export const aiProviderFormSchema = z }); } - if (data.type === "custom" && routingMode === "url" && !data.authType) { + if (data.type === "custom" && !data.authType) { ctx.addIssue({ code: "custom", message: "authType is required for custom providers", @@ -96,6 +96,18 @@ export const aiProviderFormSchema = z export type AiProviderFormValues = z.infer; +export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( + (data, ctx) => { + if (!data.apiKey?.trim()) { + ctx.addIssue({ + code: "custom", + message: "API key is required", + path: ["apiKey"] + }); + } + } +); + export function emptyUpstreamForType(type: AiProviderType): string { if (type === "custom") { return ""; @@ -145,7 +157,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { upstreamUrl, apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, authType: - values.type === "custom" && routingMode === "url" + values.type === "custom" ? (values.authType ?? "bearer") : (values.authType ?? undefined), skipTlsVerification: values.skipTlsVerification, @@ -176,7 +188,7 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { routingMode: values.type === "custom" ? routingMode : "url", upstreamUrl, authType: - values.type === "custom" && routingMode === "url" + values.type === "custom" ? (values.authType ?? "bearer") : (values.authType ?? null), skipTlsVerification: values.skipTlsVerification ?? false, @@ -192,6 +204,23 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { return payload; } +export function toAiProviderNetworkPayload(values: AiProviderFormValues) { + const full = toAiProviderUpdatePayload(values); + return { + routingMode: full.routingMode, + upstreamUrl: full.upstreamUrl, + skipTlsVerification: full.skipTlsVerification + }; +} + +export function toAiProviderAuthPayload(values: AiProviderFormValues) { + const full = toAiProviderUpdatePayload(values); + return { + authType: full.authType, + ...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {}) + }; +} + export function toAiProviderConfigurationPayload(values: AiProviderFormValues) { const { name: _name, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 96024c3a0..bdc2f413a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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 + >(`/ai-provider/${providerId}/targets`, { signal }); + + return res.data.data.targets; + } + }) +}; + export const resourceQueries = { resourceUsers: ({ resourceId }: { resourceId: number }) => queryOptions({