mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-06 12:41:25 +02:00
add headers to provider
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { CommandModule } from "yargs";
|
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 { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -132,12 +132,14 @@ export const rotateServerSecret: CommandModule<
|
|||||||
const certs = await db.select().from(certificates);
|
const certs = await db.select().from(certificates);
|
||||||
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
||||||
const webhookActions = await db.select().from(alertWebhookActions);
|
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 ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||||
console.log(`Found ${certs.length} certificate(s)`);
|
console.log(`Found ${certs.length} certificate(s)`);
|
||||||
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
||||||
console.log(`Found ${webhookActions.length} alert webhook action(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
|
// Prepare all decrypted and re-encrypted values
|
||||||
console.log("\nDecrypting and re-encrypting values...");
|
console.log("\nDecrypting and re-encrypting values...");
|
||||||
@@ -171,11 +173,18 @@ export const rotateServerSecret: CommandModule<
|
|||||||
encryptedConfig: string;
|
encryptedConfig: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiProviderUpdate = {
|
||||||
|
providerId: number;
|
||||||
|
encryptedApiKey: string | null;
|
||||||
|
encryptedHeaders: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const idpUpdates: IdpUpdate[] = [];
|
const idpUpdates: IdpUpdate[] = [];
|
||||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||||
const certUpdates: CertUpdate[] = [];
|
const certUpdates: CertUpdate[] = [];
|
||||||
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
||||||
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
||||||
|
const aiProviderUpdates: AiProviderUpdate[] = [];
|
||||||
|
|
||||||
// Process idpOidcConfig entries
|
// Process idpOidcConfig entries
|
||||||
for (const idpConfig of idpConfigs) {
|
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
|
// Perform all database updates in a single transaction
|
||||||
console.log("\nUpdating database in transaction...");
|
console.log("\nUpdating database in transaction...");
|
||||||
await db.transaction(async (trx) => {
|
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)`);
|
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 ${certUpdates.length} certificate(s)`);
|
||||||
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
||||||
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
||||||
|
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
|
||||||
|
|
||||||
// Update config file with new secret
|
// Update config file with new secret
|
||||||
console.log("\nUpdating config file...");
|
console.log("\nUpdating config file...");
|
||||||
@@ -402,6 +454,7 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(` - Certificates: ${certUpdates.length}`);
|
console.log(` - Certificates: ${certUpdates.length}`);
|
||||||
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
||||||
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
||||||
|
console.log(` - AI providers: ${aiProviderUpdates.length}`);
|
||||||
console.log(
|
console.log(
|
||||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1680,6 +1680,7 @@
|
|||||||
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
|
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
|
||||||
"aiProviderApiKey": "API Key",
|
"aiProviderApiKey": "API Key",
|
||||||
"aiProviderApiKeyDescription": "API key used to authenticate requests to this provider",
|
"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",
|
"aiProviderApiKeyLastChars": "API Key",
|
||||||
"aiProviderAuthType": "Auth Type",
|
"aiProviderAuthType": "Auth Type",
|
||||||
"aiProviderAuthTypeSearch": "Search auth types...",
|
"aiProviderAuthTypeSearch": "Search auth types...",
|
||||||
|
|||||||
@@ -1660,6 +1660,7 @@ export const aiProviders = pgTable("aiProviders", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("url"),
|
.default("url"),
|
||||||
capabilities: text("capabilities").notNull().default("[]"),
|
capabilities: text("capabilities").notNull().default("[]"),
|
||||||
|
headers: text("headers"), // JSON array of { name, value }
|
||||||
skipTlsVerification: boolean("skipTlsVerification")
|
skipTlsVerification: boolean("skipTlsVerification")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -1642,6 +1642,7 @@ export const aiProviders = sqliteTable("aiProviders", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("url"),
|
.default("url"),
|
||||||
capabilities: text("capabilities").notNull().default("[]"),
|
capabilities: text("capabilities").notNull().default("[]"),
|
||||||
|
headers: text("headers"), // JSON array of { name, value }
|
||||||
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
|
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { decrypt, encrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
export type AiProviderType =
|
export type AiProviderType =
|
||||||
| "openai"
|
| "openai"
|
||||||
| "anthropic"
|
| "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.
|
* Apply provider auth to upstream headers.
|
||||||
* - Injected modes: strip client auth headers, then set the provider key.
|
* - Injected modes: strip client auth headers, then set the provider key.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { decrypt } from "@server/lib/crypto";
|
|||||||
import {
|
import {
|
||||||
AiProviderAuthType,
|
AiProviderAuthType,
|
||||||
applyAiProviderAuthHeaders,
|
applyAiProviderAuthHeaders,
|
||||||
|
applyAiProviderCustomHeaders,
|
||||||
authTypeRequiresApiKey
|
authTypeRequiresApiKey
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
import {
|
import {
|
||||||
@@ -536,6 +537,11 @@ export async function handleAiGatewayProxy(
|
|||||||
}
|
}
|
||||||
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
||||||
}
|
}
|
||||||
|
applyAiProviderCustomHeaders(
|
||||||
|
headers,
|
||||||
|
provider.headers,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
applyAiProviderAuthHeaders(headers, authType, apiKey);
|
applyAiProviderAuthHeaders(headers, authType, apiKey);
|
||||||
|
|
||||||
// No dedicated per-request TLS agent is wired up (no extra deps for
|
// 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 {
|
import {
|
||||||
aiAuthTypeSchema,
|
aiAuthTypeSchema,
|
||||||
aiCapabilitiesSchema,
|
aiCapabilitiesSchema,
|
||||||
|
aiProviderHeadersSchema,
|
||||||
aiProviderTypeSchema,
|
aiProviderTypeSchema,
|
||||||
aiRoutingModeSchema,
|
aiRoutingModeSchema,
|
||||||
refineProviderUpstreamFields
|
refineProviderUpstreamFields
|
||||||
} from "@server/routers/aiProvider/validation";
|
} from "@server/routers/aiProvider/validation";
|
||||||
|
import { serializeAiProviderHeaders } from "@server/lib/aiProviderDefaults";
|
||||||
import {
|
import {
|
||||||
resolveCapabilitiesForCreate,
|
resolveCapabilitiesForCreate,
|
||||||
serializeCapabilities
|
serializeCapabilities
|
||||||
@@ -37,6 +39,7 @@ const bodySchema = z
|
|||||||
authType: aiAuthTypeSchema.optional(),
|
authType: aiAuthTypeSchema.optional(),
|
||||||
routingMode: aiRoutingModeSchema.optional(),
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
capabilities: aiCapabilitiesSchema.optional(),
|
capabilities: aiCapabilitiesSchema.optional(),
|
||||||
|
headers: aiProviderHeadersSchema,
|
||||||
skipTlsVerification: z.boolean().optional(),
|
skipTlsVerification: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
@@ -101,6 +104,7 @@ export async function createAiProvider(
|
|||||||
authType,
|
authType,
|
||||||
routingMode,
|
routingMode,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
headers,
|
||||||
skipTlsVerification,
|
skipTlsVerification,
|
||||||
enabled
|
enabled
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
@@ -132,6 +136,7 @@ export async function createAiProvider(
|
|||||||
authType: resolved.authType,
|
authType: resolved.authType,
|
||||||
routingMode: resolved.routingMode,
|
routingMode: resolved.routingMode,
|
||||||
capabilities: serializeCapabilities(resolvedCapabilities),
|
capabilities: serializeCapabilities(resolvedCapabilities),
|
||||||
|
headers: serializeAiProviderHeaders(headers, key),
|
||||||
skipTlsVerification: skipTlsVerification ?? false,
|
skipTlsVerification: skipTlsVerification ?? false,
|
||||||
enabled: enabled ?? true,
|
enabled: enabled ?? true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { AiModel, AiProvider } from "@server/db";
|
import type { AiModel, AiProvider } from "@server/db";
|
||||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
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 {
|
import {
|
||||||
parseCapabilities,
|
parseCapabilities,
|
||||||
type AiCapability
|
type AiCapability
|
||||||
@@ -8,10 +12,13 @@ import {
|
|||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export type AiProviderPublic = Omit<AiProvider, "apiKey" | "capabilities"> & {
|
export type AiProviderPublic = Omit<
|
||||||
/** Decrypted API key. Only included on get/create/update of a single provider. */
|
AiProvider,
|
||||||
|
"apiKey" | "capabilities" | "headers"
|
||||||
|
> & {
|
||||||
apiKey?: string | null;
|
apiKey?: string | null;
|
||||||
capabilities: AiCapability[];
|
capabilities: AiCapability[];
|
||||||
|
headers: AiProviderHeader[] | null;
|
||||||
effectiveUpstreamUrl: string | null;
|
effectiveUpstreamUrl: string | null;
|
||||||
effectiveAuthType: AiProviderAuthType;
|
effectiveAuthType: AiProviderAuthType;
|
||||||
};
|
};
|
||||||
@@ -47,6 +54,7 @@ export function toPublicAiProvider(
|
|||||||
const {
|
const {
|
||||||
apiKey: encryptedApiKey,
|
apiKey: encryptedApiKey,
|
||||||
capabilities: rawCapabilities,
|
capabilities: rawCapabilities,
|
||||||
|
headers: rawHeaders,
|
||||||
...rest
|
...rest
|
||||||
} = provider;
|
} = provider;
|
||||||
|
|
||||||
@@ -62,10 +70,16 @@ export function toPublicAiProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedHeaders = parseAiProviderHeaders(
|
||||||
|
rawHeaders,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
...(options?.includeApiKey ? { apiKey } : {}),
|
...(options?.includeApiKey ? { apiKey } : {}),
|
||||||
capabilities: parseCapabilities(rawCapabilities),
|
capabilities: parseCapabilities(rawCapabilities),
|
||||||
|
headers: parsedHeaders.length > 0 ? parsedHeaders : null,
|
||||||
effectiveUpstreamUrl: provider.upstreamUrl,
|
effectiveUpstreamUrl: provider.upstreamUrl,
|
||||||
effectiveAuthType: provider.authType as AiProviderAuthType
|
effectiveAuthType: provider.authType as AiProviderAuthType
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
|||||||
import {
|
import {
|
||||||
aiAuthTypeSchema,
|
aiAuthTypeSchema,
|
||||||
aiCapabilitiesSchema,
|
aiCapabilitiesSchema,
|
||||||
|
aiProviderHeadersSchema,
|
||||||
aiProviderTypeSchema,
|
aiProviderTypeSchema,
|
||||||
aiRoutingModeSchema,
|
aiRoutingModeSchema,
|
||||||
refineProviderUpstreamFields
|
refineProviderUpstreamFields
|
||||||
} from "@server/routers/aiProvider/validation";
|
} from "@server/routers/aiProvider/validation";
|
||||||
import type {
|
import {
|
||||||
AiProviderAuthType,
|
serializeAiProviderHeaders,
|
||||||
AiProviderRoutingMode,
|
type AiProviderAuthType,
|
||||||
AiProviderType
|
type AiProviderRoutingMode,
|
||||||
|
type AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
import {
|
import {
|
||||||
parseCapabilities,
|
parseCapabilities,
|
||||||
@@ -40,6 +42,7 @@ const bodySchema = z.strictObject({
|
|||||||
authType: aiAuthTypeSchema.optional(),
|
authType: aiAuthTypeSchema.optional(),
|
||||||
routingMode: aiRoutingModeSchema.optional(),
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
capabilities: aiCapabilitiesSchema.optional(),
|
capabilities: aiCapabilitiesSchema.optional(),
|
||||||
|
headers: aiProviderHeadersSchema,
|
||||||
skipTlsVerification: z.boolean().optional(),
|
skipTlsVerification: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
});
|
});
|
||||||
@@ -202,6 +205,11 @@ export async function updateAiProvider(
|
|||||||
updateData.apiKeyLastChars = body.apiKey.slice(-4);
|
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
|
const [provider] = await db
|
||||||
.update(aiProviders)
|
.update(aiProviders)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
|
|||||||
@@ -28,6 +28,49 @@ export const aiCapabilitySchema = z.enum(AI_CAPABILITIES);
|
|||||||
|
|
||||||
export const aiCapabilitiesSchema = z.array(aiCapabilitySchema);
|
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(
|
export function refineProviderUpstreamFields(
|
||||||
data: {
|
data: {
|
||||||
type: AiProviderType;
|
type: AiProviderType;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -66,6 +67,7 @@ export default function AiProviderNetworkPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [headersValid, setHeadersValid] = useState(true);
|
||||||
const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null);
|
const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null);
|
||||||
|
|
||||||
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
|
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
|
||||||
@@ -79,6 +81,7 @@ export default function AiProviderNetworkPage() {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
|
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
|
||||||
routingMode: (provider.routingMode as "url" | "target") ?? "url",
|
routingMode: (provider.routingMode as "url" | "target") ?? "url",
|
||||||
|
headers: provider.headers ?? [],
|
||||||
skipTlsVerification: provider.skipTlsVerification,
|
skipTlsVerification: provider.skipTlsVerification,
|
||||||
enabled: provider.enabled
|
enabled: provider.enabled
|
||||||
}
|
}
|
||||||
@@ -122,6 +125,7 @@ export default function AiProviderNetworkPage() {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
|
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
|
||||||
routingMode: (updated.routingMode as "url" | "target") ?? "url",
|
routingMode: (updated.routingMode as "url" | "target") ?? "url",
|
||||||
|
headers: updated.headers ?? [],
|
||||||
skipTlsVerification: updated.skipTlsVerification,
|
skipTlsVerification: updated.skipTlsVerification,
|
||||||
enabled: updated.enabled
|
enabled: updated.enabled
|
||||||
});
|
});
|
||||||
@@ -310,6 +314,38 @@ export default function AiProviderNetworkPage() {
|
|||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</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>
|
</SettingsFormGrid>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -346,7 +382,7 @@ export default function AiProviderNetworkPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
disabled={saveLoading}
|
disabled={saveLoading || !headersValid}
|
||||||
form="ai-provider-network-form"
|
form="ai-provider-network-form"
|
||||||
>
|
>
|
||||||
{t("saveSettings")}
|
{t("saveSettings")}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
capabilityLabelKey
|
capabilityLabelKey
|
||||||
} from "@app/components/AiProviderCapabilitiesSelect";
|
} from "@app/components/AiProviderCapabilitiesSelect";
|
||||||
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
|
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
|
||||||
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -68,6 +69,7 @@ export default function CreateAiProviderPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [headersValid, setHeadersValid] = useState(true);
|
||||||
const targetsRef = useRef<LocalTarget[]>([]);
|
const targetsRef = useRef<LocalTarget[]>([]);
|
||||||
|
|
||||||
const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]);
|
const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]);
|
||||||
@@ -82,6 +84,7 @@ export default function CreateAiProviderPage() {
|
|||||||
authType: defaultAuthTypeForProvider("openai"),
|
authType: defaultAuthTypeForProvider("openai"),
|
||||||
routingMode: "url",
|
routingMode: "url",
|
||||||
capabilities: defaultCapabilitiesForProvider("openai"),
|
capabilities: defaultCapabilitiesForProvider("openai"),
|
||||||
|
headers: [],
|
||||||
skipTlsVerification: false,
|
skipTlsVerification: false,
|
||||||
enabled: true
|
enabled: true
|
||||||
}
|
}
|
||||||
@@ -531,6 +534,38 @@ export default function CreateAiProviderPage() {
|
|||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</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>
|
</SettingsFormGrid>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|
||||||
@@ -663,7 +698,7 @@ export default function CreateAiProviderPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading || !headersValid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export function createAiProviderFormSchema(t: TranslateFn) {
|
|||||||
authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(),
|
authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(),
|
||||||
routingMode: z.enum(["url", "target"]).optional(),
|
routingMode: z.enum(["url", "target"]).optional(),
|
||||||
capabilities: z.array(z.enum(AI_CAPABILITIES)).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(),
|
skipTlsVerification: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
@@ -185,6 +189,8 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) {
|
|||||||
authType: values.authType ?? "bearer",
|
authType: values.authType ?? "bearer",
|
||||||
capabilities:
|
capabilities:
|
||||||
values.type === "custom" ? (values.capabilities ?? []) : undefined,
|
values.type === "custom" ? (values.capabilities ?? []) : undefined,
|
||||||
|
headers:
|
||||||
|
values.headers && values.headers.length > 0 ? values.headers : null,
|
||||||
skipTlsVerification: values.skipTlsVerification,
|
skipTlsVerification: values.skipTlsVerification,
|
||||||
enabled: values.enabled ?? true
|
enabled: values.enabled ?? true
|
||||||
};
|
};
|
||||||
@@ -226,7 +232,9 @@ export function toAiProviderNetworkPayload(values: AiProviderFormValues) {
|
|||||||
return {
|
return {
|
||||||
routingMode: full.routingMode,
|
routingMode: full.routingMode,
|
||||||
upstreamUrl: full.upstreamUrl,
|
upstreamUrl: full.upstreamUrl,
|
||||||
skipTlsVerification: full.skipTlsVerification
|
skipTlsVerification: full.skipTlsVerification,
|
||||||
|
headers:
|
||||||
|
values.headers && values.headers.length > 0 ? values.headers : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user