mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-13 16:00:02 +02:00
add virtual api key validation in verifySession
This commit is contained in:
@@ -0,0 +1,316 @@
|
|||||||
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
users,
|
||||||
|
virtualApiKeyResources,
|
||||||
|
virtualApiKeys,
|
||||||
|
type VirtualApiKey
|
||||||
|
} from "@server/db";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import {
|
||||||
|
decryptVirtualApiKeyToken,
|
||||||
|
VIRTUAL_API_KEY_PREFIX,
|
||||||
|
looksLikeVirtualApiKeyCredential
|
||||||
|
} from "@server/lib/virtualApiKey";
|
||||||
|
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
|
||||||
|
export type VirtualApiKeyCredential = {
|
||||||
|
virtualApiKeyId: string;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VirtualApiKeyUserData = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getHeader(
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
name: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!headers) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (headers[name] !== undefined) {
|
||||||
|
return headers[name];
|
||||||
|
}
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === lower) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVkCredential(
|
||||||
|
raw: string | undefined
|
||||||
|
): VirtualApiKeyCredential | null {
|
||||||
|
if (!raw || !looksLikeVirtualApiKeyCredential(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const withoutPrefix = raw.trim().slice(VIRTUAL_API_KEY_PREFIX.length);
|
||||||
|
const dot = withoutPrefix.indexOf(".");
|
||||||
|
return {
|
||||||
|
virtualApiKeyId: withoutPrefix.slice(0, dot),
|
||||||
|
secret: withoutPrefix.slice(dot + 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a virtual API key credential from provider-style auth headers.
|
||||||
|
* Checks Authorization Bearer / Splunk, x-api-key, x-goog-api-key, and
|
||||||
|
* cf-aig-authorization. First matching vk-{id}.{secret} wins.
|
||||||
|
*/
|
||||||
|
export function extractVirtualApiKeyCredential(
|
||||||
|
headers: Record<string, string> | undefined
|
||||||
|
): VirtualApiKeyCredential | null {
|
||||||
|
if (!headers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = getHeader(headers, "authorization");
|
||||||
|
if (authorization) {
|
||||||
|
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (bearerMatch) {
|
||||||
|
const credential = parseVkCredential(bearerMatch[1]);
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const splunkMatch = authorization.match(/^Splunk\s+(.+)$/i);
|
||||||
|
if (splunkMatch) {
|
||||||
|
const credential = parseVkCredential(splunkMatch[1]);
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfAig = getHeader(headers, "cf-aig-authorization");
|
||||||
|
if (cfAig) {
|
||||||
|
const bearerMatch = cfAig.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const credential = parseVkCredential(
|
||||||
|
bearerMatch ? bearerMatch[1] : cfAig
|
||||||
|
);
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of ["x-api-key", "x-goog-api-key"] as const) {
|
||||||
|
const credential = parseVkCredential(getHeader(headers, name));
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUserData(
|
||||||
|
userId: string,
|
||||||
|
orgId: string
|
||||||
|
): Promise<VirtualApiKeyUserData | undefined> {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.getRawConfig().flags?.require_email_verification &&
|
||||||
|
!user.emailVerified
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||||
|
if (userOrgRoles.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userHasResourceAccess(
|
||||||
|
userId: string,
|
||||||
|
resourceId: number,
|
||||||
|
orgId: string
|
||||||
|
): Promise<{ allowed: boolean; userData?: VirtualApiKeyUserData }> {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.getRawConfig().flags?.require_email_verification &&
|
||||||
|
!user.emailVerified
|
||||||
|
) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||||
|
if (userOrgRoles.length === 0) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = await canUserAccessResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleIds: userOrgRoles.map((r) => r.roleId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
userData: {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualKeyHasResourceAccess(
|
||||||
|
key: VirtualApiKey,
|
||||||
|
resourceId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (key.allResources) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ resourceId: virtualApiKeyResources.resourceId })
|
||||||
|
.from(virtualApiKeyResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(virtualApiKeyResources.virtualApiKeyId, key.virtualApiKeyId),
|
||||||
|
eq(virtualApiKeyResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function touchLastUsedAt(virtualApiKeyId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(virtualApiKeys)
|
||||||
|
.set({ lastUsedAt: Date.now() })
|
||||||
|
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId));
|
||||||
|
} catch {
|
||||||
|
// Best-effort; do not fail auth on audit timestamp updates.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyVirtualApiKey({
|
||||||
|
credential,
|
||||||
|
resourceId,
|
||||||
|
orgId
|
||||||
|
}: {
|
||||||
|
credential: VirtualApiKeyCredential;
|
||||||
|
resourceId: number;
|
||||||
|
orgId: string;
|
||||||
|
}): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
key?: VirtualApiKey;
|
||||||
|
userData?: VirtualApiKeyUserData;
|
||||||
|
}> {
|
||||||
|
const [key] = await db
|
||||||
|
.select()
|
||||||
|
.from(virtualApiKeys)
|
||||||
|
.where(eq(virtualApiKeys.virtualApiKeyId, credential.virtualApiKeyId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return { valid: false, error: "Virtual API key not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.orgId !== orgId) {
|
||||||
|
return { valid: false, error: "Virtual API key org mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let plaintext: string;
|
||||||
|
try {
|
||||||
|
plaintext = decryptVirtualApiKeyToken(key.token);
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: "Virtual API key secret is invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plaintext !== credential.secret) {
|
||||||
|
return { valid: false, error: "Invalid virtual API key secret" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.expiresAt && !isWithinExpirationDate(new Date(key.expiresAt))) {
|
||||||
|
return { valid: false, error: "Virtual API key has expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.kind === "manual") {
|
||||||
|
const scoped = await manualKeyHasResourceAccess(key, resourceId);
|
||||||
|
if (!scoped) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Virtual API key is not scoped to this resource"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let userData: VirtualApiKeyUserData | undefined;
|
||||||
|
if (key.userId) {
|
||||||
|
userData = await buildUserData(key.userId, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await touchLastUsedAt(key.virtualApiKeyId);
|
||||||
|
return { valid: true, key, userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.kind === "user") {
|
||||||
|
if (!key.userId) {
|
||||||
|
return { valid: false, error: "User virtual API key has no user" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await userHasResourceAccess(
|
||||||
|
key.userId,
|
||||||
|
resourceId,
|
||||||
|
orgId
|
||||||
|
);
|
||||||
|
if (!access.allowed || !access.userData) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "User is not allowed to access this resource"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await touchLastUsedAt(key.virtualApiKeyId);
|
||||||
|
return { valid: true, key, userData: access.userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, error: "Unknown virtual API key kind" };
|
||||||
|
}
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||||
|
|
||||||
export const AI_CAPABILITIES = [
|
export { AI_CAPABILITIES, type AiCapability };
|
||||||
"openai_chat",
|
|
||||||
"openai_responses",
|
|
||||||
"anthropic_messages",
|
|
||||||
"gemini_generate_content",
|
|
||||||
"bedrock_model_invoke",
|
|
||||||
"google_generate_content",
|
|
||||||
"google_raw_predict",
|
|
||||||
"bedrock_converse"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type AiCapability = (typeof AI_CAPABILITIES)[number];
|
|
||||||
|
|
||||||
export type AiCapabilityRoute = {
|
export type AiCapabilityRoute = {
|
||||||
method: "POST";
|
method: "POST";
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
export const AI_GATEWAY_TRUST_HEADER = "X-Pangolin-Ai-Gateway-Auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a Traefik-injected trust token from the server secret.
|
||||||
|
* Traefik overwrites this header on inference routes so the AI gateway can
|
||||||
|
* trust Badger-injected Remote-* identity without re-validating credentials.
|
||||||
|
*/
|
||||||
|
export function deriveAiGatewayTrustToken(secret: string): string {
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(`ai-gateway-trust:${secret}`)
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiGatewayTrustToken(): string {
|
||||||
|
const secret = config.getRawConfig().server.secret;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("Server secret is required for AI gateway trust token");
|
||||||
|
}
|
||||||
|
return deriveAiGatewayTrustToken(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAiGatewayTrustHeaderValid(
|
||||||
|
headers: Record<string, string | string[] | undefined> | undefined,
|
||||||
|
expectedToken?: string
|
||||||
|
): boolean {
|
||||||
|
if (!headers) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expected = expectedToken ?? getAiGatewayTrustToken();
|
||||||
|
const raw =
|
||||||
|
headers[AI_GATEWAY_TRUST_HEADER] ??
|
||||||
|
headers[AI_GATEWAY_TRUST_HEADER.toLowerCase()];
|
||||||
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
return typeof value === "string" && value === expected;
|
||||||
|
}
|
||||||
@@ -3,82 +3,29 @@ import {
|
|||||||
parseCapabilities,
|
parseCapabilities,
|
||||||
type AiCapability
|
type AiCapability
|
||||||
} from "@server/lib/aiCapabilities";
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import { stripVirtualApiKeyAuthHeaders } from "@app/lib/virtualApiKeyFormat";
|
||||||
|
import {
|
||||||
|
AI_PROVIDER_AUTH_TYPES,
|
||||||
|
AI_PROVIDER_DEFAULTS,
|
||||||
|
authTypeRequiresApiKey,
|
||||||
|
defaultsForProviderType,
|
||||||
|
providerRequiresUpstreamUrl,
|
||||||
|
type AiBudgetUnit,
|
||||||
|
type AiProviderAuthType,
|
||||||
|
type AiProviderRoutingMode,
|
||||||
|
type AiProviderType
|
||||||
|
} from "@app/lib/aiProviderDefaults";
|
||||||
|
|
||||||
export type AiProviderType =
|
export {
|
||||||
| "openai"
|
AI_PROVIDER_AUTH_TYPES,
|
||||||
| "anthropic"
|
AI_PROVIDER_DEFAULTS,
|
||||||
| "googleGemini"
|
authTypeRequiresApiKey,
|
||||||
| "vertexAi"
|
defaultsForProviderType,
|
||||||
| "bedrock"
|
providerRequiresUpstreamUrl,
|
||||||
| "microsoftFoundry"
|
type AiBudgetUnit,
|
||||||
| "openRouter"
|
type AiProviderAuthType,
|
||||||
| "vercelAiGateway"
|
type AiProviderRoutingMode,
|
||||||
| "custom";
|
type AiProviderType
|
||||||
|
|
||||||
export const AI_PROVIDER_AUTH_TYPES = [
|
|
||||||
"bearer",
|
|
||||||
"x-api-key",
|
|
||||||
"x-goog-api-key",
|
|
||||||
"hec",
|
|
||||||
"cf-aig-authorization",
|
|
||||||
"none",
|
|
||||||
"passthrough"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
|
|
||||||
export type AiBudgetUnit = "usd" | "tokens";
|
|
||||||
export type AiProviderRoutingMode = "url" | "target";
|
|
||||||
|
|
||||||
type AiProviderDefaults = {
|
|
||||||
upstreamUrl: string | null;
|
|
||||||
authType: AiProviderAuthType;
|
|
||||||
capabilities: readonly AiCapability[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AI_PROVIDER_DEFAULTS: Record<
|
|
||||||
Exclude<AiProviderType, "custom">,
|
|
||||||
AiProviderDefaults
|
|
||||||
> = {
|
|
||||||
openai: {
|
|
||||||
upstreamUrl: "https://api.openai.com/v1",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat", "openai_responses"]
|
|
||||||
},
|
|
||||||
anthropic: {
|
|
||||||
upstreamUrl: "https://api.anthropic.com",
|
|
||||||
authType: "x-api-key",
|
|
||||||
capabilities: ["anthropic_messages"]
|
|
||||||
},
|
|
||||||
googleGemini: {
|
|
||||||
upstreamUrl: "https://generativelanguage.googleapis.com",
|
|
||||||
authType: "x-goog-api-key",
|
|
||||||
capabilities: ["gemini_generate_content"]
|
|
||||||
},
|
|
||||||
vertexAi: {
|
|
||||||
upstreamUrl: null,
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["google_generate_content", "google_raw_predict"]
|
|
||||||
},
|
|
||||||
bedrock: {
|
|
||||||
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["bedrock_converse"]
|
|
||||||
},
|
|
||||||
microsoftFoundry: {
|
|
||||||
upstreamUrl: null,
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
|
||||||
},
|
|
||||||
openRouter: {
|
|
||||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat"]
|
|
||||||
},
|
|
||||||
vercelAiGateway: {
|
|
||||||
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat", "openai_responses"]
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONFLICTING_AUTH_HEADERS = [
|
const CONFLICTING_AUTH_HEADERS = [
|
||||||
@@ -88,23 +35,6 @@ const CONFLICTING_AUTH_HEADERS = [
|
|||||||
"cf-aig-authorization"
|
"cf-aig-authorization"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
|
|
||||||
return authType !== "none" && authType !== "passthrough";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function providerRequiresUpstreamUrl(
|
|
||||||
type: AiProviderType,
|
|
||||||
routingMode: AiProviderRoutingMode = "url"
|
|
||||||
): boolean {
|
|
||||||
if (routingMode === "target") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (type === "custom") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAiProviderCreateFields(input: {
|
export function resolveAiProviderCreateFields(input: {
|
||||||
type: AiProviderType;
|
type: AiProviderType;
|
||||||
upstreamUrl?: string | null;
|
upstreamUrl?: string | null;
|
||||||
@@ -191,15 +121,18 @@ export function applyAiProviderCustomHeaders(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply provider auth to upstream headers.
|
* Apply provider auth to upstream headers.
|
||||||
* - Injected modes: strip client auth headers, then set the provider key.
|
* - Always strips Pangolin virtual API key credentials from client auth headers.
|
||||||
* - none: strip client auth headers, send no auth.
|
* - Injected modes: strip conflicting client auth headers, then set the provider key.
|
||||||
* - passthrough: leave client auth headers as-is.
|
* - none: strip conflicting client auth headers, send no auth.
|
||||||
|
* - passthrough: leave remaining client auth headers as-is (after VAK strip).
|
||||||
*/
|
*/
|
||||||
export function applyAiProviderAuthHeaders(
|
export function applyAiProviderAuthHeaders(
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
authType: AiProviderAuthType,
|
authType: AiProviderAuthType,
|
||||||
apiKey: string | null
|
apiKey: string | null
|
||||||
): void {
|
): void {
|
||||||
|
stripVirtualApiKeyAuthHeaders(headers);
|
||||||
|
|
||||||
if (authType === "passthrough") {
|
if (authType === "passthrough") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -251,12 +184,3 @@ export function resolveCapabilitiesForCreate(input: {
|
|||||||
}
|
}
|
||||||
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
|
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultsForProviderType(
|
|
||||||
type: AiProviderType
|
|
||||||
): readonly AiCapability[] {
|
|
||||||
if (type === "custom") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return AI_PROVIDER_DEFAULTS[type].capabilities;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { resources, sites, Target, targets } from "@server/db";
|
|||||||
import createPathRewriteMiddleware from "./middleware";
|
import createPathRewriteMiddleware from "./middleware";
|
||||||
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||||
import regionalCache from "@server/lib/cache";
|
import regionalCache from "@server/lib/cache";
|
||||||
|
import {
|
||||||
|
AI_GATEWAY_TRUST_HEADER,
|
||||||
|
getAiGatewayTrustToken
|
||||||
|
} from "@server/lib/aiGatewayTrust";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
@@ -808,7 +812,8 @@ export async function getTraefikConfig(
|
|||||||
headers: {
|
headers: {
|
||||||
customRequestHeaders: {
|
customRequestHeaders: {
|
||||||
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
||||||
"p-host": fullDomain
|
"p-host": fullDomain,
|
||||||
|
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -911,7 +916,8 @@ export async function getTraefikConfig(
|
|||||||
headers: {
|
headers: {
|
||||||
customRequestHeaders: {
|
customRequestHeaders: {
|
||||||
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
||||||
"p-host": fullDomain
|
"p-host": fullDomain,
|
||||||
|
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ import config from "@server/lib/config";
|
|||||||
import { decrypt, encrypt } from "@server/lib/crypto";
|
import { decrypt, encrypt } from "@server/lib/crypto";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
export {
|
||||||
|
VIRTUAL_API_KEY_PREFIX,
|
||||||
|
formatVirtualApiKeyCredential,
|
||||||
|
formatVirtualApiKeyPreview,
|
||||||
|
looksLikeVirtualApiKeyCredential,
|
||||||
|
stripVirtualApiKeyAuthHeaders
|
||||||
|
} from "@app/lib/virtualApiKeyFormat";
|
||||||
|
|
||||||
export type MintedVirtualApiKeySecret = {
|
export type MintedVirtualApiKeySecret = {
|
||||||
virtualApiKeyId: string;
|
virtualApiKeyId: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ import {
|
|||||||
} from "#private/lib/certificates";
|
} from "#private/lib/certificates";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import regionalCache from "#private/lib/cache";
|
import regionalCache from "#private/lib/cache";
|
||||||
|
import {
|
||||||
|
AI_GATEWAY_TRUST_HEADER,
|
||||||
|
getAiGatewayTrustToken
|
||||||
|
} from "@server/lib/aiGatewayTrust";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||||
@@ -1634,18 +1638,19 @@ export async function getTraefikConfig(
|
|||||||
config.getRawConfig().traefik.additional_middlewares || [];
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
const routerMiddlewares = [badgerMiddlewareName];
|
const routerMiddlewares = [badgerMiddlewareName];
|
||||||
|
|
||||||
if (aiGatewayOverride) {
|
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
|
||||||
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
|
config_output.http.middlewares[irHeadersMiddlewareName] = {
|
||||||
config_output.http.middlewares[irHeadersMiddlewareName] = {
|
headers: {
|
||||||
headers: {
|
customRequestHeaders: {
|
||||||
customRequestHeaders: {
|
...(aiGatewayOverride && aiGatewayHost
|
||||||
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
? { Host: aiGatewayHost }
|
||||||
"p-host": fullDomain
|
: {}),
|
||||||
}
|
...(aiGatewayOverride ? { "p-host": fullDomain } : {}),
|
||||||
|
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
routerMiddlewares.push(irHeadersMiddlewareName);
|
};
|
||||||
}
|
routerMiddlewares.push(irHeadersMiddlewareName);
|
||||||
|
|
||||||
routerMiddlewares.push(...additionalMiddlewares);
|
routerMiddlewares.push(...additionalMiddlewares);
|
||||||
|
|
||||||
@@ -1733,18 +1738,19 @@ export async function getTraefikConfig(
|
|||||||
config.getRawConfig().traefik.additional_middlewares || [];
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
const routerMiddlewares: string[] = [];
|
const routerMiddlewares: string[] = [];
|
||||||
|
|
||||||
if (aiGatewayOverride) {
|
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
|
||||||
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
|
config_output.http.middlewares[srHeadersMiddlewareName] = {
|
||||||
config_output.http.middlewares[srHeadersMiddlewareName] = {
|
headers: {
|
||||||
headers: {
|
customRequestHeaders: {
|
||||||
customRequestHeaders: {
|
...(aiGatewayOverride && aiGatewayHost
|
||||||
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
|
? { Host: aiGatewayHost }
|
||||||
"p-host": fullDomain
|
: {}),
|
||||||
}
|
...(aiGatewayOverride ? { "p-host": fullDomain } : {}),
|
||||||
|
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
routerMiddlewares.push(srHeadersMiddlewareName);
|
};
|
||||||
}
|
routerMiddlewares.push(srHeadersMiddlewareName);
|
||||||
|
|
||||||
routerMiddlewares.push(...additionalMiddlewares);
|
routerMiddlewares.push(...additionalMiddlewares);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ import {
|
|||||||
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||||
import { isIpInCidr } from "@server/lib/ip";
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
import { localCache } from "@server/lib/cache";
|
import { localCache } from "@server/lib/cache";
|
||||||
|
import {
|
||||||
|
AI_GATEWAY_TRUST_HEADER,
|
||||||
|
isAiGatewayTrustHeaderValid
|
||||||
|
} from "@server/lib/aiGatewayTrust";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
@@ -217,9 +221,42 @@ async function buildRequestUser(
|
|||||||
|
|
||||||
async function resolveRequestUser(
|
async function resolveRequestUser(
|
||||||
req: Request,
|
req: Request,
|
||||||
_resourceId: number | null,
|
resourceId: number | null,
|
||||||
orgId: string | null
|
orgId: string | null
|
||||||
): Promise<RequestUser | null> {
|
): Promise<RequestUser | null> {
|
||||||
|
// Public inference: identity comes from Badger via Remote-* only when the
|
||||||
|
// Traefik trust header proves the request passed verify-session (VAK).
|
||||||
|
if (isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)) {
|
||||||
|
const userId = getRequestHeader(req, "remote-user-id");
|
||||||
|
if (userId) {
|
||||||
|
const username = getRequestHeader(req, "remote-user") || userId;
|
||||||
|
const email = getRequestHeader(req, "remote-email");
|
||||||
|
const name = getRequestHeader(req, "remote-name");
|
||||||
|
const role = getRequestHeader(req, "remote-role");
|
||||||
|
const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
email: email || null,
|
||||||
|
name: name || null,
|
||||||
|
role:
|
||||||
|
role || orgRoles.map((r) => r.roleName).join(", ") || null,
|
||||||
|
roleIds: orgRoles.map((r) => r.roleId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trusted request with no associated user (manual key without userId).
|
||||||
|
if (resourceId != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public inference must come through Badger; do not authorize via app session.
|
||||||
|
if (resourceId != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
const { session, user } = await validateSessionToken(sessionToken);
|
const { session, user } = await validateSessionToken(sessionToken);
|
||||||
@@ -251,6 +288,14 @@ async function resolveRequestUser(
|
|||||||
return buildRequestUser(client.userId, orgId);
|
return buildRequestUser(client.userId, orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRequestHeader(req: Request, name: string): string | undefined {
|
||||||
|
const raw = req.headers[name.toLowerCase()];
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw[0];
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||||
const [[resourceRow], [siteResourceRow]] = await Promise.all([
|
const [[resourceRow], [siteResourceRow]] = await Promise.all([
|
||||||
db
|
db
|
||||||
@@ -696,6 +741,21 @@ export async function handleAiGatewayProxy(
|
|||||||
orgId
|
orgId
|
||||||
} = target;
|
} = target;
|
||||||
|
|
||||||
|
// Public inference must pass Badger verify-session first. Traefik
|
||||||
|
// injects the trust header only on that path; the gateway trusts it
|
||||||
|
// and does not re-verify the virtual API key.
|
||||||
|
if (
|
||||||
|
resourceId != null &&
|
||||||
|
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
|
||||||
|
) {
|
||||||
|
return res.status(HttpCode.UNAUTHORIZED).json({
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
"Request must be authenticated via the inference resource"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const capableAttachments = attachments.filter((a) =>
|
const capableAttachments = attachments.filter((a) =>
|
||||||
providerHasCapability(a.provider.capabilities, capability)
|
providerHasCapability(a.provider.capabilities, capability)
|
||||||
);
|
);
|
||||||
@@ -823,7 +883,8 @@ export async function handleAiGatewayProxy(
|
|||||||
"transfer-encoding",
|
"transfer-encoding",
|
||||||
"upgrade",
|
"upgrade",
|
||||||
"content-length",
|
"content-length",
|
||||||
"accept-encoding"
|
"accept-encoding",
|
||||||
|
AI_GATEWAY_TRUST_HEADER.toLowerCase()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
type RequestUser
|
type RequestUser
|
||||||
} from "@server/routers/aiGateway/pipeline";
|
} from "@server/routers/aiGateway/pipeline";
|
||||||
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
|
||||||
|
import { AI_GATEWAY_TRUST_HEADER } from "@server/lib/aiGatewayTrust";
|
||||||
|
|
||||||
// Short TTL: long enough to spare the DB on a burst of requests, short
|
// Short TTL: long enough to spare the DB on a burst of requests, short
|
||||||
// enough that target/site changes (added, removed, exit node moved) show up
|
// enough that target/site changes (added, removed, exit node moved) show up
|
||||||
@@ -62,7 +63,8 @@ const SKIP_HEADERS = new Set([
|
|||||||
"transfer-encoding",
|
"transfer-encoding",
|
||||||
"upgrade",
|
"upgrade",
|
||||||
"content-length",
|
"content-length",
|
||||||
"accept-encoding"
|
"accept-encoding",
|
||||||
|
AI_GATEWAY_TRUST_HEADER.toLowerCase()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type ResolvedProviderTarget = {
|
type ResolvedProviderTarget = {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Reasons:
|
|||||||
106 - Valid email
|
106 - Valid email
|
||||||
107 - Valid SSO
|
107 - Valid SSO
|
||||||
108 - Connected Client
|
108 - Connected Client
|
||||||
|
109 - Valid Virtual API Key
|
||||||
|
|
||||||
201 - Resource Not Found
|
201 - Resource Not Found
|
||||||
202 - Resource Blocked
|
202 - Resource Blocked
|
||||||
@@ -90,7 +91,9 @@ async function flushAuditLogs() {
|
|||||||
auditLogBuffer.unshift(...logsToWrite);
|
auditLogBuffer.unshift(...logsToWrite);
|
||||||
logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`);
|
logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`);
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`);
|
logger.error(
|
||||||
|
`Buffer full, dropped ${logsToWrite.length} audit logs`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isFlushInProgress = false;
|
isFlushInProgress = false;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
} from "@server/auth/sessions/resource";
|
} from "@server/auth/sessions/resource";
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
|
import {
|
||||||
|
extractVirtualApiKeyCredential,
|
||||||
|
verifyVirtualApiKey
|
||||||
|
} from "@server/auth/verifyVirtualApiKey";
|
||||||
import {
|
import {
|
||||||
getResourceByDomain,
|
getResourceByDomain,
|
||||||
getResourceRules,
|
getResourceRules,
|
||||||
@@ -127,7 +131,8 @@ export async function verifyResourceSession(
|
|||||||
// Extract HTTP Basic Auth credentials if present
|
// Extract HTTP Basic Auth credentials if present
|
||||||
const clientHeaderAuth = extractBasicAuth(headers);
|
const clientHeaderAuth = extractBasicAuth(headers);
|
||||||
|
|
||||||
const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"];
|
const clientUserAgent =
|
||||||
|
headers?.["user-agent"] || headers?.["User-Agent"];
|
||||||
const clientIsBrowser = isBrowserUserAgent(clientUserAgent);
|
const clientIsBrowser = isBrowserUserAgent(clientUserAgent);
|
||||||
|
|
||||||
const clientIp = requestIp
|
const clientIp = requestIp
|
||||||
@@ -254,20 +259,28 @@ export async function verifyResourceSession(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (action == "ACCEPT") {
|
if (action == "ACCEPT") {
|
||||||
logger.debug("Resource allowed by rule");
|
// Public inference still requires a virtual API key; do not
|
||||||
|
// bypass that with an allow rule.
|
||||||
|
if (mode === "inference") {
|
||||||
|
logger.debug(
|
||||||
|
"Rule ACCEPT ignored for inference; continuing to virtual API key check"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug("Resource allowed by rule");
|
||||||
|
|
||||||
logRequestAudit(
|
logRequestAudit(
|
||||||
{
|
{
|
||||||
action: true,
|
action: true,
|
||||||
reason: 100, // allowed by rule
|
reason: 100, // allowed by rule
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource.resourceId,
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
location: ipCC
|
location: ipCC
|
||||||
},
|
},
|
||||||
parsedBody.data
|
parsedBody.data
|
||||||
);
|
);
|
||||||
|
|
||||||
return allowed(res, undefined, dontStripSession);
|
return allowed(res, undefined, dontStripSession);
|
||||||
|
}
|
||||||
} else if (action == "DROP") {
|
} else if (action == "DROP") {
|
||||||
logger.debug("Resource denied by rule");
|
logger.debug("Resource denied by rule");
|
||||||
|
|
||||||
@@ -302,20 +315,23 @@ export async function verifyResourceSession(
|
|||||||
!emailWhitelistEnabled &&
|
!emailWhitelistEnabled &&
|
||||||
!headerAuth
|
!headerAuth
|
||||||
) {
|
) {
|
||||||
logger.debug("Resource allowed because no auth");
|
// Public inference always requires a virtual API key.
|
||||||
|
if (mode !== "inference") {
|
||||||
|
logger.debug("Resource allowed because no auth");
|
||||||
|
|
||||||
logRequestAudit(
|
logRequestAudit(
|
||||||
{
|
{
|
||||||
action: true,
|
action: true,
|
||||||
reason: 101, // allowed no auth
|
reason: 101, // allowed no auth
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource.resourceId,
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
location: ipCC
|
location: ipCC
|
||||||
},
|
},
|
||||||
parsedBody.data
|
parsedBody.data
|
||||||
);
|
);
|
||||||
|
|
||||||
return allowed(res, undefined, dontStripSession);
|
return allowed(res, undefined, dontStripSession);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only offer a browser redirect to clients that can actually follow one and log in
|
// Only offer a browser redirect to clients that can actually follow one and log in
|
||||||
@@ -327,6 +343,82 @@ export async function verifyResourceSession(
|
|||||||
)}?redirect=${encodeURIComponent(originalRequestURL)}`
|
)}?redirect=${encodeURIComponent(originalRequestURL)}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Virtual API keys for public inference resources (provider-style auth headers).
|
||||||
|
// Session/SSO may authenticate users elsewhere (e.g. dashboard key pages), but
|
||||||
|
// only a valid virtual API key is allowed through to the AI gateway.
|
||||||
|
if (mode === "inference") {
|
||||||
|
const vakCredential = extractVirtualApiKeyCredential(headers);
|
||||||
|
if (vakCredential) {
|
||||||
|
const {
|
||||||
|
valid,
|
||||||
|
error,
|
||||||
|
key,
|
||||||
|
userData: vakUserData
|
||||||
|
} = await verifyVirtualApiKey({
|
||||||
|
credential: vakCredential,
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
orgId: resource.orgId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.debug("Virtual API key invalid: " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
logger.info(
|
||||||
|
`Virtual API key is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid && key) {
|
||||||
|
logRequestAudit(
|
||||||
|
{
|
||||||
|
action: true,
|
||||||
|
reason: 109, // valid virtual API key
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
orgId: resource.orgId,
|
||||||
|
location: ipCC,
|
||||||
|
...(vakUserData
|
||||||
|
? {
|
||||||
|
user: {
|
||||||
|
username: vakUserData.username,
|
||||||
|
userId: vakUserData.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
apiKey: {
|
||||||
|
name: key.name,
|
||||||
|
apiKeyId: key.virtualApiKeyId
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
metadata: {
|
||||||
|
virtualApiKeyId: key.virtualApiKeyId,
|
||||||
|
virtualApiKeyKind: key.kind
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parsedBody.data
|
||||||
|
);
|
||||||
|
|
||||||
|
return allowed(res, vakUserData, dontStripSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logRequestAudit(
|
||||||
|
{
|
||||||
|
action: false,
|
||||||
|
reason: 299, // no more auth methods / VAK required
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
orgId: resource.orgId,
|
||||||
|
location: ipCC
|
||||||
|
},
|
||||||
|
parsedBody.data
|
||||||
|
);
|
||||||
|
|
||||||
|
return notAllowed(res, redirectPath);
|
||||||
|
}
|
||||||
|
|
||||||
// check for access token in headers
|
// check for access token in headers
|
||||||
if (
|
if (
|
||||||
headers &&
|
headers &&
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
authTypeRequiresApiKey,
|
authTypeRequiresApiKey,
|
||||||
type AiProviderAuthType,
|
type AiProviderAuthType,
|
||||||
type AiProviderType
|
type AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@app/lib/aiProviderDefaults";
|
||||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities";
|
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import type {
|
import type {
|
||||||
AiProviderAuthType,
|
AiProviderAuthType,
|
||||||
AiProviderType
|
AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@app/lib/aiProviderDefaults";
|
||||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
type AiProviderFormValues
|
type AiProviderFormValues
|
||||||
} from "@app/lib/aiProviderFormSchema";
|
} from "@app/lib/aiProviderFormSchema";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { authTypeRequiresApiKey } from "@server/lib/aiProviderDefaults";
|
import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults";
|
||||||
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import {
|
import {
|
||||||
AI_PROVIDER_AUTH_TYPES,
|
AI_PROVIDER_AUTH_TYPES,
|
||||||
type AiProviderAuthType
|
type AiProviderAuthType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@app/lib/aiProviderDefaults";
|
||||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||||
import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities";
|
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema";
|
import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema";
|
||||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
|
||||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||||
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||||
|
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||||
import {
|
import {
|
||||||
MultiResourcesSelector,
|
MultiResourcesSelector,
|
||||||
formatMultiResourcesSelectorLabel
|
formatMultiResourcesSelectorLabel
|
||||||
@@ -146,7 +147,12 @@ export default function CreateVirtualApiKeyForm({
|
|||||||
if (res?.data.data.virtualApiKey) {
|
if (res?.data.data.virtualApiKey) {
|
||||||
const key = res.data.data.virtualApiKey;
|
const key = res.data.data.virtualApiKey;
|
||||||
if (key.secret) {
|
if (key.secret) {
|
||||||
setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`);
|
setCredential(
|
||||||
|
formatVirtualApiKeyCredential(
|
||||||
|
key.virtualApiKeyId,
|
||||||
|
key.secret
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceLookup = new Map(
|
const resourceLookup = new Map(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||||
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||||
|
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||||
import {
|
import {
|
||||||
MultiResourcesSelector,
|
MultiResourcesSelector,
|
||||||
formatMultiResourcesSelectorLabel
|
formatMultiResourcesSelectorLabel
|
||||||
@@ -151,7 +152,10 @@ export default function EditVirtualApiKeyForm({
|
|||||||
const secret = res.data.data.virtualApiKey.secret;
|
const secret = res.data.data.virtualApiKey.secret;
|
||||||
if (secret) {
|
if (secret) {
|
||||||
setCredential(
|
setCredential(
|
||||||
`vk-${virtualApiKey.virtualApiKeyId}.${secret}`
|
formatVirtualApiKeyCredential(
|
||||||
|
virtualApiKey.virtualApiKeyId,
|
||||||
|
secret
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import type {
|
|||||||
ListMyVirtualApiKeysResponse,
|
ListMyVirtualApiKeysResponse,
|
||||||
VirtualApiKeyWithResources
|
VirtualApiKeyWithResources
|
||||||
} from "@server/routers/virtualApiKey/types";
|
} from "@server/routers/virtualApiKey/types";
|
||||||
|
import {
|
||||||
|
formatVirtualApiKeyCredential,
|
||||||
|
formatVirtualApiKeyPreview
|
||||||
|
} from "@app/lib/virtualApiKeyFormat";
|
||||||
|
|
||||||
type UserVirtualApiKeysProps = {
|
type UserVirtualApiKeysProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -34,10 +38,6 @@ type UserVirtualApiKeysProps = {
|
|||||||
initialData: ListMyVirtualApiKeysResponse;
|
initialData: ListMyVirtualApiKeysResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
function keyPreview(virtualApiKeyId: string, lastChars: string): string {
|
|
||||||
return `vk-${virtualApiKeyId}••••${lastChars}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
@@ -56,7 +56,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
const secret = res.data.data.virtualApiKey.secret;
|
const secret = res.data.data.virtualApiKey.secret;
|
||||||
if (secret) {
|
if (secret) {
|
||||||
setCredential(`vk-${virtualApiKeyId}.${secret}`);
|
setCredential(
|
||||||
|
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -95,7 +97,7 @@ function OwnedKeySecret({
|
|||||||
lastChars: string;
|
lastChars: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const preview = keyPreview(virtualApiKeyId, lastChars);
|
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||||
const { credential, loading, revealSecret } = useRevealSecret(
|
const { credential, loading, revealSecret } = useRevealSecret(
|
||||||
orgId,
|
orgId,
|
||||||
virtualApiKeyId
|
virtualApiKeyId
|
||||||
@@ -137,7 +139,7 @@ function IdentityKeyCenterpiece({
|
|||||||
resourceGuid?: string;
|
resourceGuid?: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const preview = keyPreview(virtualApiKeyId, lastChars);
|
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||||
const { credential, loading, revealSecret } = useRevealSecret(
|
const { credential, loading, revealSecret } = useRevealSecret(
|
||||||
orgId,
|
orgId,
|
||||||
virtualApiKeyId
|
virtualApiKeyId
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||||
|
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||||
|
|
||||||
type ViewVirtualApiKeySecretProps = {
|
type ViewVirtualApiKeySecretProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -56,7 +57,12 @@ export default function ViewVirtualApiKeySecret({
|
|||||||
}
|
}
|
||||||
const key = res.data.data.virtualApiKey;
|
const key = res.data.data.virtualApiKey;
|
||||||
if (key.secret) {
|
if (key.secret) {
|
||||||
setCredential(`vk-${key.virtualApiKeyId}.${key.secret}`);
|
setCredential(
|
||||||
|
formatVirtualApiKeyCredential(
|
||||||
|
key.virtualApiKeyId,
|
||||||
|
key.secret
|
||||||
|
)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ import {
|
|||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||||
|
import {
|
||||||
|
formatVirtualApiKeyCredential,
|
||||||
|
formatVirtualApiKeyPreview
|
||||||
|
} from "@app/lib/virtualApiKeyFormat";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
export type VirtualApiKeyRow = CreatedVirtualApiKey;
|
export type VirtualApiKeyRow = CreatedVirtualApiKey;
|
||||||
@@ -507,7 +511,7 @@ function VirtualApiKeySecretCell({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const preview = `vk-${virtualApiKeyId}••••${lastChars}`;
|
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||||
const [credential, setCredential] = useState<string | null>(null);
|
const [credential, setCredential] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -522,7 +526,9 @@ function VirtualApiKeySecretCell({
|
|||||||
}
|
}
|
||||||
const secret = res.data.data.virtualApiKey.secret;
|
const secret = res.data.data.virtualApiKey.secret;
|
||||||
if (secret) {
|
if (secret) {
|
||||||
setCredential(`vk-${virtualApiKeyId}.${secret}`);
|
setCredential(
|
||||||
|
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import type {
|
|||||||
GetMyVirtualApiKeyResponse,
|
GetMyVirtualApiKeyResponse,
|
||||||
VirtualApiKeyWithResources
|
VirtualApiKeyWithResources
|
||||||
} from "@server/routers/virtualApiKey/types";
|
} from "@server/routers/virtualApiKey/types";
|
||||||
|
import {
|
||||||
|
formatVirtualApiKeyCredential,
|
||||||
|
formatVirtualApiKeyPreview
|
||||||
|
} from "@app/lib/virtualApiKeyFormat";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
@@ -31,10 +35,6 @@ type LauncherInferenceApiKeysSectionProps = {
|
|||||||
resourceGuid: string;
|
resourceGuid: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function keyPreview(virtualApiKeyId: string, lastChars: string): string {
|
|
||||||
return `vk-${virtualApiKeyId}••••${lastChars}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
@@ -53,7 +53,9 @@ function useRevealSecret(orgId: string, virtualApiKeyId: string) {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
const secret = res.data.data.virtualApiKey.secret;
|
const secret = res.data.data.virtualApiKey.secret;
|
||||||
if (secret) {
|
if (secret) {
|
||||||
setCredential(`vk-${virtualApiKeyId}.${secret}`);
|
setCredential(
|
||||||
|
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -92,7 +94,7 @@ function PanelKeySecret({
|
|||||||
lastChars: string;
|
lastChars: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const preview = keyPreview(virtualApiKeyId, lastChars);
|
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||||
const { credential, loading, revealSecret } = useRevealSecret(
|
const { credential, loading, revealSecret } = useRevealSecret(
|
||||||
orgId,
|
orgId,
|
||||||
virtualApiKeyId
|
virtualApiKeyId
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function UserSelector({
|
|||||||
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||||
|
|
||||||
const { data: users = [] } = useQuery(
|
const { data: users = [] } = useQuery(
|
||||||
orgQueries.users({ orgId, perPage: 10, term: debouncedValue })
|
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
|
||||||
);
|
);
|
||||||
|
|
||||||
const usersShown = useMemo(() => {
|
const usersShown = useMemo(() => {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const AI_CAPABILITIES = [
|
||||||
|
"openai_chat",
|
||||||
|
"openai_responses",
|
||||||
|
"anthropic_messages",
|
||||||
|
"gemini_generate_content",
|
||||||
|
"bedrock_model_invoke",
|
||||||
|
"google_generate_content",
|
||||||
|
"google_raw_predict",
|
||||||
|
"bedrock_converse"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AiCapability = (typeof AI_CAPABILITIES)[number];
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||||
|
|
||||||
|
export type AiProviderType =
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "googleGemini"
|
||||||
|
| "vertexAi"
|
||||||
|
| "bedrock"
|
||||||
|
| "microsoftFoundry"
|
||||||
|
| "openRouter"
|
||||||
|
| "vercelAiGateway"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export const AI_PROVIDER_AUTH_TYPES = [
|
||||||
|
"bearer",
|
||||||
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
|
"hec",
|
||||||
|
"cf-aig-authorization",
|
||||||
|
"none",
|
||||||
|
"passthrough"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
|
||||||
|
export type AiBudgetUnit = "usd" | "tokens";
|
||||||
|
export type AiProviderRoutingMode = "url" | "target";
|
||||||
|
|
||||||
|
type AiProviderDefaults = {
|
||||||
|
upstreamUrl: string | null;
|
||||||
|
authType: AiProviderAuthType;
|
||||||
|
capabilities: readonly AiCapability[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AI_PROVIDER_DEFAULTS: Record<
|
||||||
|
Exclude<AiProviderType, "custom">,
|
||||||
|
AiProviderDefaults
|
||||||
|
> = {
|
||||||
|
openai: {
|
||||||
|
upstreamUrl: "https://api.openai.com/v1",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat", "openai_responses"]
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
upstreamUrl: "https://api.anthropic.com",
|
||||||
|
authType: "x-api-key",
|
||||||
|
capabilities: ["anthropic_messages"]
|
||||||
|
},
|
||||||
|
googleGemini: {
|
||||||
|
upstreamUrl: "https://generativelanguage.googleapis.com",
|
||||||
|
authType: "x-goog-api-key",
|
||||||
|
capabilities: ["gemini_generate_content"]
|
||||||
|
},
|
||||||
|
vertexAi: {
|
||||||
|
upstreamUrl: null,
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["google_generate_content", "google_raw_predict"]
|
||||||
|
},
|
||||||
|
bedrock: {
|
||||||
|
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["bedrock_converse"]
|
||||||
|
},
|
||||||
|
microsoftFoundry: {
|
||||||
|
upstreamUrl: null,
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
||||||
|
},
|
||||||
|
openRouter: {
|
||||||
|
upstreamUrl: "https://openrouter.ai/api/v1",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat"]
|
||||||
|
},
|
||||||
|
vercelAiGateway: {
|
||||||
|
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
|
||||||
|
authType: "bearer",
|
||||||
|
capabilities: ["openai_chat", "openai_responses"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
|
||||||
|
return authType !== "none" && authType !== "passthrough";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerRequiresUpstreamUrl(
|
||||||
|
type: AiProviderType,
|
||||||
|
routingMode: AiProviderRoutingMode = "url"
|
||||||
|
): boolean {
|
||||||
|
if (routingMode === "target") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type === "custom") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultsForProviderType(
|
||||||
|
type: AiProviderType
|
||||||
|
): readonly AiCapability[] {
|
||||||
|
if (type === "custom") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return AI_PROVIDER_DEFAULTS[type].capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AI_CAPABILITIES };
|
||||||
|
export type { AiCapability };
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
AI_CAPABILITIES,
|
||||||
AI_PROVIDER_AUTH_TYPES,
|
AI_PROVIDER_AUTH_TYPES,
|
||||||
AI_PROVIDER_DEFAULTS,
|
AI_PROVIDER_DEFAULTS,
|
||||||
authTypeRequiresApiKey,
|
authTypeRequiresApiKey,
|
||||||
defaultsForProviderType,
|
defaultsForProviderType,
|
||||||
providerRequiresUpstreamUrl,
|
providerRequiresUpstreamUrl,
|
||||||
|
type AiCapability,
|
||||||
type AiProviderAuthType,
|
type AiProviderAuthType,
|
||||||
type AiProviderType
|
type AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@app/lib/aiProviderDefaults";
|
||||||
import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities";
|
|
||||||
|
|
||||||
type TranslateFn = (key: string) => string;
|
type TranslateFn = (key: string) => string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export const VIRTUAL_API_KEY_PREFIX = "vk-";
|
||||||
|
|
||||||
|
const VIRTUAL_API_KEY_AUTH_HEADER_NAMES = [
|
||||||
|
"authorization",
|
||||||
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
|
"cf-aig-authorization"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function formatVirtualApiKeyCredential(
|
||||||
|
virtualApiKeyId: string,
|
||||||
|
secret: string
|
||||||
|
): string {
|
||||||
|
return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}.${secret}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVirtualApiKeyPreview(
|
||||||
|
virtualApiKeyId: string,
|
||||||
|
lastChars: string
|
||||||
|
): string {
|
||||||
|
return `${VIRTUAL_API_KEY_PREFIX}${virtualApiKeyId}••••${lastChars}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeVirtualApiKeyCredential(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith(VIRTUAL_API_KEY_PREFIX)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const withoutPrefix = trimmed.slice(VIRTUAL_API_KEY_PREFIX.length);
|
||||||
|
const dot = withoutPrefix.indexOf(".");
|
||||||
|
return dot > 0 && dot < withoutPrefix.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerValueCarriesVirtualApiKey(raw: string): boolean {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const bearerMatch = trimmed.match(/^(?:Bearer|Splunk)\s+(.+)$/i);
|
||||||
|
if (bearerMatch) {
|
||||||
|
return looksLikeVirtualApiKeyCredential(bearerMatch[1]);
|
||||||
|
}
|
||||||
|
return looksLikeVirtualApiKeyCredential(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove client headers that carry a Pangolin virtual API key so they are
|
||||||
|
* never forwarded to upstream providers (including passthrough auth).
|
||||||
|
*/
|
||||||
|
export function stripVirtualApiKeyAuthHeaders(
|
||||||
|
headers: Record<string, string>
|
||||||
|
): void {
|
||||||
|
for (const key of Object.keys(headers)) {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
if (
|
||||||
|
!(VIRTUAL_API_KEY_AUTH_HEADER_NAMES as readonly string[]).includes(
|
||||||
|
lower
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (headerValueCarriesVirtualApiKey(headers[key])) {
|
||||||
|
delete headers[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user