add no auth and passthrough auth

This commit is contained in:
miloschwartz
2026-08-05 15:39:08 -04:00
parent 2e9bd50172
commit bcf6b86b84
10 changed files with 323 additions and 205 deletions
+11
View File
@@ -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",
+2
View File
@@ -1651,6 +1651,8 @@ export const aiProviders = pgTable("aiProviders", {
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: varchar("routingMode")
+2
View File
@@ -1633,6 +1633,8 @@ export const aiProviders = sqliteTable("aiProviders", {
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: text("routingMode")
+25 -5
View File
@@ -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<string, string>,
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}`;
+15 -10
View File
@@ -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
-8
View File
@@ -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"]
});
}
}
@@ -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"
>
<SettingsFormGrid>
{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>
<SelectItem value="x-api-key">
{t(
"aiProviderAuthTypeXApiKey"
)}
</SelectItem>
<SelectItem value="x-goog-api-key">
{t(
"aiProviderAuthTypeXGoogApiKey"
)}
</SelectItem>
<SelectItem value="hec">
{t(
"aiProviderAuthTypeHec"
)}
</SelectItem>
<SelectItem value="cf-aig-authorization">
{t(
"aiProviderAuthTypeCfAigAuthorization"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderApiKey")}
{t(
"aiProviderAuthType"
)}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
<AiProviderAuthTypeSelect
value={
field.value ??
""
"bearer"
}
onChange={
field.onChange
@@ -225,7 +157,7 @@ export default function AiProviderAuthenticationPage() {
</FormControl>
<FormDescription>
{t(
"aiProviderApiKeyDescription"
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
@@ -233,6 +165,43 @@ export default function AiProviderAuthenticationPage() {
)}
/>
</SettingsFormCell>
{showApiKey && (
<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>
)}
</SettingsFormGrid>
</form>
</Form>
@@ -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() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
{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>
<SelectItem value="x-api-key">
{t(
"aiProviderAuthTypeXApiKey"
)}
</SelectItem>
<SelectItem value="x-goog-api-key">
{t(
"aiProviderAuthTypeXGoogApiKey"
)}
</SelectItem>
<SelectItem value="hec">
{t(
"aiProviderAuthTypeHec"
)}
</SelectItem>
<SelectItem value="cf-aig-authorization">
{t(
"aiProviderAuthTypeCfAigAuthorization"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderApiKey")}
{t(
"aiProviderAuthType"
)}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
<AiProviderAuthTypeSelect
value={
field.value ??
""
"bearer"
}
onChange={
field.onChange
@@ -585,7 +522,7 @@ export default function CreateAiProviderPage() {
</FormControl>
<FormDescription>
{t(
"aiProviderApiKeyDescription"
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
@@ -593,6 +530,43 @@ export default function CreateAiProviderPage() {
)}
/>
</SettingsFormCell>
{showApiKey && (
<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>
)}
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
+139
View File
@@ -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 (
<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",
!selected && "text-muted-foreground",
className
)}
>
<span className="truncate text-left">
{selected?.title ?? t("noneSelected")}
</span>
<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("aiProviderAuthTypeSearch")} />
<CommandList>
<CommandEmpty>
{t("aiProviderAuthTypeNotFound")}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.authType}
value={`${option.authType} ${option.title} ${option.description}`}
onSelect={() => {
onChange(option.authType);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.authType === 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>
);
}
+20 -16
View File
@@ -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<typeof aiProviderFormSchema>;
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<string, unknown> = {
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) {