diff --git a/messages/en-US.json b/messages/en-US.json index fa9202b2f..257c56c42 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 868b2a121..7883345da 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -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() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8af639238..e586c4e02 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -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() diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index 6cfb816b7..d803e7613 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -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, + 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; + } +} diff --git a/server/routers/aiGateway/chatCompletions.ts b/server/routers/aiGateway/chatCompletions.ts index 2f1bfc451..e1bb8bbc9 100644 --- a/server/routers/aiGateway/chatCompletions.ts +++ b/server/routers/aiGateway/chatCompletions.ts @@ -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. diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 6fb3f395a..6188601a3 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -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, diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 3fa966785..7ac492056 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -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 & { /** 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 }; } diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 35e27fba2..82224c28b 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -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) { diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index a5e2aa962..42476c710 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -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 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 02d1d2142..264e6b252 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx @@ -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" )} + + {t( + "aiProviderAuthTypeXApiKey" + )} + + + {t( + "aiProviderAuthTypeXGoogApiKey" + )} + + + {t( + "aiProviderAuthTypeHec" + )} + + + {t( + "aiProviderAuthTypeCfAigAuthorization" + )} + 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 fbe9669cf..270ba3c68 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx @@ -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 diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index b84462153..5ad4c4ec7 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -527,6 +527,26 @@ export default function CreateAiProviderPage() { "aiProviderAuthTypeBearer" )} + + {t( + "aiProviderAuthTypeXApiKey" + )} + + + {t( + "aiProviderAuthTypeXGoogApiKey" + )} + + + {t( + "aiProviderAuthTypeHec" + )} + + + {t( + "aiProviderAuthTypeCfAigAuthorization" + )} + diff --git a/src/lib/aiProviderFormSchema.ts b/src/lib/aiProviderFormSchema.ts index 26245620c..8eb038fba 100644 --- a/src/lib/aiProviderFormSchema.ts +++ b/src/lib/aiProviderFormSchema.ts @@ -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 = { ...(values.apiKey !== undefined ? { apiKey: values.apiKey.trim() } : {}) }; + if (values.type === "custom") { + payload.authType = values.authType ?? "bearer"; + } + return payload; } export function toAiProviderConfigurationPayload(values: AiProviderFormValues) {