providers table, create, and edit first pass

This commit is contained in:
miloschwartz
2026-08-03 10:36:42 -04:00
parent 1c9ede9729
commit 0d9a01a066
16 changed files with 2141 additions and 0 deletions
+77
View File
@@ -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",
@@ -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<AiProviderFormValues>({
resolver: zodResolver(aiProviderFormSchema),
defaultValues: {
name: provider.name,
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: "",
authType: (provider.authType as "bearer" | null) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
skipTlsVerification: provider.skipTlsVerification,
budgetAmount: provider.budgetAmount,
budgetUnit: provider.budgetUnit as "usd" | "tokens" | null,
enabled: provider.enabled
}
});
const providerType = form.watch("type");
const routingMode = form.watch("routingMode");
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
const showRoutingMode = providerType === "custom";
const showAuthType =
providerType === "custom" && (routingMode ?? "url") === "url";
const showTargetNote =
providerType === "custom" && routingMode === "target";
async function onSubmit(values: AiProviderFormValues) {
setSaveLoading(true);
try {
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(
`/ai-provider/${provider.providerId}`,
toAiProviderConfigurationPayload({
...values,
type: provider.type as AiProviderType
})
);
const updated = res.data.data.provider;
updateProvider(updated);
form.reset({
name: updated.name,
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: "",
authType: (updated.authType as "bearer" | null) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
skipTlsVerification: updated.skipTlsVerification,
budgetAmount: updated.budgetAmount,
budgetUnit: updated.budgetUnit as "usd" | "tokens" | null,
enabled: updated.enabled
});
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderConfiguration")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-configuration-form"
>
<SettingsFormGrid>
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<StrategySelect
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value ??
"url"
}
onChange={(
value
) => {
field.onChange(
value
);
if (
value ===
"target"
) {
form.setValue(
"upstreamUrl",
""
);
}
}}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderRoutingModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showTargetNote && (
<SettingsFormCell span="full">
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle>
{t(
"aiProviderRoutingModeTarget"
)}
</AlertTitle>
<AlertDescription>
{t(
"aiProviderRoutingModeTargetNote"
)}
</AlertDescription>
</Alert>
</SettingsFormCell>
)}
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderUpstreamUrl"
)}
{requireUpstream
? ""
: " (optional)"}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="https://"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{requireUpstream
? t(
"aiProviderUpstreamUrlDescription"
)
: t(
"aiProviderUpstreamUrlOptionalDescription"
)}
</FormDescription>
{provider.effectiveUpstreamUrl && (
<FormDescription>
{t(
"aiProviderEffectiveUpstreamUrl"
)}
{": "}
<span className="font-mono">
{
provider.effectiveUpstreamUrl
}
</span>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showAuthType && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderAuthType"
)}
</FormLabel>
<Select
value={
field.value ??
"bearer"
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bearer">
{t(
"aiProviderAuthTypeBearer"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderApiKey")}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{provider.apiKeyLastChars
? `••••${provider.apiKeyLastChars}. ${t("aiProviderApiKeyDescription")}`
: t(
"aiProviderApiKeyDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="edit-skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="ai-provider-configuration-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -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<typeof generalSchema>;
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<GeneralFormValues>({
resolver: zodResolver(generalSchema),
defaultValues: {
name: provider.name,
enabled: provider.enabled
}
});
async function onSubmit(values: GeneralFormValues) {
setSaveLoading(true);
try {
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(`/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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-general-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="edit-enabled"
label={t(
"aiProviderEnabled"
)}
description={t(
"aiProviderEnabledDescription"
)}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="ai-provider-general-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -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<AxiosResponse<GetAiProviderResponse>>(
`/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<AxiosResponse<GetOrgResponse>>(
`/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 (
<>
<SettingsSectionTitle
title={t("aiProviderSetting", {
providerName: provider.name
})}
description={t("aiProviderSettingDescription")}
/>
<OrgProvider org={org}>
<AiProviderProvider provider={provider}>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</AiProviderProvider>
</OrgProvider>
</>
);
}
@@ -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`);
}
@@ -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;
}
@@ -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<AiProviderFormValues>({
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<CreateOrEditAiProviderResponse>
>(`/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 (
<>
<div className="flex justify-between">
<HeaderTitle
title={t("aiProviderCreate")}
description={t("aiProviderCreateDescription")}
/>
<Button
variant="outline"
onClick={() =>
router.push(`/${orgId}/settings/ai-providers`)
}
>
{t("aiProviderSeeAll")}
</Button>
</div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="create-ai-provider-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="enabled"
label={t(
"aiProviderEnabled"
)}
description={t(
"aiProviderEnabledDescription"
)}
checked={
field.value ??
true
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderType"
)}
</FormLabel>
<FormControl>
<AiProviderTypeSelect
value={
field.value
}
onChange={(
value
) => {
field.onChange(
value
);
form.setValue(
"upstreamUrl",
emptyUpstreamForType(
value
)
);
if (
value !==
"custom"
) {
form.setValue(
"routingMode",
"url"
);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<StrategySelect
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value ??
"url"
}
onChange={(
value
) => {
field.onChange(
value
);
if (
value ===
"target"
) {
form.setValue(
"upstreamUrl",
""
);
}
}}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderRoutingModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showTargetNote && (
<SettingsFormCell span="full">
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle>
{t(
"aiProviderRoutingModeTarget"
)}
</AlertTitle>
<AlertDescription>
{t(
"aiProviderRoutingModeTargetNote"
)}
</AlertDescription>
</Alert>
</SettingsFormCell>
)}
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderUpstreamUrl"
)}
{requireUpstream
? ""
: " (optional)"}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="https://"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{requireUpstream
? t(
"aiProviderUpstreamUrlDescription"
)
: t(
"aiProviderUpstreamUrlOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{showAuthType && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderAuthType"
)}
</FormLabel>
<Select
value={
field.value ??
"bearer"
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bearer">
{t(
"aiProviderAuthTypeBearer"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderApiKey"
)}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderApiKeyDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loading}
disabled={loading}
form="create-ai-provider-form"
>
{t("create")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);
}
@@ -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<Record<string, string>>;
};
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<AxiosResponse<ListAiProvidersResponse>>(
`/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 (
<>
<SettingsSectionTitle
title={t("aiProvidersTitle")}
description={t("aiProvidersDescription")}
/>
<AiProvidersTable
orgId={orgId}
providers={providers.map((provider) => ({
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
}}
/>
</>
);
}
+21
View File
@@ -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: <Sparkles className="size-4 flex-none" />
}
]
},
{
heading: "sidebarOrganization",
items: [
@@ -471,6 +482,16 @@ export const commandBarNavSections = (
}
]
},
{
heading: "sidebarAi",
items: [
{
title: "commandAiProviders",
href: "/{orgId}/settings/ai-providers",
icon: <Sparkles className="size-4 flex-none" />
}
]
},
{
heading: "commandLogsAndAnalytics",
items: [
+147
View File
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal h-auto min-h-10 py-2",
className
)}
>
<div className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left">
<span className="truncate">
{selected?.title ?? t("noneSelected")}
</span>
{selected?.description && (
<span className="text-muted-foreground text-xs leading-snug truncate w-full">
{selected.description}
</span>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={t("aiProviderTypeSearch")} />
<CommandList>
<CommandEmpty>
{t("aiProviderTypeNotFound")}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.type}
value={`${option.type} ${option.title} ${option.description}`}
onSelect={() => {
onChange(option.type);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.type === value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{option.title}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{option.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+317
View File
@@ -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<AiProviderRow | null>(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<string, string> = {
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<ExtendedColumnDef<AiProviderRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
className="hover:underline"
>
{row.original.name}
</Link>
)
},
{
accessorKey: "type",
header: () => (
<span className="p-3">{t("aiProviderType")}</span>
),
cell: ({ row }) => typeLabel(row.original.type)
},
{
accessorKey: "routingMode",
header: () => (
<span className="p-3">{t("aiProviderRoutingMode")}</span>
),
cell: ({ row }) => routingLabel(row.original.routingMode)
},
{
accessorKey: "effectiveUpstreamUrl",
header: () => (
<span className="p-3">{t("aiProviderUpstreamUrl")}</span>
),
cell: ({ row }) => (
<span>{row.original.effectiveUpstreamUrl ?? "-"}</span>
)
},
{
accessorKey: "enabled",
header: () => (
<span className="p-3">{t("aiProviderEnabled")}</span>
),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={(checked) =>
toggleEnabled(row.original, checked)
}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
>
{t("edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
>
<Button variant="outline">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
)
}
],
[orgId, t]
);
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelected(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("aiProviderQuestionRemove")}</p>
<p>{t("aiProviderMessageRemove")}</p>
</div>
}
buttonText={t("aiProviderDeleteConfirm")}
onConfirm={async () => deleteProvider(selected)}
string={selected.name}
title={t("aiProviderDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={rows}
addButtonText={t("aiProvidersAdd")}
onAdd={() =>
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"
/>
</>
);
}
+1
View File
@@ -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);
}
+13
View File
@@ -0,0 +1,13 @@
import { createContext } from "react";
import type { AiProviderPublic } from "@server/routers/aiProvider/types";
export type AiProviderContextType = {
provider: AiProviderPublic;
updateProvider: (updated: Partial<AiProviderPublic>) => void;
};
const AiProviderContext = createContext<AiProviderContextType | undefined>(
undefined
);
export default AiProviderContext;
+12
View File
@@ -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;
}
+202
View File
@@ -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<typeof aiProviderFormSchema>;
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<string, unknown> = {
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;
}
+47
View File
@@ -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<AiProviderPublic>(serverProvider);
const t = useTranslations();
useEffect(() => {
setProvider(serverProvider);
}, [serverProvider]);
const updateProvider = (updated: Partial<AiProviderPublic>) => {
if (!provider) {
throw new Error(t("aiProviderErrorNoUpdate"));
}
setProvider((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updated
};
});
};
return (
<AiProviderContext.Provider value={{ provider, updateProvider }}>
{children}
</AiProviderContext.Provider>
);
}
export default AiProviderProvider;