diff --git a/messages/en-US.json b/messages/en-US.json index 5528c0ee1..39a3b54e6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1721,6 +1721,11 @@ "aiProviderErrorUpdate": "Failed to update AI provider", "aiProviderErrorDelete": "Failed to delete AI provider", "aiProviderErrorLoad": "Failed to load AI provider", + "aiProviderErrorUpstreamUrlInvalid": "Enter a valid upstream URL", + "aiProviderErrorUpstreamUrlRequired": "Upstream URL is required for this provider", + "aiProviderErrorAuthTypeRequired": "Auth type is required", + "aiProviderErrorApiKeyRequired": "API key is required", + "aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers", "aiProviderCreated": "AI provider created", "aiProviderUpdated": "AI provider updated", "aiProviderDeleted": "AI provider deleted", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx index a8f827e93..5a7b068d8 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -29,7 +29,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderFormSchema, + createAiProviderFormSchema, toAiProviderAuthPayload, type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; @@ -43,7 +43,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; export default function AiProviderAuthenticationPage() { @@ -54,8 +54,10 @@ export default function AiProviderAuthenticationPage() { const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); + const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]); + const form = useForm({ - resolver: zodResolver(aiProviderFormSchema), + resolver: zodResolver(formSchema), defaultValues: { name: provider.name, type: provider.type as AiProviderType, diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx index 99db2eda8..1018f8e63 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx @@ -32,17 +32,10 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -const generalSchema = z.object({ - name: z.string().trim().min(1), - enabled: z.boolean() -}); - -type GeneralFormValues = z.infer; - export default function AiProviderGeneralPage() { const { provider, updateProvider } = useAiProviderContext(); const { env } = useEnvContext(); @@ -51,6 +44,20 @@ export default function AiProviderGeneralPage() { const t = useTranslations(); const [saveLoading, setSaveLoading] = useState(false); + const generalSchema = useMemo( + () => + z.object({ + name: z + .string() + .trim() + .min(1, { message: t("nameRequired") }), + enabled: z.boolean() + }), + [t] + ); + + type GeneralFormValues = z.infer; + const form = useForm({ resolver: zodResolver(generalSchema), defaultValues: { diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx index 270ba3c68..49f251cfa 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -37,7 +37,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderFormSchema, + createAiProviderFormSchema, showsUpstreamUrlField, toAiProviderNetworkPayload, upstreamUrlRequired, @@ -54,7 +54,7 @@ import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; export default function AiProviderNetworkPage() { @@ -68,8 +68,10 @@ export default function AiProviderNetworkPage() { const [saveLoading, setSaveLoading] = useState(false); const targetsFormRef = useRef(null); + const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]); + const form = useForm({ - resolver: zodResolver(aiProviderFormSchema), + resolver: zodResolver(formSchema), defaultValues: { name: provider.name, type: provider.type as AiProviderType, diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 1de013a2e..1947c3f85 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -38,7 +38,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { - aiProviderCreateFormSchema, + createAiProviderCreateFormSchema, defaultAuthTypeForProvider, emptyUpstreamForType, showsUpstreamUrlField, @@ -52,7 +52,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/ import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; export default function CreateAiProviderPage() { @@ -65,8 +65,10 @@ export default function CreateAiProviderPage() { const [loading, setLoading] = useState(false); const targetsRef = useRef([]); + const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]); + const form = useForm({ - resolver: zodResolver(aiProviderCreateFormSchema), + resolver: zodResolver(formSchema), defaultValues: { name: "", type: "openai", diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 67b540305..f484d1f05 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -8,6 +8,8 @@ import { type AiProviderType } from "@server/lib/aiProviderDefaults"; +type TranslateFn = (key: string) => string; + export const aiProviderTypeValues = [ "openai", "anthropic", @@ -20,82 +22,88 @@ export const aiProviderTypeValues = [ "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(AI_PROVIDER_AUTH_TYPES).optional().nullable(), - routingMode: z.enum(["url", "target"]).optional(), - skipTlsVerification: z.boolean().optional(), - enabled: z.boolean().optional() - }) - .superRefine((data, ctx) => { - const routingMode = - data.type === "custom" ? (data.routingMode ?? "url") : "url"; +export function createAiProviderFormSchema(t: TranslateFn) { + return z + .object({ + name: z + .string() + .trim() + .min(1, { message: t("nameRequired") }), + type: z.enum(aiProviderTypeValues), + upstreamUrl: z.string().optional().nullable(), + apiKey: z.string().optional(), + authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(), + routingMode: z.enum(["url", "target"]).optional(), + skipTlsVerification: z.boolean().optional(), + 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 { + if (data.type !== "custom" && data.routingMode === "target") { ctx.addIssue({ code: "custom", - message: "Invalid URL", + message: t("aiProviderErrorRoutingModeTarget"), + 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: t("aiProviderErrorUpstreamUrlInvalid"), + path: ["upstreamUrl"] + }); + } + } + + if ( + providerRequiresUpstreamUrl(data.type, routingMode) && + !upstreamUrl + ) { + ctx.addIssue({ + code: "custom", + message: t("aiProviderErrorUpstreamUrlRequired"), path: ["upstreamUrl"] }); } - } - if ( - providerRequiresUpstreamUrl(data.type, routingMode) && - !upstreamUrl - ) { - ctx.addIssue({ - code: "custom", - message: `upstreamUrl is required for ${data.type} providers`, - path: ["upstreamUrl"] - }); - } + if (!data.authType) { + ctx.addIssue({ + code: "custom", + message: t("aiProviderErrorAuthTypeRequired"), + path: ["authType"] + }); + } + }); +} - if (!data.authType) { - ctx.addIssue({ - code: "custom", - message: "authType is required", - path: ["authType"] - }); - } - }); - -export type AiProviderFormValues = z.infer; - -export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( - (data, ctx) => { +export function createAiProviderCreateFormSchema(t: TranslateFn) { + return createAiProviderFormSchema(t).superRefine((data, ctx) => { const authType: AiProviderAuthType = data.authType ?? "bearer"; if (authTypeRequiresApiKey(authType) && !data.apiKey?.trim()) { ctx.addIssue({ code: "custom", - message: "API key is required", + message: t("aiProviderErrorApiKeyRequired"), path: ["apiKey"] }); } - } -); + }); +} + +export type AiProviderFormValues = z.infer< + ReturnType +>; export function defaultAuthTypeForProvider( type: AiProviderType