diff --git a/messages/en-US.json b/messages/en-US.json index 257c56c42..bf498421a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1682,12 +1682,23 @@ "aiProviderApiKeyDescription": "API key used to authenticate requests to this provider", "aiProviderApiKeyLastChars": "API Key", "aiProviderAuthType": "Auth Type", + "aiProviderAuthTypeSearch": "Search auth types...", + "aiProviderAuthTypeNotFound": "No auth type found", "aiProviderAuthTypeBearer": "Bearer", + "aiProviderAuthTypeBearerDescription": "Authorization: Bearer key. Used by OpenAI and most providers", "aiProviderAuthTypeXApiKey": "x-api-key", + "aiProviderAuthTypeXApiKeyDescription": "x-api-key header. Used by Anthropic", "aiProviderAuthTypeXGoogApiKey": "x-goog-api-key", + "aiProviderAuthTypeXGoogApiKeyDescription": "x-goog-api-key header. Used by Google Gemini", "aiProviderAuthTypeHec": "Splunk HEC", + "aiProviderAuthTypeHecDescription": "Authorization: Splunk key. Used by Splunk HTTP Event Collector", "aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway", + "aiProviderAuthTypeCfAigAuthorizationDescription": "cf-aig-authorization: Bearer key. Used by Cloudflare AI Gateway", + "aiProviderAuthTypeNone": "No Auth", + "aiProviderAuthTypePassthrough": "Passthrough", "aiProviderAuthTypeDescription": "How the upstream API authenticates requests", + "aiProviderAuthTypePassthroughDescription": "Forward the caller's API key headers to the upstream", + "aiProviderAuthTypeNoneDescription": "Do not send authentication headers to the upstream", "aiProviderRoutingMode": "Routing Mode", "aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites", "aiProviderRoutingModeUrl": "Upstream URL", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7883345da..9bf11a21d 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1651,6 +1651,8 @@ export const aiProviders = pgTable("aiProviders", { | "x-goog-api-key" | "hec" | "cf-aig-authorization" + | "none" + | "passthrough" >() .notNull(), routingMode: varchar("routingMode") diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e586c4e02..35028e815 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1633,6 +1633,8 @@ export const aiProviders = sqliteTable("aiProviders", { | "x-goog-api-key" | "hec" | "cf-aig-authorization" + | "none" + | "passthrough" >() .notNull(), routingMode: text("routingMode") diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index d803e7613..70b666954 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -14,7 +14,9 @@ export const AI_PROVIDER_AUTH_TYPES = [ "x-api-key", "x-goog-api-key", "hec", - "cf-aig-authorization" + "cf-aig-authorization", + "none", + "passthrough" ] as const; export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number]; @@ -71,6 +73,10 @@ const CONFLICTING_AUTH_HEADERS = [ "cf-aig-authorization" ] as const; +export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean { + return authType !== "none" && authType !== "passthrough"; +} + export function providerRequiresUpstreamUrl( type: AiProviderType, routingMode: AiProviderRoutingMode = "url" @@ -116,20 +122,26 @@ export function resolveAiProviderCreateFields(input: { const defaults = AI_PROVIDER_DEFAULTS[input.type]; return { upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl, - authType: defaults.authType, + authType: input.authType ?? defaults.authType, routingMode }; } /** - * Strip inbound client auth headers, then set the provider auth header - * for the given authType. + * Apply provider auth to upstream headers. + * - Injected modes: strip client auth headers, then set the provider key. + * - none: strip client auth headers, send no auth. + * - passthrough: leave client auth headers as-is. */ export function applyAiProviderAuthHeaders( headers: Record, authType: AiProviderAuthType, - apiKey: string + apiKey: string | null ): void { + if (authType === "passthrough") { + return; + } + for (const name of CONFLICTING_AUTH_HEADERS) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === name) { @@ -138,6 +150,14 @@ export function applyAiProviderAuthHeaders( } } + if (authType === "none") { + return; + } + + if (!apiKey) { + throw new Error(`API key required for authType ${authType}`); + } + switch (authType) { case "bearer": headers["Authorization"] = `Bearer ${apiKey}`; diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index e1bb8bbc9..d2671aa63 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -19,7 +19,8 @@ import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { AiProviderAuthType, - applyAiProviderAuthHeaders + applyAiProviderAuthHeaders, + authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; import { SESSION_COOKIE_NAME, @@ -488,15 +489,6 @@ export async function chatCompletions( const { provider } = selection; - if (!provider.apiKey) { - return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ - error: { message: "AI provider has no API key configured" } - }); - } - - const secret = config.getRawConfig().server.secret!; - const apiKey = decrypt(provider.apiKey, secret); - const upstreamUrl = provider.upstreamUrl; const authType = provider.authType as AiProviderAuthType; @@ -508,6 +500,19 @@ export async function chatCompletions( }); } + let apiKey: string | null = null; + if (authTypeRequiresApiKey(authType)) { + if (!provider.apiKey) { + return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ + error: { + message: "AI provider has no API key configured" + } + }); + } + const secret = config.getRawConfig().server.secret!; + apiKey = decrypt(provider.apiKey, secret); + } + const targetUrl = `${upstreamUrl.replace(/\/$/, "")}`; // Drop hop-by-hop / proxy-only headers. Forwarding Host especially diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index 42476c710..b4ab104aa 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -52,12 +52,4 @@ export function refineProviderUpstreamFields( path: ["upstreamUrl"] }); } - - if (data.type === "custom" && !data.authType) { - ctx.addIssue({ - code: "custom", - message: "authType is required for custom providers", - path: ["authType"] - }); - } } 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 264e6b252..a8f827e93 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -12,6 +12,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; +import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; import { Button } from "@app/components/ui/button"; import { Form, @@ -23,13 +24,6 @@ import { 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"; @@ -40,9 +34,10 @@ import { type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; -import type { - AiProviderAuthType, - AiProviderType +import { + authTypeRequiresApiKey, + type AiProviderAuthType, + type AiProviderType } from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; @@ -73,7 +68,10 @@ export default function AiProviderAuthenticationPage() { } }); - const showAuthType = provider.type === "custom"; + const authType = form.watch("authType"); + const showApiKey = authTypeRequiresApiKey( + (authType as AiProviderAuthType | null) ?? "bearer" + ); async function onSubmit(values: AiProviderFormValues) { setSaveLoading(true); @@ -135,88 +133,22 @@ export default function AiProviderAuthenticationPage() { id="ai-provider-auth-form" > - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - ( - {t("aiProviderApiKey")} + {t( + "aiProviderAuthType" + )} - {t( - "aiProviderApiKeyDescription" + "aiProviderAuthTypeDescription" )} @@ -233,6 +165,43 @@ export default function AiProviderAuthenticationPage() { )} /> + + {showApiKey && ( + + ( + + + {t( + "aiProviderApiKey" + )} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + )} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 5ad4c4ec7..1de013a2e 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -19,6 +19,7 @@ import { SettingsSubsectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; @@ -33,18 +34,12 @@ import { 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 { aiProviderCreateFormSchema, + defaultAuthTypeForProvider, emptyUpstreamForType, showsUpstreamUrlField, toAiProviderCreatePayload, @@ -52,6 +47,7 @@ import { type AiProviderFormValues } from "@app/lib/aiProviderFormSchema"; import { zodResolver } from "@hookform/resolvers/zod"; +import { authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; import type { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; @@ -76,7 +72,7 @@ export default function CreateAiProviderPage() { type: "openai", upstreamUrl: emptyUpstreamForType("openai"), apiKey: "", - authType: "bearer", + authType: defaultAuthTypeForProvider("openai"), routingMode: "url", skipTlsVerification: false, enabled: true @@ -85,12 +81,13 @@ export default function CreateAiProviderPage() { const providerType = form.watch("type"); const routingMode = form.watch("routingMode"); + const authType = form.watch("authType"); const showUpstream = showsUpstreamUrlField(providerType, routingMode); const requireUpstream = upstreamUrlRequired(providerType, routingMode); const showRoutingMode = providerType === "custom"; - const showAuthType = providerType === "custom"; const showTargets = providerType === "custom" && routingMode === "target"; + const showApiKey = authTypeRequiresApiKey(authType ?? "bearer"); async function createTargets( providerId: number, @@ -272,6 +269,12 @@ export default function CreateAiProviderPage() { value ) ); + form.setValue( + "authType", + defaultAuthTypeForProvider( + value + ) + ); if ( value !== "custom" @@ -495,88 +498,22 @@ export default function CreateAiProviderPage() { - {showAuthType && ( - - ( - - - {t( - "aiProviderAuthType" - )} - - - - {t( - "aiProviderAuthTypeDescription" - )} - - - - )} - /> - - )} - ( - {t("aiProviderApiKey")} + {t( + "aiProviderAuthType" + )} - {t( - "aiProviderApiKeyDescription" + "aiProviderAuthTypeDescription" )} @@ -593,6 +530,43 @@ export default function CreateAiProviderPage() { )} /> + + {showApiKey && ( + + ( + + + {t( + "aiProviderApiKey" + )} + + + + + + {t( + "aiProviderApiKeyDescription" + )} + + + + )} + /> + + )} diff --git a/src/components/AiProviderAuthTypeSelect.tsx b/src/components/AiProviderAuthTypeSelect.tsx new file mode 100644 index 000000000..a040c6fa1 --- /dev/null +++ b/src/components/AiProviderAuthTypeSelect.tsx @@ -0,0 +1,139 @@ +"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 { + AI_PROVIDER_AUTH_TYPES, + type AiProviderAuthType +} from "@server/lib/aiProviderDefaults"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; + +const authLabelMap = { + bearer: "aiProviderAuthTypeBearer", + "x-api-key": "aiProviderAuthTypeXApiKey", + "x-goog-api-key": "aiProviderAuthTypeXGoogApiKey", + hec: "aiProviderAuthTypeHec", + "cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorization", + none: "aiProviderAuthTypeNone", + passthrough: "aiProviderAuthTypePassthrough" +} as const; + +const authDescriptionMap = { + bearer: "aiProviderAuthTypeBearerDescription", + "x-api-key": "aiProviderAuthTypeXApiKeyDescription", + "x-goog-api-key": "aiProviderAuthTypeXGoogApiKeyDescription", + hec: "aiProviderAuthTypeHecDescription", + "cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorizationDescription", + none: "aiProviderAuthTypeNoneDescription", + passthrough: "aiProviderAuthTypePassthroughDescription" +} as const; + +type AiProviderAuthTypeSelectProps = { + value: AiProviderAuthType; + onChange: (value: AiProviderAuthType) => void; + disabled?: boolean; + className?: string; +}; + +export function AiProviderAuthTypeSelect({ + value, + onChange, + disabled, + className +}: AiProviderAuthTypeSelectProps) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + + const options = useMemo( + () => + AI_PROVIDER_AUTH_TYPES.map((authType) => ({ + authType, + title: t(authLabelMap[authType]), + description: t(authDescriptionMap[authType]) + })), + [t] + ); + + const selected = options.find((option) => option.authType === value); + + return ( + + + + + + + + + + {t("aiProviderAuthTypeNotFound")} + + + {options.map((option) => ( + { + onChange(option.authType); + setOpen(false); + }} + > + +
+ + {option.title} + + + {option.description} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 8eb038fba..67b540305 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { AI_PROVIDER_AUTH_TYPES, AI_PROVIDER_DEFAULTS, + authTypeRequiresApiKey, providerRequiresUpstreamUrl, + type AiProviderAuthType, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -70,10 +72,10 @@ export const aiProviderFormSchema = z }); } - if (data.type === "custom" && !data.authType) { + if (!data.authType) { ctx.addIssue({ code: "custom", - message: "authType is required for custom providers", + message: "authType is required", path: ["authType"] }); } @@ -83,7 +85,9 @@ export type AiProviderFormValues = z.infer; export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( (data, ctx) => { - if (!data.apiKey?.trim()) { + const authType: AiProviderAuthType = data.authType ?? "bearer"; + + if (authTypeRequiresApiKey(authType) && !data.apiKey?.trim()) { ctx.addIssue({ code: "custom", message: "API key is required", @@ -93,6 +97,15 @@ export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine( } ); +export function defaultAuthTypeForProvider( + type: AiProviderType +): AiProviderAuthType { + if (type === "custom") { + return "bearer"; + } + return AI_PROVIDER_DEFAULTS[type].authType; +} + export function emptyUpstreamForType(type: AiProviderType): string { if (type === "custom") { return ""; @@ -136,10 +149,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) { routingMode: values.type === "custom" ? routingMode : undefined, upstreamUrl, apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, - authType: - values.type === "custom" - ? (values.authType ?? "bearer") - : undefined, + authType: values.authType ?? "bearer", skipTlsVerification: values.skipTlsVerification, enabled: values.enabled ?? true }; @@ -160,14 +170,11 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) { name: values.name.trim(), routingMode: values.type === "custom" ? routingMode : "url", upstreamUrl, + authType: values.authType ?? "bearer", skipTlsVerification: values.skipTlsVerification ?? false, enabled: values.enabled ?? true }; - if (values.type === "custom") { - payload.authType = values.authType ?? "bearer"; - } - if (values.apiKey?.trim()) { payload.apiKey = values.apiKey.trim(); } @@ -185,13 +192,10 @@ export function toAiProviderNetworkPayload(values: AiProviderFormValues) { } export function toAiProviderAuthPayload(values: AiProviderFormValues) { - const payload: Record = { + return { + authType: values.authType ?? "bearer", ...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {}) }; - if (values.type === "custom") { - payload.authType = values.authType ?? "bearer"; - } - return payload; } export function toAiProviderConfigurationPayload(values: AiProviderFormValues) {