add headers to provider

This commit is contained in:
miloschwartz
2026-08-05 17:42:58 -04:00
parent 1f3fff4a9d
commit 3d7e322bf9
13 changed files with 271 additions and 11 deletions
+54 -1
View File
@@ -1,5 +1,5 @@
import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
@@ -132,12 +132,14 @@ export const rotateServerSecret: CommandModule<
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
const webhookActions = await db.select().from(alertWebhookActions);
const providers = await db.select().from(aiProviders);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
console.log(`Found ${providers.length} AI provider(s)`);
// Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values...");
@@ -171,11 +173,18 @@ export const rotateServerSecret: CommandModule<
encryptedConfig: string;
};
type AiProviderUpdate = {
providerId: number;
encryptedApiKey: string | null;
encryptedHeaders: string | null;
};
const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
const aiProviderUpdates: AiProviderUpdate[] = [];
// Process idpOidcConfig entries
for (const idpConfig of idpConfigs) {
@@ -306,6 +315,37 @@ export const rotateServerSecret: CommandModule<
}
}
// Process aiProviders entries (apiKey + headers)
for (const provider of providers) {
try {
if (!provider.apiKey && !provider.headers) {
continue;
}
const encryptedApiKey = provider.apiKey
? encrypt(decrypt(provider.apiKey, oldSecret), newSecret)
: null;
const encryptedHeaders = provider.headers
? encrypt(
decrypt(provider.headers, oldSecret),
newSecret
)
: null;
aiProviderUpdates.push({
providerId: provider.providerId,
encryptedApiKey,
encryptedHeaders
});
} catch (error) {
console.error(
`Error processing AI provider ${provider.providerId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction
console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => {
@@ -376,6 +416,17 @@ export const rotateServerSecret: CommandModule<
)
);
}
// Update AI provider entries
for (const update of aiProviderUpdates) {
await trx
.update(aiProviders)
.set({
apiKey: update.encryptedApiKey,
headers: update.encryptedHeaders
})
.where(eq(aiProviders.providerId, update.providerId));
}
});
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
@@ -383,6 +434,7 @@ export const rotateServerSecret: CommandModule<
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
// Update config file with new secret
console.log("\nUpdating config file...");
@@ -402,6 +454,7 @@ export const rotateServerSecret: CommandModule<
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log(` - AI providers: ${aiProviderUpdates.length}`);
console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.`
);
+1
View File
@@ -1680,6 +1680,7 @@
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
"aiProviderApiKey": "API Key",
"aiProviderApiKeyDescription": "API key used to authenticate requests to this provider",
"aiProviderCustomHeadersDescription": "Headers sent on every request to this provider. Newline separated: Header-Name: value",
"aiProviderApiKeyLastChars": "API Key",
"aiProviderAuthType": "Auth Type",
"aiProviderAuthTypeSearch": "Search auth types...",
+1
View File
@@ -1660,6 +1660,7 @@ export const aiProviders = pgTable("aiProviders", {
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: boolean("skipTlsVerification")
.notNull()
.default(false),
+1
View File
@@ -1642,6 +1642,7 @@ export const aiProviders = sqliteTable("aiProviders", {
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
.notNull()
.default(false),
+49
View File
@@ -1,3 +1,5 @@
import { decrypt, encrypt } from "@server/lib/crypto";
export type AiProviderType =
| "openai"
| "anthropic"
@@ -127,6 +129,53 @@ export function resolveAiProviderCreateFields(input: {
};
}
export type AiProviderHeader = { name: string; value: string };
export function serializeAiProviderHeaders(
headers: AiProviderHeader[] | null | undefined,
secret: string
): string | null {
if (!headers || headers.length === 0) {
return null;
}
return encrypt(JSON.stringify(headers), secret);
}
export function parseAiProviderHeaders(
raw: string | null | undefined,
secret: string
): AiProviderHeader[] {
if (!raw) {
return [];
}
try {
const decrypted = decrypt(raw, secret);
const parsed = JSON.parse(decrypted);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(h): h is AiProviderHeader =>
h != null &&
typeof h === "object" &&
typeof h.name === "string" &&
typeof h.value === "string"
);
} catch {
return [];
}
}
export function applyAiProviderCustomHeaders(
headers: Record<string, string>,
raw: string | null | undefined,
secret: string
): void {
for (const { name, value } of parseAiProviderHeaders(raw, secret)) {
headers[name] = value;
}
}
/**
* Apply provider auth to upstream headers.
* - Injected modes: strip client auth headers, then set the provider key.
+6
View File
@@ -20,6 +20,7 @@ import { decrypt } from "@server/lib/crypto";
import {
AiProviderAuthType,
applyAiProviderAuthHeaders,
applyAiProviderCustomHeaders,
authTypeRequiresApiKey
} from "@server/lib/aiProviderDefaults";
import {
@@ -536,6 +537,11 @@ export async function handleAiGatewayProxy(
}
headers[key] = Array.isArray(value) ? value.join(", ") : value;
}
applyAiProviderCustomHeaders(
headers,
provider.headers,
config.getRawConfig().server.secret!
);
applyAiProviderAuthHeaders(headers, authType, apiKey);
// No dedicated per-request TLS agent is wired up (no extra deps for
@@ -15,10 +15,12 @@ import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiCapabilitiesSchema,
aiProviderHeadersSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import { serializeAiProviderHeaders } from "@server/lib/aiProviderDefaults";
import {
resolveCapabilitiesForCreate,
serializeCapabilities
@@ -37,6 +39,7 @@ const bodySchema = z
authType: aiAuthTypeSchema.optional(),
routingMode: aiRoutingModeSchema.optional(),
capabilities: aiCapabilitiesSchema.optional(),
headers: aiProviderHeadersSchema,
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
})
@@ -101,6 +104,7 @@ export async function createAiProvider(
authType,
routingMode,
capabilities,
headers,
skipTlsVerification,
enabled
} = parsedBody.data;
@@ -132,6 +136,7 @@ export async function createAiProvider(
authType: resolved.authType,
routingMode: resolved.routingMode,
capabilities: serializeCapabilities(resolvedCapabilities),
headers: serializeAiProviderHeaders(headers, key),
skipTlsVerification: skipTlsVerification ?? false,
enabled: enabled ?? true,
createdAt: now,
+17 -3
View File
@@ -1,6 +1,10 @@
import type { AiModel, AiProvider } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
import type { AiProviderAuthType } from "@server/lib/aiProviderDefaults";
import {
parseAiProviderHeaders,
type AiProviderAuthType,
type AiProviderHeader
} from "@server/lib/aiProviderDefaults";
import {
parseCapabilities,
type AiCapability
@@ -8,10 +12,13 @@ import {
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
export type AiProviderPublic = Omit<AiProvider, "apiKey" | "capabilities"> & {
/** Decrypted API key. Only included on get/create/update of a single provider. */
export type AiProviderPublic = Omit<
AiProvider,
"apiKey" | "capabilities" | "headers"
> & {
apiKey?: string | null;
capabilities: AiCapability[];
headers: AiProviderHeader[] | null;
effectiveUpstreamUrl: string | null;
effectiveAuthType: AiProviderAuthType;
};
@@ -47,6 +54,7 @@ export function toPublicAiProvider(
const {
apiKey: encryptedApiKey,
capabilities: rawCapabilities,
headers: rawHeaders,
...rest
} = provider;
@@ -62,10 +70,16 @@ export function toPublicAiProvider(
}
}
const parsedHeaders = parseAiProviderHeaders(
rawHeaders,
config.getRawConfig().server.secret!
);
return {
...rest,
...(options?.includeApiKey ? { apiKey } : {}),
capabilities: parseCapabilities(rawCapabilities),
headers: parsedHeaders.length > 0 ? parsedHeaders : null,
effectiveUpstreamUrl: provider.upstreamUrl,
effectiveAuthType: provider.authType as AiProviderAuthType
};
+12 -4
View File
@@ -15,14 +15,16 @@ import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiCapabilitiesSchema,
aiProviderHeadersSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import type {
AiProviderAuthType,
AiProviderRoutingMode,
AiProviderType
import {
serializeAiProviderHeaders,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
import {
parseCapabilities,
@@ -40,6 +42,7 @@ const bodySchema = z.strictObject({
authType: aiAuthTypeSchema.optional(),
routingMode: aiRoutingModeSchema.optional(),
capabilities: aiCapabilitiesSchema.optional(),
headers: aiProviderHeadersSchema,
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
});
@@ -202,6 +205,11 @@ export async function updateAiProvider(
updateData.apiKeyLastChars = body.apiKey.slice(-4);
}
if (body.headers !== undefined) {
const key = config.getRawConfig().server.secret!;
updateData.headers = serializeAiProviderHeaders(body.headers, key);
}
const [provider] = await db
.update(aiProviders)
.set(updateData)
+43
View File
@@ -28,6 +28,49 @@ export const aiCapabilitySchema = z.enum(AI_CAPABILITIES);
export const aiCapabilitiesSchema = z.array(aiCapabilitySchema);
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValue = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export const aiProviderHeadersSchema = z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional()
.superRefine((headers, ctx) => {
if (!headers) {
return;
}
for (const [index, header] of headers.entries()) {
if (!validHeaderName.test(header.name)) {
ctx.addIssue({
code: "custom",
message:
"Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~).",
path: [index, "name"]
});
}
if (!validHeaderValue.test(header.value)) {
ctx.addIssue({
code: "custom",
message:
"Header values may only contain printable ASCII characters and horizontal whitespace.",
path: [index, "value"]
});
}
if (
templatePattern.test(header.name) ||
templatePattern.test(header.value)
) {
ctx.addIssue({
code: "custom",
message:
"Header names and values must not contain template expressions such as {{value}}.",
path: [index]
});
}
}
});
export function refineProviderUpstreamFields(
data: {
type: AiProviderType;
@@ -21,6 +21,7 @@ import {
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { HeadersInput } from "@app/components/HeadersInput";
import { Button } from "@app/components/ui/button";
import {
Form,
@@ -66,6 +67,7 @@ export default function AiProviderNetworkPage() {
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const [headersValid, setHeadersValid] = useState(true);
const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null);
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
@@ -79,6 +81,7 @@ export default function AiProviderNetworkPage() {
apiKey: "",
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
headers: provider.headers ?? [],
skipTlsVerification: provider.skipTlsVerification,
enabled: provider.enabled
}
@@ -122,6 +125,7 @@ export default function AiProviderNetworkPage() {
apiKey: "",
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
headers: updated.headers ?? [],
skipTlsVerification: updated.skipTlsVerification,
enabled: updated.enabled
});
@@ -310,6 +314,38 @@ export default function AiProviderNetworkPage() {
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<FormField
control={form.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={
field.onChange
}
onValidityChange={
setHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderCustomHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
@@ -346,7 +382,7 @@ export default function AiProviderNetworkPage() {
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
disabled={saveLoading || !headersValid}
form="ai-provider-network-form"
>
{t("saveSettings")}
@@ -25,6 +25,7 @@ import {
capabilityLabelKey
} from "@app/components/AiProviderCapabilitiesSelect";
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
import { HeadersInput } from "@app/components/HeadersInput";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
@@ -68,6 +69,7 @@ export default function CreateAiProviderPage() {
const router = useRouter();
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [headersValid, setHeadersValid] = useState(true);
const targetsRef = useRef<LocalTarget[]>([]);
const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]);
@@ -82,6 +84,7 @@ export default function CreateAiProviderPage() {
authType: defaultAuthTypeForProvider("openai"),
routingMode: "url",
capabilities: defaultCapabilitiesForProvider("openai"),
headers: [],
skipTlsVerification: false,
enabled: true
}
@@ -531,6 +534,38 @@ export default function CreateAiProviderPage() {
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<FormField
control={form.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={
field.onChange
}
onValidityChange={
setHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderCustomHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
@@ -663,7 +698,7 @@ export default function CreateAiProviderPage() {
<Button
type="button"
loading={loading}
disabled={loading}
disabled={loading || !headersValid}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
+9 -1
View File
@@ -42,6 +42,10 @@ export function createAiProviderFormSchema(t: TranslateFn) {
authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(),
routingMode: z.enum(["url", "target"]).optional(),
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional(),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
})
@@ -185,6 +189,8 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) {
authType: values.authType ?? "bearer",
capabilities:
values.type === "custom" ? (values.capabilities ?? []) : undefined,
headers:
values.headers && values.headers.length > 0 ? values.headers : null,
skipTlsVerification: values.skipTlsVerification,
enabled: values.enabled ?? true
};
@@ -226,7 +232,9 @@ export function toAiProviderNetworkPayload(values: AiProviderFormValues) {
return {
routingMode: full.routingMode,
upstreamUrl: full.upstreamUrl,
skipTlsVerification: full.skipTlsVerification
skipTlsVerification: full.skipTlsVerification,
headers:
values.headers && values.headers.length > 0 ? values.headers : null
};
}