mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-03 19:21:11 +02:00
providers table, create, and edit first pass
This commit is contained in:
@@ -1632,6 +1632,83 @@
|
|||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Shareable Links",
|
"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",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
|
Sparkles,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
TicketCheck,
|
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",
|
heading: "sidebarOrganization",
|
||||||
items: [
|
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",
|
heading: "commandLogsAndAnalytics",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export function HorizontalTabs({
|
|||||||
.replace("{userId}", params.userId as string)
|
.replace("{userId}", params.userId as string)
|
||||||
.replace("{clientId}", params.clientId as string)
|
.replace("{clientId}", params.clientId as string)
|
||||||
.replace("{apiKeyId}", params.apiKeyId as string)
|
.replace("{apiKeyId}", params.apiKeyId as string)
|
||||||
|
.replace("{providerId}", params.providerId as string)
|
||||||
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
|
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user