add provider specific auth modes

This commit is contained in:
miloschwartz
2026-08-05 15:17:50 -04:00
parent c673dce484
commit 2e9bd50172
13 changed files with 178 additions and 78 deletions
+5 -1
View File
@@ -1652,7 +1652,7 @@
"aiProviderNetworkSettings": "Network Settings",
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
"aiProviderAuthSettings": "Authentication",
"aiProviderAuthSettingsDescription": "Credentials used for both upstream URL and Pangolin target routing",
"aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL",
"aiProviderType": "Provider Type",
"aiProviderTypeSearch": "Search providers...",
"aiProviderTypeNotFound": "No provider type found",
@@ -1683,6 +1683,10 @@
"aiProviderApiKeyLastChars": "API Key",
"aiProviderAuthType": "Auth Type",
"aiProviderAuthTypeBearer": "Bearer",
"aiProviderAuthTypeXApiKey": "x-api-key",
"aiProviderAuthTypeXGoogApiKey": "x-goog-api-key",
"aiProviderAuthTypeHec": "Splunk HEC",
"aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway",
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
"aiProviderRoutingMode": "Routing Mode",
"aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites",
+9 -1
View File
@@ -1644,7 +1644,15 @@ export const aiProviders = pgTable("aiProviders", {
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: varchar("apiKeyLastChars"),
authType: varchar("authType").$type<"bearer">(),
authType: varchar("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
>()
.notNull(),
routingMode: varchar("routingMode")
.$type<"url" | "target">()
.notNull()
+9 -1
View File
@@ -1626,7 +1626,15 @@ export const aiProviders = sqliteTable("aiProviders", {
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: text("apiKeyLastChars"),
authType: text("authType").$type<"bearer">(),
authType: text("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
>()
.notNull(),
routingMode: text("routingMode")
.$type<"url" | "target">()
.notNull()
+63 -11
View File
@@ -9,7 +9,15 @@ export type AiProviderType =
| "vercelAiGateway"
| "custom";
export type AiProviderAuthType = "bearer";
export const AI_PROVIDER_AUTH_TYPES = [
"bearer",
"x-api-key",
"x-goog-api-key",
"hec",
"cf-aig-authorization"
] as const;
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
export type AiBudgetUnit = "usd" | "tokens";
export type AiProviderRoutingMode = "url" | "target";
@@ -28,11 +36,11 @@ export const AI_PROVIDER_DEFAULTS: Record<
},
anthropic: {
upstreamUrl: "https://api.anthropic.com",
authType: "bearer"
authType: "x-api-key"
},
googleGemini: {
upstreamUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
authType: "bearer"
authType: "x-goog-api-key"
},
vertexAi: {
upstreamUrl: null,
@@ -56,6 +64,13 @@ export const AI_PROVIDER_DEFAULTS: Record<
}
};
const CONFLICTING_AUTH_HEADERS = [
"authorization",
"x-api-key",
"x-goog-api-key",
"cf-aig-authorization"
] as const;
export function providerRequiresUpstreamUrl(
type: AiProviderType,
routingMode: AiProviderRoutingMode = "url"
@@ -69,17 +84,18 @@ export function providerRequiresUpstreamUrl(
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
}
export function resolveAiProviderConfig(input: {
export function resolveAiProviderCreateFields(input: {
type: AiProviderType;
upstreamUrl: string | null;
authType: AiProviderAuthType | null;
upstreamUrl?: string | null;
authType?: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
}): {
upstreamUrl: string | null;
authType: AiProviderAuthType | null;
authType: AiProviderAuthType;
routingMode: AiProviderRoutingMode;
} {
const routingMode = input.routingMode ?? "url";
const routingMode =
input.type === "custom" ? (input.routingMode ?? "url") : "url";
if (routingMode === "target") {
return {
@@ -91,8 +107,8 @@ export function resolveAiProviderConfig(input: {
if (input.type === "custom") {
return {
upstreamUrl: input.upstreamUrl,
authType: input.authType,
upstreamUrl: input.upstreamUrl ?? null,
authType: input.authType ?? "bearer",
routingMode
};
}
@@ -100,7 +116,43 @@ export function resolveAiProviderConfig(input: {
const defaults = AI_PROVIDER_DEFAULTS[input.type];
return {
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
authType: input.authType ?? defaults.authType,
authType: defaults.authType,
routingMode
};
}
/**
* Strip inbound client auth headers, then set the provider auth header
* for the given authType.
*/
export function applyAiProviderAuthHeaders(
headers: Record<string, string>,
authType: AiProviderAuthType,
apiKey: string
): void {
for (const name of CONFLICTING_AUTH_HEADERS) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === name) {
delete headers[key];
}
}
}
switch (authType) {
case "bearer":
headers["Authorization"] = `Bearer ${apiKey}`;
break;
case "x-api-key":
headers["x-api-key"] = apiKey;
break;
case "x-goog-api-key":
headers["x-goog-api-key"] = apiKey;
break;
case "hec":
headers["Authorization"] = `Splunk ${apiKey}`;
break;
case "cf-aig-authorization":
headers["cf-aig-authorization"] = `Bearer ${apiKey}`;
break;
}
}
+4 -11
View File
@@ -19,9 +19,7 @@ import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
AiProviderAuthType,
AiProviderRoutingMode,
AiProviderType,
resolveAiProviderConfig
applyAiProviderAuthHeaders
} from "@server/lib/aiProviderDefaults";
import {
SESSION_COOKIE_NAME,
@@ -499,12 +497,8 @@ export async function chatCompletions(
const secret = config.getRawConfig().server.secret!;
const apiKey = decrypt(provider.apiKey, secret);
const { upstreamUrl, authType } = resolveAiProviderConfig({
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl,
authType: provider.authType as AiProviderAuthType | null,
routingMode: provider.routingMode as AiProviderRoutingMode | null
});
const upstreamUrl = provider.upstreamUrl;
const authType = provider.authType as AiProviderAuthType;
if (!upstreamUrl) {
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
@@ -541,8 +535,7 @@ export async function chatCompletions(
}
headers[key] = Array.isArray(value) ? value.join(", ") : value;
}
// TODO: temporary hardcoded auth for testing; restore bearer from authType
headers["x-api-key"] = apiKey;
applyAiProviderAuthHeaders(headers, authType, apiKey);
// No dedicated per-request TLS agent is wired up (no extra deps for
// this v1 gateway) - toggle the process-wide Node TLS check instead.
+11 -9
View File
@@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { resolveAiProviderCreateFields } from "@server/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
@@ -28,7 +29,7 @@ const bodySchema = z
type: aiProviderTypeSchema,
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
authType: aiAuthTypeSchema.optional(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
@@ -101,8 +102,12 @@ export async function createAiProvider(
const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null;
const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null;
const now = Date.now();
const resolvedRoutingMode =
type === "custom" ? (routingMode ?? "url") : "url";
const resolved = resolveAiProviderCreateFields({
type,
upstreamUrl,
authType,
routingMode
});
const [provider] = await db
.insert(aiProviders)
@@ -110,14 +115,11 @@ export async function createAiProvider(
orgId,
name,
type,
upstreamUrl:
resolvedRoutingMode === "target"
? null
: (upstreamUrl ?? null),
upstreamUrl: resolved.upstreamUrl,
apiKey: encryptedApiKey,
apiKeyLastChars,
authType: authType ?? null,
routingMode: resolvedRoutingMode,
authType: resolved.authType,
routingMode: resolved.routingMode,
skipTlsVerification: skipTlsVerification ?? false,
enabled: enabled ?? true,
createdAt: now,
+4 -15
View File
@@ -1,11 +1,6 @@
import type { AiModel, AiProvider } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
import {
resolveAiProviderConfig,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
import type { AiProviderAuthType } from "@server/lib/aiProviderDefaults";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
@@ -13,7 +8,7 @@ export type AiProviderPublic = Omit<AiProvider, "apiKey"> & {
/** Decrypted API key. Only included on get/create/update of a single provider. */
apiKey?: string | null;
effectiveUpstreamUrl: string | null;
effectiveAuthType: AiProviderAuthType | null;
effectiveAuthType: AiProviderAuthType;
};
export type ListAiProvidersResponse = PaginatedResponse<{
@@ -45,12 +40,6 @@ export function toPublicAiProvider(
options?: { includeApiKey?: boolean }
): AiProviderPublic {
const { apiKey: encryptedApiKey, ...rest } = provider;
const resolved = resolveAiProviderConfig({
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl,
authType: provider.authType as AiProviderAuthType | null,
routingMode: provider.routingMode as AiProviderRoutingMode | null
});
let apiKey: string | null | undefined;
if (options?.includeApiKey) {
@@ -67,7 +56,7 @@ export function toPublicAiProvider(
return {
...rest,
...(options?.includeApiKey ? { apiKey } : {}),
effectiveUpstreamUrl: resolved.upstreamUrl,
effectiveAuthType: resolved.authType
effectiveUpstreamUrl: provider.upstreamUrl,
effectiveAuthType: provider.authType as AiProviderAuthType
};
}
+5 -12
View File
@@ -19,6 +19,7 @@ import {
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import type {
AiProviderAuthType,
AiProviderRoutingMode,
AiProviderType
} from "@server/lib/aiProviderDefaults";
@@ -31,7 +32,7 @@ const bodySchema = z.strictObject({
name: z.string().nonempty().optional(),
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
authType: aiAuthTypeSchema.optional(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
@@ -116,17 +117,16 @@ export async function updateAiProvider(
body.upstreamUrl !== undefined
? body.upstreamUrl
: existing.upstreamUrl;
const nextAuthType =
const nextAuthType: AiProviderAuthType =
body.authType !== undefined
? body.authType
: (existing.authType ??
(providerType === "custom" ? "bearer" : null));
: (existing.authType as AiProviderAuthType);
const validation = z
.object({
type: aiProviderTypeSchema,
upstreamUrl: z.string().nullable().optional(),
authType: aiAuthTypeSchema.nullable().optional(),
authType: aiAuthTypeSchema,
routingMode: aiRoutingModeSchema.optional()
})
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
@@ -167,13 +167,6 @@ export async function updateAiProvider(
}
if (body.authType !== undefined) {
updateData.authType = body.authType;
} else if (
providerType === "custom" &&
!existing.authType &&
nextAuthType
) {
// Backfill required authType for custom providers created without one
updateData.authType = nextAuthType;
}
if (body.apiKey !== undefined) {
+4 -2
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import {
AI_PROVIDER_AUTH_TYPES,
providerRequiresUpstreamUrl,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
@@ -17,7 +19,7 @@ export const aiProviderTypeSchema = z.enum([
"custom"
]);
export const aiAuthTypeSchema = z.enum(["bearer"]);
export const aiAuthTypeSchema = z.enum(AI_PROVIDER_AUTH_TYPES);
export const aiRoutingModeSchema = z.enum(["url", "target"]);
@@ -25,7 +27,7 @@ export function refineProviderUpstreamFields(
data: {
type: AiProviderType;
upstreamUrl?: string | null;
authType?: "bearer" | null;
authType?: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
},
ctx: z.RefinementCtx
@@ -40,7 +40,10 @@ import {
type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type {
AiProviderAuthType,
AiProviderType
} from "@server/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
@@ -63,7 +66,7 @@ export default function AiProviderAuthenticationPage() {
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: provider.apiKey ?? "",
authType: (provider.authType as "bearer" | null) ?? "bearer",
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
skipTlsVerification: provider.skipTlsVerification,
enabled: provider.enabled
@@ -91,7 +94,7 @@ export default function AiProviderAuthenticationPage() {
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: updated.apiKey ?? "",
authType: (updated.authType as "bearer" | null) ?? "bearer",
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
skipTlsVerification: updated.skipTlsVerification,
enabled: updated.enabled
@@ -164,6 +167,26 @@ export default function AiProviderAuthenticationPage() {
"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>
@@ -45,7 +45,10 @@ import {
} from "@app/lib/aiProviderFormSchema";
import { aiProviderQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type {
AiProviderAuthType,
AiProviderType
} from "@server/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
@@ -72,7 +75,7 @@ export default function AiProviderNetworkPage() {
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: "",
authType: (provider.authType as "bearer" | null) ?? "bearer",
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
skipTlsVerification: provider.skipTlsVerification,
enabled: provider.enabled
@@ -115,7 +118,7 @@ export default function AiProviderNetworkPage() {
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: "",
authType: (updated.authType as "bearer" | null) ?? "bearer",
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
skipTlsVerification: updated.skipTlsVerification,
enabled: updated.enabled
@@ -527,6 +527,26 @@ export default function CreateAiProviderPage() {
"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>
+12 -9
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
providerRequiresUpstreamUrl,
type AiProviderType
@@ -23,7 +24,7 @@ export const aiProviderFormSchema = z
type: z.enum(aiProviderTypeValues),
upstreamUrl: z.string().optional().nullable(),
apiKey: z.string().optional(),
authType: z.enum(["bearer"]).optional().nullable(),
authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(),
routingMode: z.enum(["url", "target"]).optional(),
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
@@ -138,7 +139,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) {
authType:
values.type === "custom"
? (values.authType ?? "bearer")
: (values.authType ?? undefined),
: undefined,
skipTlsVerification: values.skipTlsVerification,
enabled: values.enabled ?? true
};
@@ -159,14 +160,14 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
name: values.name.trim(),
routingMode: values.type === "custom" ? routingMode : "url",
upstreamUrl,
authType:
values.type === "custom"
? (values.authType ?? "bearer")
: (values.authType ?? null),
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();
}
@@ -184,11 +185,13 @@ export function toAiProviderNetworkPayload(values: AiProviderFormValues) {
}
export function toAiProviderAuthPayload(values: AiProviderFormValues) {
const full = toAiProviderUpdatePayload(values);
return {
authType: full.authType,
const payload: Record<string, unknown> = {
...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {})
};
if (values.type === "custom") {
payload.authType = values.authType ?? "bearer";
}
return payload;
}
export function toAiProviderConfigurationPayload(values: AiProviderFormValues) {