mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-06 20:51:36 +02:00
add headers to provider
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user