From 0d9a01a066df1d07b4ae7c576a483a10efbe883d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 3 Aug 2026 10:36:42 -0400 Subject: [PATCH] providers table, create, and edit first pass --- messages/en-US.json | 77 +++ .../[providerId]/configuration/page.tsx | 426 +++++++++++++++ .../[providerId]/general/page.tsx | 180 ++++++ .../ai-providers/[providerId]/layout.tsx | 90 +++ .../ai-providers/[providerId]/page.tsx | 10 + .../settings/ai-providers/create/layout.tsx | 13 + .../settings/ai-providers/create/page.tsx | 514 ++++++++++++++++++ .../[orgId]/settings/ai-providers/page.tsx | 71 +++ src/app/navigation.tsx | 21 + src/components/AiProviderTypeSelect.tsx | 147 +++++ src/components/AiProvidersTable.tsx | 317 +++++++++++ src/components/HorizontalTabs.tsx | 1 + src/contexts/aiProviderContext.ts | 13 + src/hooks/useAiProviderContext.ts | 12 + src/lib/aiProviderFormSchema.ts | 202 +++++++ src/providers/AiProviderProvider.tsx | 47 ++ 16 files changed, 2141 insertions(+) create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/create/layout.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/create/page.tsx create mode 100644 src/app/[orgId]/settings/ai-providers/page.tsx create mode 100644 src/components/AiProviderTypeSelect.tsx create mode 100644 src/components/AiProvidersTable.tsx create mode 100644 src/contexts/aiProviderContext.ts create mode 100644 src/hooks/useAiProviderContext.ts create mode 100644 src/lib/aiProviderFormSchema.ts create mode 100644 src/providers/AiProviderProvider.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 94dd49f96..4febc876a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1632,6 +1632,83 @@ "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Shareable Links", + "sidebarAi": "AI", + "sidebarAiProviders": "Providers", + "commandAiProviders": "AI Providers", + "aiProvidersTitle": "AI Providers", + "aiProvidersDescription": "Connect model providers for AI workloads in this organization", + "aiProvidersAdd": "Add Provider", + "aiProvidersSearch": "Search providers...", + "aiProvidersEmpty": "No AI providers yet", + "aiProviderCreate": "Create AI Provider", + "aiProviderCreateDescription": "Add a model provider for this organization", + "aiProviderSeeAll": "See All Providers", + "aiProviderSetting": "Provider Settings for {providerName}", + "aiProviderSettingDescription": "Configure this AI provider", + "aiProviderGeneral": "General", + "aiProviderGeneralDescription": "Basic settings for this provider", + "aiProviderConfiguration": "Configuration", + "aiProviderConfigurationDescription": "Upstream URL, routing, authentication, and TLS settings", + "aiProviderType": "Provider Type", + "aiProviderTypeSearch": "Search providers...", + "aiProviderTypeNotFound": "No provider type found", + "aiProviderTypeOpenai": "OpenAI", + "aiProviderTypeAnthropic": "Anthropic", + "aiProviderTypeGoogleGemini": "Google Gemini", + "aiProviderTypeVertexAi": "Vertex AI", + "aiProviderTypeBedrock": "Amazon Bedrock", + "aiProviderTypeMicrosoftFoundry": "Microsoft Foundry", + "aiProviderTypeOpenRouter": "OpenRouter", + "aiProviderTypeVercelAiGateway": "Vercel AI Gateway", + "aiProviderTypeCustom": "Custom", + "aiProviderTypeOpenaiDescription": "OpenAI API with default upstream URL", + "aiProviderTypeAnthropicDescription": "Anthropic API with default upstream URL", + "aiProviderTypeGoogleGeminiDescription": "Google Gemini OpenAI-compatible endpoint", + "aiProviderTypeVertexAiDescription": "Google Vertex AI; upstream URL required", + "aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime", + "aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry; upstream URL required", + "aiProviderTypeOpenRouterDescription": "OpenRouter API", + "aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway", + "aiProviderTypeCustomDescription": "Bring your own OpenAI-compatible endpoint or route via Pangolin targets", + "aiProviderUpstreamUrl": "Upstream URL", + "aiProviderUpstreamUrlDescription": "Base URL for the provider API", + "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.", + "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", + "aiProviderRoutingModeUrl": "Upstream URL", + "aiProviderRoutingModeUrlDescription": "Call a public or private API base URL", + "aiProviderRoutingModeTarget": "Pangolin 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.", + "aiProviderSkipTlsVerification": "Skip TLS Verification", + "aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection", + "aiProviderBudget": "Budget", + "aiProviderBudgetDescription": "Optional spending or token budget for this provider", + "aiProviderBudgetAmount": "Budget Amount", + "aiProviderBudgetUnit": "Budget Unit", + "aiProviderBudgetUnitUsd": "USD", + "aiProviderBudgetUnitTokens": "Tokens", + "aiProviderEnabled": "Enabled", + "aiProviderEnabledDescription": "Disable to stop using this provider without deleting it", + "aiProviderErrorCreate": "Failed to create AI provider", + "aiProviderErrorUpdate": "Failed to update AI provider", + "aiProviderErrorDelete": "Failed to delete AI provider", + "aiProviderErrorLoad": "Failed to load AI provider", + "aiProviderCreated": "AI provider created", + "aiProviderUpdated": "AI provider updated", + "aiProviderDeleted": "AI provider deleted", + "aiProviderDelete": "Delete Provider", + "aiProviderDeleteConfirm": "Delete Provider", + "aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?", + "aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.", + "aiProviderErrorNoUpdate": "AI provider is not available to update", "sidebarApiKeys": "API Keys", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx new file mode 100644 index 000000000..03a9ab48f --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx @@ -0,0 +1,426 @@ +"use client"; + +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"; + +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" + )} + + + + )} + /> + + + + ( + + + + + + + )} + /> + + +
+ +
+
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx new file mode 100644 index 000000000..99db2eda8 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + 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 { zodResolver } from "@hookform/resolvers/zod"; +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"; +import { z } from "zod"; + +const generalSchema = z.object({ + name: z.string().trim().min(1), + enabled: z.boolean() +}); + +type GeneralFormValues = z.infer; + +export default function AiProviderGeneralPage() { + 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(generalSchema), + defaultValues: { + name: provider.name, + enabled: provider.enabled + } + }); + + async function onSubmit(values: GeneralFormValues) { + setSaveLoading(true); + try { + const res = await api.post< + AxiosResponse + >(`/ai-provider/${provider.providerId}`, { + name: values.name.trim(), + enabled: values.enabled + }); + const updated = res.data.data.provider; + updateProvider(updated); + form.reset({ + name: updated.name, + 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("aiProviderGeneral")} + + + {t("aiProviderGeneralDescription")} + + + + + +
+ + + + ( + + + + + + + )} + /> + + + + ( + + + {t("name")} + + + + + + + )} + /> + + +
+ +
+
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx new file mode 100644 index 000000000..b734f6084 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/layout.tsx @@ -0,0 +1,90 @@ +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import OrgProvider from "@app/providers/OrgProvider"; +import AiProviderProvider from "@app/providers/AiProviderProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export const metadata: Metadata = { + title: "AI Provider" +}; + +export const dynamic = "force-dynamic"; + +type Props = { + children: React.ReactNode; + params: Promise<{ orgId: string; providerId: string }>; +}; + +export default async function AiProviderLayout({ children, params }: Props) { + const { orgId, providerId } = await params; + const t = await getTranslations(); + + let provider = null; + try { + const res = await internal.get>( + `/ai-provider/${providerId}`, + await authCookieHeader() + ); + provider = res.data.data.provider; + } catch { + redirect(`/${orgId}/settings/ai-providers`); + } + + if (!provider || provider.orgId !== orgId) { + redirect(`/${orgId}/settings/ai-providers`); + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${orgId}`, + await authCookieHeader() + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${orgId}/settings/ai-providers`); + } + + if (!org) { + redirect(`/${orgId}/settings/ai-providers`); + } + + const navItems = [ + { + title: t("general"), + href: "/{orgId}/settings/ai-providers/{providerId}/general" + }, + { + title: t("aiProviderConfiguration"), + href: "/{orgId}/settings/ai-providers/{providerId}/configuration" + } + ]; + + return ( + <> + + + + + {children} + + + + ); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx new file mode 100644 index 000000000..2daca1227 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +type Props = { + params: Promise<{ orgId: string; providerId: string }>; +}; + +export default async function AiProviderPage({ params }: Props) { + const { orgId, providerId } = await params; + redirect(`/${orgId}/settings/ai-providers/${providerId}/general`); +} diff --git a/src/app/[orgId]/settings/ai-providers/create/layout.tsx b/src/app/[orgId]/settings/ai-providers/create/layout.tsx new file mode 100644 index 000000000..f33dcd80d --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/create/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create AI Provider" +}; + +export default function CreateAiProviderLayout({ + children +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx new file mode 100644 index 000000000..9a9258d8d --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} 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, + 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 { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + aiProviderFormSchema, + emptyUpstreamForType, + showsUpstreamUrlField, + toAiProviderCreatePayload, + upstreamUrlRequired, + type AiProviderFormValues +} from "@app/lib/aiProviderFormSchema"; +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 { useForm } from "react-hook-form"; + +export default function CreateAiProviderPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const params = useParams(); + const orgId = params.orgId as string; + const router = useRouter(); + const t = useTranslations(); + const [loading, setLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(aiProviderFormSchema), + defaultValues: { + name: "", + type: "openai", + upstreamUrl: emptyUpstreamForType("openai"), + apiKey: "", + authType: "bearer", + routingMode: "url", + skipTlsVerification: false, + budgetAmount: null, + budgetUnit: null, + enabled: true + } + }); + + 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) { + setLoading(true); + try { + const res = await api.put< + AxiosResponse + >(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values)); + + toast({ + title: t("success"), + description: t("aiProviderCreated") + }); + + router.push( + `/${orgId}/settings/ai-providers/${res.data.data.provider.providerId}` + ); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorCreate"), + description: formatAxiosError(e, t("aiProviderErrorCreate")) + }); + } finally { + setLoading(false); + } + } + + return ( + <> +
+ + +
+ + + + + + {t("aiProviderGeneral")} + + + {t("aiProviderGeneralDescription")} + + + + + +
+ + + + ( + + + + + + + )} + /> + + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + ( + + + {t( + "aiProviderType" + )} + + + { + field.onChange( + value + ); + form.setValue( + "upstreamUrl", + emptyUpstreamForType( + value + ) + ); + if ( + value !== + "custom" + ) { + form.setValue( + "routingMode", + "url" + ); + } + }} + /> + + + + )} + /> + + + {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" + )} + + + + )} + /> + + )} + + + ( + + + {t( + "aiProviderApiKey" + )} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + + + ( + + + + + + + )} + /> + + +
+ +
+
+ + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/ai-providers/page.tsx b/src/app/[orgId]/settings/ai-providers/page.tsx new file mode 100644 index 000000000..9858641e2 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/page.tsx @@ -0,0 +1,71 @@ +import AiProvidersTable from "@app/components/AiProvidersTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types"; +import type { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export const metadata: Metadata = { + title: "AI Providers" +}; + +export const dynamic = "force-dynamic"; + +type Props = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export default async function AiProvidersPage({ params, searchParams }: Props) { + const { orgId } = await params; + const searchParamsObj = new URLSearchParams(await searchParams); + const t = await getTranslations(); + + let providers: ListAiProvidersResponse["providers"] = []; + let pagination: ListAiProvidersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${orgId}/ai-providers?${searchParamsObj.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + providers = responseData.providers; + pagination = responseData.pagination; + } catch { + // empty list on error + } + + return ( + <> + + + ({ + providerId: provider.providerId, + name: provider.name, + type: provider.type, + routingMode: provider.routingMode, + enabled: provider.enabled, + effectiveUpstreamUrl: provider.effectiveUpstreamUrl, + apiKeyLastChars: provider.apiKeyLastChars + }))} + rowCount={pagination.total} + pagination={{ + pageIndex: pagination.page - 1, + pageSize: pagination.pageSize + }} + /> + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index b0650ce05..cb91bd734 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -25,6 +25,7 @@ import { Server, Settings, ShieldIcon, + Sparkles, SquareMousePointer, TagIcon, TicketCheck, @@ -186,6 +187,16 @@ export const orgNavSections = ( } ] }, + { + heading: "sidebarAi", + items: [ + { + title: "sidebarAiProviders", + href: "/{orgId}/settings/ai-providers", + icon: + } + ] + }, { heading: "sidebarOrganization", items: [ @@ -471,6 +482,16 @@ export const commandBarNavSections = ( } ] }, + { + heading: "sidebarAi", + items: [ + { + title: "commandAiProviders", + href: "/{orgId}/settings/ai-providers", + icon: + } + ] + }, { heading: "commandLogsAndAnalytics", items: [ diff --git a/src/components/AiProviderTypeSelect.tsx b/src/components/AiProviderTypeSelect.tsx new file mode 100644 index 000000000..72e076f53 --- /dev/null +++ b/src/components/AiProviderTypeSelect.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; + +const typeLabelMap = { + openai: "aiProviderTypeOpenai", + anthropic: "aiProviderTypeAnthropic", + googleGemini: "aiProviderTypeGoogleGemini", + vertexAi: "aiProviderTypeVertexAi", + bedrock: "aiProviderTypeBedrock", + microsoftFoundry: "aiProviderTypeMicrosoftFoundry", + openRouter: "aiProviderTypeOpenRouter", + vercelAiGateway: "aiProviderTypeVercelAiGateway", + custom: "aiProviderTypeCustom" +} as const; + +const typeDescriptionMap = { + openai: "aiProviderTypeOpenaiDescription", + anthropic: "aiProviderTypeAnthropicDescription", + googleGemini: "aiProviderTypeGoogleGeminiDescription", + vertexAi: "aiProviderTypeVertexAiDescription", + bedrock: "aiProviderTypeBedrockDescription", + microsoftFoundry: "aiProviderTypeMicrosoftFoundryDescription", + openRouter: "aiProviderTypeOpenRouterDescription", + vercelAiGateway: "aiProviderTypeVercelAiGatewayDescription", + custom: "aiProviderTypeCustomDescription" +} as const; + +type AiProviderTypeSelectProps = { + value: AiProviderType; + onChange: (value: AiProviderType) => void; + disabled?: boolean; + className?: string; +}; + +export function AiProviderTypeSelect({ + value, + onChange, + disabled, + className +}: AiProviderTypeSelectProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + + const options = useMemo( + () => + aiProviderTypeValues.map((type) => ({ + type, + title: t(typeLabelMap[type]), + description: t(typeDescriptionMap[type]) + })), + [t] + ); + + const selected = options.find((option) => option.type === value); + + return ( + + + + + + + + + + {t("aiProviderTypeNotFound")} + + + {options.map((option) => ( + { + onChange(option.type); + setOpen(false); + }} + > + +
+ + {option.title} + + + {option.description} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/AiProvidersTable.tsx b/src/components/AiProvidersTable.tsx new file mode 100644 index 000000000..ba7fbb00c --- /dev/null +++ b/src/components/AiProvidersTable.tsx @@ -0,0 +1,317 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { PaginationState } from "@tanstack/react-table"; +import { ArrowRight, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; + +export type AiProviderRow = { + providerId: number; + name: string; + type: string; + routingMode: string; + enabled: boolean; + effectiveUpstreamUrl: string | null; + apiKeyLastChars: string | null; +}; + +type AiProvidersTableProps = { + providers: AiProviderRow[]; + orgId: string; + pagination: PaginationState; + rowCount: number; +}; + +export default function AiProvidersTable({ + providers, + orgId, + pagination, + rowCount +}: AiProvidersTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [rows, setRows] = useState(providers); + const [selected, setSelected] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + + useEffect(() => { + setRows(providers); + }, [providers]); + + function refreshData() { + startTransition(() => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + + function typeLabel(type: string) { + const key = `aiProviderType${type.charAt(0).toUpperCase()}${type.slice(1)}`; + const map: Record = { + openai: "aiProviderTypeOpenai", + anthropic: "aiProviderTypeAnthropic", + googleGemini: "aiProviderTypeGoogleGemini", + vertexAi: "aiProviderTypeVertexAi", + bedrock: "aiProviderTypeBedrock", + microsoftFoundry: "aiProviderTypeMicrosoftFoundry", + openRouter: "aiProviderTypeOpenRouter", + vercelAiGateway: "aiProviderTypeVercelAiGateway", + custom: "aiProviderTypeCustom" + }; + return t(map[type] ?? key); + } + + function routingLabel(mode: string) { + return mode === "target" + ? t("aiProviderRoutingModeTarget") + : t("aiProviderRoutingModeUrl"); + } + + async function toggleEnabled(row: AiProviderRow, enabled: boolean) { + setRows((prev) => + prev.map((r) => + r.providerId === row.providerId ? { ...r, enabled } : r + ) + ); + + try { + await api.post(`/ai-provider/${row.providerId}`, { enabled }); + toast({ + title: t("success"), + description: t("aiProviderUpdated") + }); + router.refresh(); + } catch (e) { + setRows((prev) => + prev.map((r) => + r.providerId === row.providerId + ? { ...r, enabled: row.enabled } + : r + ) + ); + toast({ + variant: "destructive", + title: t("aiProviderErrorUpdate"), + description: formatAxiosError(e, t("aiProviderErrorUpdate")) + }); + } + } + + function deleteProvider(row: AiProviderRow) { + startTransition(async () => { + try { + await api.delete(`/ai-provider/${row.providerId}`); + setRows((prev) => + prev.filter((r) => r.providerId !== row.providerId) + ); + setIsDeleteModalOpen(false); + setSelected(null); + toast({ + title: t("success"), + description: t("aiProviderDeleted") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("aiProviderErrorDelete"), + description: formatAxiosError(e, t("aiProviderErrorDelete")) + }); + } + }); + } + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + enableHiding: false, + header: () => {t("name")}, + cell: ({ row }) => ( + + {row.original.name} + + ) + }, + { + accessorKey: "type", + header: () => ( + {t("aiProviderType")} + ), + cell: ({ row }) => typeLabel(row.original.type) + }, + { + accessorKey: "routingMode", + header: () => ( + {t("aiProviderRoutingMode")} + ), + cell: ({ row }) => routingLabel(row.original.routingMode) + }, + { + accessorKey: "effectiveUpstreamUrl", + header: () => ( + {t("aiProviderUpstreamUrl")} + ), + cell: ({ row }) => ( + {row.original.effectiveUpstreamUrl ?? "-"} + ) + }, + { + accessorKey: "enabled", + header: () => ( + {t("aiProviderEnabled")} + ), + cell: ({ row }) => ( + + toggleEnabled(row.original, checked) + } + /> + ) + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => ( +
+ + + + + + + + {t("edit")} + + + { + setSelected(row.original); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ) + } + ], + [orgId, t] + ); + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + if (!val) { + setSelected(null); + } + }} + dialog={ +
+

{t("aiProviderQuestionRemove")}

+

{t("aiProviderMessageRemove")}

+
+ } + buttonText={t("aiProviderDeleteConfirm")} + onConfirm={async () => deleteProvider(selected)} + string={selected.name} + title={t("aiProviderDelete")} + /> + )} + + + router.push(`/${orgId}/settings/ai-providers/create`) + } + tableId="ai-providers-table" + searchPlaceholder={t("aiProvidersSearch")} + pagination={pagination} + onPaginationChange={handlePaginationChange} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + rowCount={rowCount} + stickyRightColumn="actions" + /> + + ); +} diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index bffaadeba..6c91dbfef 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -47,6 +47,7 @@ export function HorizontalTabs({ .replace("{userId}", params.userId as string) .replace("{clientId}", params.clientId as string) .replace("{apiKeyId}", params.apiKeyId as string) + .replace("{providerId}", params.providerId as string) .replace("{remoteExitNodeId}", params.remoteExitNodeId as string); } diff --git a/src/contexts/aiProviderContext.ts b/src/contexts/aiProviderContext.ts new file mode 100644 index 000000000..bb72eef1a --- /dev/null +++ b/src/contexts/aiProviderContext.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; +import type { AiProviderPublic } from "@server/routers/aiProvider/types"; + +export type AiProviderContextType = { + provider: AiProviderPublic; + updateProvider: (updated: Partial) => void; +}; + +const AiProviderContext = createContext( + undefined +); + +export default AiProviderContext; diff --git a/src/hooks/useAiProviderContext.ts b/src/hooks/useAiProviderContext.ts new file mode 100644 index 000000000..9ab1182ca --- /dev/null +++ b/src/hooks/useAiProviderContext.ts @@ -0,0 +1,12 @@ +import AiProviderContext from "@app/contexts/aiProviderContext"; +import { useContext } from "react"; + +export function useAiProviderContext() { + const context = useContext(AiProviderContext); + if (context === undefined) { + throw new Error( + "useAiProviderContext must be used within an AiProviderProvider" + ); + } + return context; +} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts new file mode 100644 index 000000000..184be14c9 --- /dev/null +++ b/src/lib/aiProviderFormSchema.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; +import { + AI_PROVIDER_DEFAULTS, + providerRequiresUpstreamUrl, + type AiProviderType +} from "@server/lib/aiProviderDefaults"; + +export const aiProviderTypeValues = [ + "openai", + "anthropic", + "googleGemini", + "vertexAi", + "bedrock", + "microsoftFoundry", + "openRouter", + "vercelAiGateway", + "custom" +] as const satisfies readonly AiProviderType[]; + +export const aiProviderFormSchema = z + .object({ + name: z.string().trim().min(1), + type: z.enum(aiProviderTypeValues), + upstreamUrl: z.string().optional().nullable(), + apiKey: z.string().optional(), + authType: z.enum(["bearer"]).optional().nullable(), + routingMode: z.enum(["url", "target"]).optional(), + skipTlsVerification: z.boolean().optional(), + budgetAmount: z.number().positive().nullable().optional(), + budgetUnit: z.enum(["usd", "tokens"]).optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + const routingMode = + data.type === "custom" ? (data.routingMode ?? "url") : "url"; + + if (data.type !== "custom" && data.routingMode === "target") { + ctx.addIssue({ + code: "custom", + message: + "routingMode target is only allowed for custom providers", + path: ["routingMode"] + }); + } + + const upstreamUrl = + data.upstreamUrl && data.upstreamUrl.trim().length > 0 + ? data.upstreamUrl.trim() + : null; + + if (upstreamUrl) { + try { + new URL(upstreamUrl); + } catch { + ctx.addIssue({ + code: "custom", + message: "Invalid URL", + path: ["upstreamUrl"] + }); + } + } + + if ( + providerRequiresUpstreamUrl(data.type, routingMode) && + !upstreamUrl + ) { + ctx.addIssue({ + code: "custom", + message: `upstreamUrl is required for ${data.type} providers`, + path: ["upstreamUrl"] + }); + } + + if (data.type === "custom" && routingMode === "url" && !data.authType) { + ctx.addIssue({ + code: "custom", + message: "authType is required for custom providers", + path: ["authType"] + }); + } + + const hasAmount = + data.budgetAmount !== undefined && data.budgetAmount !== null; + const hasUnit = + data.budgetUnit !== undefined && data.budgetUnit !== null; + + if (hasAmount !== hasUnit) { + ctx.addIssue({ + code: "custom", + message: + "budgetAmount and budgetUnit must both be set or both omitted", + path: hasAmount ? ["budgetUnit"] : ["budgetAmount"] + }); + } + }); + +export type AiProviderFormValues = z.infer; + +export function emptyUpstreamForType(type: AiProviderType): string { + if (type === "custom") { + return ""; + } + return AI_PROVIDER_DEFAULTS[type].upstreamUrl ?? ""; +} + +export function showsUpstreamUrlField( + type: AiProviderType, + routingMode: "url" | "target" | undefined +): boolean { + const mode = type === "custom" ? (routingMode ?? "url") : "url"; + if (mode === "target") { + return false; + } + return true; +} + +export function upstreamUrlRequired( + type: AiProviderType, + routingMode: "url" | "target" | undefined +): boolean { + const mode = type === "custom" ? (routingMode ?? "url") : "url"; + return providerRequiresUpstreamUrl(type, mode); +} + +export function toAiProviderCreatePayload(values: AiProviderFormValues) { + const routingMode = + values.type === "custom" ? (values.routingMode ?? "url") : "url"; + const upstreamRaw = values.upstreamUrl?.trim() ?? ""; + const upstreamUrl = + routingMode === "target" + ? null + : upstreamRaw.length > 0 + ? upstreamRaw + : null; + + const hasBudget = + values.budgetAmount !== undefined && + values.budgetAmount !== null && + values.budgetUnit; + + return { + name: values.name.trim(), + type: values.type, + routingMode: values.type === "custom" ? routingMode : undefined, + upstreamUrl, + apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, + authType: + values.type === "custom" && routingMode === "url" + ? (values.authType ?? "bearer") + : (values.authType ?? undefined), + skipTlsVerification: values.skipTlsVerification, + budgetAmount: hasBudget ? values.budgetAmount : null, + budgetUnit: hasBudget ? values.budgetUnit : null, + enabled: values.enabled ?? true + }; +} + +export function toAiProviderUpdatePayload(values: AiProviderFormValues) { + const routingMode = + values.type === "custom" ? (values.routingMode ?? "url") : "url"; + const upstreamRaw = values.upstreamUrl?.trim() ?? ""; + const upstreamUrl = + routingMode === "target" + ? null + : upstreamRaw.length > 0 + ? upstreamRaw + : null; + + const hasBudget = + values.budgetAmount !== undefined && + values.budgetAmount !== null && + values.budgetUnit; + + const payload: Record = { + name: values.name.trim(), + routingMode: values.type === "custom" ? routingMode : "url", + upstreamUrl, + authType: + values.type === "custom" && routingMode === "url" + ? (values.authType ?? "bearer") + : (values.authType ?? null), + skipTlsVerification: values.skipTlsVerification ?? false, + budgetAmount: hasBudget ? values.budgetAmount : null, + budgetUnit: hasBudget ? values.budgetUnit : null, + enabled: values.enabled ?? true + }; + + if (values.apiKey?.trim()) { + payload.apiKey = values.apiKey.trim(); + } + + return payload; +} + +export function toAiProviderConfigurationPayload(values: AiProviderFormValues) { + const { + name: _name, + enabled: _enabled, + ...payload + } = toAiProviderUpdatePayload(values); + return payload; +} diff --git a/src/providers/AiProviderProvider.tsx b/src/providers/AiProviderProvider.tsx new file mode 100644 index 000000000..072f75608 --- /dev/null +++ b/src/providers/AiProviderProvider.tsx @@ -0,0 +1,47 @@ +"use client"; + +import AiProviderContext from "@app/contexts/aiProviderContext"; +import type { AiProviderPublic } from "@server/routers/aiProvider/types"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +type AiProviderProviderProps = { + children: React.ReactNode; + provider: AiProviderPublic; +}; + +export function AiProviderProvider({ + children, + provider: serverProvider +}: AiProviderProviderProps) { + const [provider, setProvider] = useState(serverProvider); + const t = useTranslations(); + + useEffect(() => { + setProvider(serverProvider); + }, [serverProvider]); + + const updateProvider = (updated: Partial) => { + if (!provider) { + throw new Error(t("aiProviderErrorNoUpdate")); + } + + setProvider((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updated + }; + }); + }; + + return ( + + {children} + + ); +} + +export default AiProviderProvider;