mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-06 12:41:25 +02:00
add api capabilities
This commit is contained in:
@@ -1726,6 +1726,29 @@
|
|||||||
"aiProviderErrorAuthTypeRequired": "Auth type is required",
|
"aiProviderErrorAuthTypeRequired": "Auth type is required",
|
||||||
"aiProviderErrorApiKeyRequired": "API key is required",
|
"aiProviderErrorApiKeyRequired": "API key is required",
|
||||||
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
|
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
|
||||||
|
"aiProviderErrorCapabilitiesRequired": "Select at least one API capability",
|
||||||
|
"aiProviderCapabilities": "API Capabilities",
|
||||||
|
"aiProviderCapabilitiesDescription": "Which API formats this provider accepts. Built-in providers use fixed capabilities.",
|
||||||
|
"aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle",
|
||||||
|
"aiProviderCapabilitiesSelect": "Select capabilities",
|
||||||
|
"aiProviderCapabilitiesEmpty": "No capabilities found",
|
||||||
|
"aiProviderCapabilitiesSearch": "Search capabilities...",
|
||||||
|
"aiCapabilityOpenaiChat": "OpenAI Chat Completions",
|
||||||
|
"aiCapabilityOpenaiChatDescription": "Supports /v1/chat/completions",
|
||||||
|
"aiCapabilityOpenaiResponses": "OpenAI Responses",
|
||||||
|
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
|
||||||
|
"aiCapabilityAnthropicMessages": "Anthropic Messages",
|
||||||
|
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
|
||||||
|
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
|
||||||
|
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
|
||||||
|
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
|
||||||
|
"aiCapabilityBedrockModelInvokeDescription": "Supports Amazon Bedrock InvokeModel",
|
||||||
|
"aiCapabilityGoogleGenerateContent": "Vertex Generate Content",
|
||||||
|
"aiCapabilityGoogleGenerateContentDescription": "Supports Vertex AI Gemini format",
|
||||||
|
"aiCapabilityGoogleRawPredict": "Vertex Raw Predict",
|
||||||
|
"aiCapabilityGoogleRawPredictDescription": "Supports Vertex AI rawPredict for Anthropic models",
|
||||||
|
"aiCapabilityBedrockConverse": "Bedrock Converse",
|
||||||
|
"aiCapabilityBedrockConverseDescription": "Supports Amazon Bedrock Converse API",
|
||||||
"aiProviderCreated": "AI provider created",
|
"aiProviderCreated": "AI provider created",
|
||||||
"aiProviderUpdated": "AI provider updated",
|
"aiProviderUpdated": "AI provider updated",
|
||||||
"aiProviderDeleted": "AI provider deleted",
|
"aiProviderDeleted": "AI provider deleted",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
errorHandlerMiddleware,
|
errorHandlerMiddleware,
|
||||||
notFoundMiddleware
|
notFoundMiddleware
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import * as aiGateway from "@server/routers/aiGateway";
|
import { createAiGatewayRouter } from "@server/routers/aiGateway";
|
||||||
|
|
||||||
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
|
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export function createAiGatewayServer() {
|
|||||||
aiGatewayServer.use(cors());
|
aiGatewayServer.use(cors());
|
||||||
aiGatewayServer.use(express.json());
|
aiGatewayServer.use(express.json());
|
||||||
|
|
||||||
aiGatewayServer.post("/chat/completions", aiGateway.chatCompletions);
|
aiGatewayServer.use(createAiGatewayRouter());
|
||||||
|
|
||||||
aiGatewayServer.use(notFoundMiddleware);
|
aiGatewayServer.use(notFoundMiddleware);
|
||||||
aiGatewayServer.use(errorHandlerMiddleware);
|
aiGatewayServer.use(errorHandlerMiddleware);
|
||||||
|
|||||||
@@ -1659,6 +1659,7 @@ export const aiProviders = pgTable("aiProviders", {
|
|||||||
.$type<"url" | "target">()
|
.$type<"url" | "target">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("url"),
|
.default("url"),
|
||||||
|
capabilities: text("capabilities").notNull().default("[]"),
|
||||||
skipTlsVerification: boolean("skipTlsVerification")
|
skipTlsVerification: boolean("skipTlsVerification")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -1641,6 +1641,7 @@ export const aiProviders = sqliteTable("aiProviders", {
|
|||||||
.$type<"url" | "target">()
|
.$type<"url" | "target">()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("url"),
|
.default("url"),
|
||||||
|
capabilities: text("capabilities").notNull().default("[]"),
|
||||||
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
|
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
|
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
export type AiCapabilityRoute = {
|
||||||
|
method: "POST";
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiCapabilityDefinition = {
|
||||||
|
id: AiCapability;
|
||||||
|
routes: AiCapabilityRoute[];
|
||||||
|
extractModel: (req: Request) => string | undefined;
|
||||||
|
resolveUpstreamUrl: (
|
||||||
|
baseUrl: string,
|
||||||
|
req: Request,
|
||||||
|
model: string
|
||||||
|
) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bodyModel(req: Request): string | undefined {
|
||||||
|
return typeof req.body?.model === "string" ? req.body.model : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramModel(req: Request): string | undefined {
|
||||||
|
const model = req.params?.model;
|
||||||
|
return typeof model === "string" && model.length > 0 ? model : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join base URL with a path, avoiding double slashes and a duplicated trailing
|
||||||
|
* /v1 when the inbound path already starts with /v1 and the base ends with /v1.
|
||||||
|
*/
|
||||||
|
export function joinUpstreamUrl(baseUrl: string, path: string): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
let suffix = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
base.endsWith("/v1") &&
|
||||||
|
(suffix === "/v1" || suffix.startsWith("/v1/"))
|
||||||
|
) {
|
||||||
|
suffix = suffix.slice("/v1".length) || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix === "/") {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromRequest(req: Request): string {
|
||||||
|
// Prefer originalUrl path (includes mounted path) over req.path when available.
|
||||||
|
const raw =
|
||||||
|
req.originalUrl?.split("?")[0] || req.url?.split("?")[0] || req.path;
|
||||||
|
return raw.startsWith("/") ? raw : `/${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
||||||
|
{
|
||||||
|
openai_chat: {
|
||||||
|
id: "openai_chat",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/v1/chat/completions" },
|
||||||
|
{ method: "POST", path: "/chat/completions" }
|
||||||
|
],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
},
|
||||||
|
openai_responses: {
|
||||||
|
id: "openai_responses",
|
||||||
|
routes: [{ method: "POST", path: "/v1/responses" }],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
},
|
||||||
|
anthropic_messages: {
|
||||||
|
id: "anthropic_messages",
|
||||||
|
routes: [{ method: "POST", path: "/v1/messages" }],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
},
|
||||||
|
gemini_generate_content: {
|
||||||
|
id: "gemini_generate_content",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1beta/models/:model\\:generateContent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1beta/models/:model\\:streamGenerateContent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
},
|
||||||
|
google_generate_content: {
|
||||||
|
id: "google_generate_content",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
// Vertex publisher model generateContent
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
},
|
||||||
|
google_raw_predict: {
|
||||||
|
id: "google_raw_predict",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
},
|
||||||
|
bedrock_model_invoke: {
|
||||||
|
id: "bedrock_model_invoke",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/model/:model/invoke" },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/model/:model/invoke-with-response-stream"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
},
|
||||||
|
bedrock_converse: {
|
||||||
|
id: "bedrock_converse",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/model/:model/converse" },
|
||||||
|
{ method: "POST", path: "/model/:model/converse-stream" }
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AI_PROVIDER_CAPABILITY_DEFAULTS: Record<
|
||||||
|
Exclude<AiProviderType, "custom">,
|
||||||
|
readonly AiCapability[]
|
||||||
|
> = {
|
||||||
|
openai: ["openai_chat"],
|
||||||
|
anthropic: ["anthropic_messages"],
|
||||||
|
googleGemini: ["openai_chat"],
|
||||||
|
vertexAi: ["google_generate_content"],
|
||||||
|
bedrock: ["bedrock_converse"],
|
||||||
|
microsoftFoundry: ["openai_chat"],
|
||||||
|
openRouter: ["openai_chat"],
|
||||||
|
vercelAiGateway: ["openai_chat"]
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAiCapability(value: unknown): value is AiCapability {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(AI_CAPABILITIES as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCapabilities(raw: unknown): AiCapability[] {
|
||||||
|
if (raw == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: AiCapability[] = [];
|
||||||
|
const seen = new Set<AiCapability>();
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (isAiCapability(item) && !seen.has(item)) {
|
||||||
|
seen.add(item);
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCapabilities(capabilities: AiCapability[]): string {
|
||||||
|
return JSON.stringify(capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerHasCapability(
|
||||||
|
capabilities: AiCapability[] | string | null | undefined,
|
||||||
|
capability: AiCapability
|
||||||
|
): boolean {
|
||||||
|
const list =
|
||||||
|
typeof capabilities === "string" || capabilities == null
|
||||||
|
? parseCapabilities(capabilities)
|
||||||
|
: capabilities;
|
||||||
|
return list.includes(capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCapabilitiesForCreate(input: {
|
||||||
|
type: AiProviderType;
|
||||||
|
capabilities?: AiCapability[] | null;
|
||||||
|
}): AiCapability[] {
|
||||||
|
if (input.type === "custom") {
|
||||||
|
return parseCapabilities(input.capabilities ?? []);
|
||||||
|
}
|
||||||
|
return [...AI_PROVIDER_CAPABILITY_DEFAULTS[input.type]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultsForProviderType(
|
||||||
|
type: AiProviderType
|
||||||
|
): readonly AiCapability[] {
|
||||||
|
if (type === "custom") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return AI_PROVIDER_CAPABILITY_DEFAULTS[type];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
AI_CAPABILITY_DEFS,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
|
||||||
|
|
||||||
|
export function createAiGatewayRouter() {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||||
|
const capability = def.id as AiCapability;
|
||||||
|
for (const route of def.routes) {
|
||||||
|
router.post(route.path, (req, res) =>
|
||||||
|
handleAiGatewayProxy(req, res, capability)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./chatCompletions";
|
export { handleAiGatewayProxy } from "./pipeline";
|
||||||
|
export { createAiGatewayRouter } from "./createAiGatewayRouter";
|
||||||
|
|||||||
+37
-36
@@ -22,6 +22,11 @@ import {
|
|||||||
applyAiProviderAuthHeaders,
|
applyAiProviderAuthHeaders,
|
||||||
authTypeRequiresApiKey
|
authTypeRequiresApiKey
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
AI_CAPABILITY_DEFS,
|
||||||
|
providerHasCapability,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
import {
|
import {
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
validateSessionToken
|
validateSessionToken
|
||||||
@@ -45,10 +50,6 @@ const REQUEST_USER_TTL_SEC = 30;
|
|||||||
|
|
||||||
type CachedClient = { clientId: number; userId: string | null } | null;
|
type CachedClient = { clientId: number; userId: string | null } | null;
|
||||||
|
|
||||||
// The set of CIDRs an exit node manages; client exitNodeSubnets are always
|
|
||||||
// /32s carved out of one of these ranges. Checking against this small,
|
|
||||||
// cacheable list lets us skip the (much more frequent) per-IP client lookup
|
|
||||||
// entirely for traffic that could never match a client anyway.
|
|
||||||
async function getExitNodeRanges(): Promise<string[]> {
|
async function getExitNodeRanges(): Promise<string[]> {
|
||||||
const cached = localCache.get<string[]>(EXIT_NODE_RANGES_CACHE_KEY);
|
const cached = localCache.get<string[]>(EXIT_NODE_RANGES_CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -96,8 +97,6 @@ type ResolvedTarget = {
|
|||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
orgId: string | null;
|
orgId: string | null;
|
||||||
attachments: ProviderAttachment[];
|
attachments: ProviderAttachment[];
|
||||||
// Model IDs on the resource allowlist that belong to allowlist-mode
|
|
||||||
// providers. Empty when no attached provider uses allowlist mode.
|
|
||||||
allowlistedModelIds: Set<number>;
|
allowlistedModelIds: Set<number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,13 +149,9 @@ 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 resources behind badger: badger passes the resource session
|
|
||||||
// cookie through to the backend (same mechanism the browser gateway,
|
|
||||||
// e.g. the SSH page, relies on), so we can validate it exactly like
|
|
||||||
// verifySessionUserMiddleware does for the dashboard.
|
|
||||||
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);
|
||||||
@@ -167,10 +162,6 @@ async function resolveRequestUser(
|
|||||||
|
|
||||||
// TODO: MAKE SURE THIS CAN NOT BE SPOOFED AND CAN BE TRUSTED AS AN INTERNAL ADDRESS FROM A NODE
|
// TODO: MAKE SURE THIS CAN NOT BE SPOOFED AND CAN BE TRUSTED AS AN INTERNAL ADDRESS FROM A NODE
|
||||||
|
|
||||||
// No session cookie - fall back to identifying the caller by source IP.
|
|
||||||
// A client's exitNodeSubnet is a /32 handed out from one of our exit
|
|
||||||
// node's address ranges, so an IP that isn't inside any of those ranges
|
|
||||||
// can never belong to a client and we can skip the DB entirely.
|
|
||||||
const ip = req.ip;
|
const ip = req.ip;
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
return null;
|
return null;
|
||||||
@@ -193,9 +184,6 @@ async function resolveRequestUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
async function resolveTarget(host: string): Promise<ResolvedTarget | null> {
|
||||||
// TODO: eventually we need to know if it's a private or public resource
|
|
||||||
// and not just simply check the fullDomain in case there is a private resource with the same fullDomain
|
|
||||||
|
|
||||||
const [resourceRow] = await db
|
const [resourceRow] = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -378,7 +366,6 @@ async function selectProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// One lookup for the requested model key across all attached providers.
|
|
||||||
const matchingModels = await db
|
const matchingModels = await db
|
||||||
.select({
|
.select({
|
||||||
modelId: aiModels.modelId,
|
modelId: aiModels.modelId,
|
||||||
@@ -407,7 +394,6 @@ async function selectProvider(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowlist: only models explicitly attached to the resource
|
|
||||||
if (allowlistedModelIds.has(model.modelId)) {
|
if (allowlistedModelIds.has(model.modelId)) {
|
||||||
candidates.push(attachment.provider);
|
candidates.push(attachment.provider);
|
||||||
}
|
}
|
||||||
@@ -432,11 +418,14 @@ async function selectProvider(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function chatCompletions(
|
export async function handleAiGatewayProxy(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response
|
res: Response,
|
||||||
|
capability: AiCapability
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
|
const def = AI_CAPABILITY_DEFS[capability];
|
||||||
|
|
||||||
const host = (
|
const host = (
|
||||||
(req.headers["p-host"] as string | undefined) ||
|
(req.headers["p-host"] as string | undefined) ||
|
||||||
req.headers.host ||
|
req.headers.host ||
|
||||||
@@ -448,7 +437,7 @@ export async function chatCompletions(
|
|||||||
.json({ error: { message: "Missing Host header" } });
|
.json({ error: { message: "Missing Host header" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`AI gateway request for host: ${host}`);
|
logger.info(`AI gateway ${capability} request for host: ${host}`);
|
||||||
|
|
||||||
const target = await resolveTarget(host);
|
const target = await resolveTarget(host);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -461,11 +450,6 @@ export async function chatCompletions(
|
|||||||
|
|
||||||
const { attachments, allowlistedModelIds, resourceId, orgId } = target;
|
const { attachments, allowlistedModelIds, resourceId, orgId } = target;
|
||||||
|
|
||||||
logger.debug("+++++ gateway target: ", target);
|
|
||||||
|
|
||||||
// Best-effort identity resolution - not yet enforced, but lets us
|
|
||||||
// start making per-user access decisions (e.g. model/role-based
|
|
||||||
// restrictions) without another round of plumbing later.
|
|
||||||
const requestUser = await resolveRequestUser(req, resourceId, orgId);
|
const requestUser = await resolveRequestUser(req, resourceId, orgId);
|
||||||
if (requestUser) {
|
if (requestUser) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -473,11 +457,22 @@ export async function chatCompletions(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedModel =
|
const capableAttachments = attachments.filter((a) =>
|
||||||
typeof req.body?.model === "string" ? req.body.model : undefined;
|
providerHasCapability(a.provider.capabilities, capability)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (capableAttachments.length === 0) {
|
||||||
|
return res.status(HttpCode.FORBIDDEN).json({
|
||||||
|
error: {
|
||||||
|
message: `No AI provider on this resource supports ${capability}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedModel = def.extractModel(req);
|
||||||
|
|
||||||
const selection = await selectProvider(
|
const selection = await selectProvider(
|
||||||
attachments,
|
capableAttachments,
|
||||||
allowlistedModelIds,
|
allowlistedModelIds,
|
||||||
requestedModel
|
requestedModel
|
||||||
);
|
);
|
||||||
@@ -513,11 +508,12 @@ export async function chatCompletions(
|
|||||||
apiKey = decrypt(provider.apiKey, secret);
|
apiKey = decrypt(provider.apiKey, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = `${upstreamUrl.replace(/\/$/, "")}`;
|
const targetUrl = def.resolveUpstreamUrl(
|
||||||
|
upstreamUrl,
|
||||||
|
req,
|
||||||
|
requestedModel!
|
||||||
|
);
|
||||||
|
|
||||||
// Drop hop-by-hop / proxy-only headers. Forwarding Host especially
|
|
||||||
// breaks Node fetch (TLS/SNI targets the upstream URL while Host
|
|
||||||
// still says localhost).
|
|
||||||
const skipHeaders = new Set([
|
const skipHeaders = new Set([
|
||||||
"p-host",
|
"p-host",
|
||||||
"host",
|
"host",
|
||||||
@@ -554,6 +550,7 @@ export async function chatCompletions(
|
|||||||
const body = JSON.stringify(req.body);
|
const body = JSON.stringify(req.body);
|
||||||
|
|
||||||
logger.debug("AI gateway upstream request", {
|
logger.debug("AI gateway upstream request", {
|
||||||
|
capability,
|
||||||
url: targetUrl,
|
url: targetUrl,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
@@ -591,7 +588,11 @@ export async function chatCompletions(
|
|||||||
const contentType = upstreamRes.headers.get("content-type") || "";
|
const contentType = upstreamRes.headers.get("content-type") || "";
|
||||||
const isStream =
|
const isStream =
|
||||||
req.body?.stream === true ||
|
req.body?.stream === true ||
|
||||||
contentType.includes("text/event-stream");
|
contentType.includes("text/event-stream") ||
|
||||||
|
req.path.includes("streamGenerateContent") ||
|
||||||
|
req.path.includes("streamRawPredict") ||
|
||||||
|
req.path.includes("converse-stream") ||
|
||||||
|
req.path.includes("invoke-with-response-stream");
|
||||||
|
|
||||||
res.status(upstreamRes.status);
|
res.status(upstreamRes.status);
|
||||||
res.setHeader("Content-Type", contentType || "application/json");
|
res.setHeader("Content-Type", contentType || "application/json");
|
||||||
@@ -14,10 +14,15 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/
|
|||||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||||
import {
|
import {
|
||||||
aiAuthTypeSchema,
|
aiAuthTypeSchema,
|
||||||
|
aiCapabilitiesSchema,
|
||||||
aiProviderTypeSchema,
|
aiProviderTypeSchema,
|
||||||
aiRoutingModeSchema,
|
aiRoutingModeSchema,
|
||||||
refineProviderUpstreamFields
|
refineProviderUpstreamFields
|
||||||
} from "@server/routers/aiProvider/validation";
|
} from "@server/routers/aiProvider/validation";
|
||||||
|
import {
|
||||||
|
resolveCapabilitiesForCreate,
|
||||||
|
serializeCapabilities
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -31,6 +36,7 @@ const bodySchema = z
|
|||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
authType: aiAuthTypeSchema.optional(),
|
authType: aiAuthTypeSchema.optional(),
|
||||||
routingMode: aiRoutingModeSchema.optional(),
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
|
capabilities: aiCapabilitiesSchema.optional(),
|
||||||
skipTlsVerification: z.boolean().optional(),
|
skipTlsVerification: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
@@ -94,6 +100,7 @@ export async function createAiProvider(
|
|||||||
apiKey,
|
apiKey,
|
||||||
authType,
|
authType,
|
||||||
routingMode,
|
routingMode,
|
||||||
|
capabilities,
|
||||||
skipTlsVerification,
|
skipTlsVerification,
|
||||||
enabled
|
enabled
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
@@ -108,6 +115,10 @@ export async function createAiProvider(
|
|||||||
authType,
|
authType,
|
||||||
routingMode
|
routingMode
|
||||||
});
|
});
|
||||||
|
const resolvedCapabilities = resolveCapabilitiesForCreate({
|
||||||
|
type,
|
||||||
|
capabilities
|
||||||
|
});
|
||||||
|
|
||||||
const [provider] = await db
|
const [provider] = await db
|
||||||
.insert(aiProviders)
|
.insert(aiProviders)
|
||||||
@@ -120,6 +131,7 @@ export async function createAiProvider(
|
|||||||
apiKeyLastChars,
|
apiKeyLastChars,
|
||||||
authType: resolved.authType,
|
authType: resolved.authType,
|
||||||
routingMode: resolved.routingMode,
|
routingMode: resolved.routingMode,
|
||||||
|
capabilities: serializeCapabilities(resolvedCapabilities),
|
||||||
skipTlsVerification: skipTlsVerification ?? false,
|
skipTlsVerification: skipTlsVerification ?? false,
|
||||||
enabled: enabled ?? true,
|
enabled: enabled ?? true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
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 type { AiProviderAuthType } from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
parseCapabilities,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
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"> & {
|
export type AiProviderPublic = Omit<AiProvider, "apiKey" | "capabilities"> & {
|
||||||
/** Decrypted API key. Only included on get/create/update of a single provider. */
|
/** Decrypted API key. Only included on get/create/update of a single provider. */
|
||||||
apiKey?: string | null;
|
apiKey?: string | null;
|
||||||
|
capabilities: AiCapability[];
|
||||||
effectiveUpstreamUrl: string | null;
|
effectiveUpstreamUrl: string | null;
|
||||||
effectiveAuthType: AiProviderAuthType;
|
effectiveAuthType: AiProviderAuthType;
|
||||||
};
|
};
|
||||||
@@ -39,7 +44,11 @@ export function toPublicAiProvider(
|
|||||||
provider: AiProvider,
|
provider: AiProvider,
|
||||||
options?: { includeApiKey?: boolean }
|
options?: { includeApiKey?: boolean }
|
||||||
): AiProviderPublic {
|
): AiProviderPublic {
|
||||||
const { apiKey: encryptedApiKey, ...rest } = provider;
|
const {
|
||||||
|
apiKey: encryptedApiKey,
|
||||||
|
capabilities: rawCapabilities,
|
||||||
|
...rest
|
||||||
|
} = provider;
|
||||||
|
|
||||||
let apiKey: string | null | undefined;
|
let apiKey: string | null | undefined;
|
||||||
if (options?.includeApiKey) {
|
if (options?.includeApiKey) {
|
||||||
@@ -56,6 +65,7 @@ export function toPublicAiProvider(
|
|||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
...(options?.includeApiKey ? { apiKey } : {}),
|
...(options?.includeApiKey ? { apiKey } : {}),
|
||||||
|
capabilities: parseCapabilities(rawCapabilities),
|
||||||
effectiveUpstreamUrl: provider.upstreamUrl,
|
effectiveUpstreamUrl: provider.upstreamUrl,
|
||||||
effectiveAuthType: provider.authType as AiProviderAuthType
|
effectiveAuthType: provider.authType as AiProviderAuthType
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/
|
|||||||
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
|
||||||
import {
|
import {
|
||||||
aiAuthTypeSchema,
|
aiAuthTypeSchema,
|
||||||
|
aiCapabilitiesSchema,
|
||||||
aiProviderTypeSchema,
|
aiProviderTypeSchema,
|
||||||
aiRoutingModeSchema,
|
aiRoutingModeSchema,
|
||||||
refineProviderUpstreamFields
|
refineProviderUpstreamFields
|
||||||
@@ -23,6 +24,10 @@ import type {
|
|||||||
AiProviderRoutingMode,
|
AiProviderRoutingMode,
|
||||||
AiProviderType
|
AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
parseCapabilities,
|
||||||
|
serializeCapabilities
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
providerId: z.coerce.number().int().positive()
|
providerId: z.coerce.number().int().positive()
|
||||||
@@ -34,6 +39,7 @@ const bodySchema = z.strictObject({
|
|||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
authType: aiAuthTypeSchema.optional(),
|
authType: aiAuthTypeSchema.optional(),
|
||||||
routingMode: aiRoutingModeSchema.optional(),
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
|
capabilities: aiCapabilitiesSchema.optional(),
|
||||||
skipTlsVerification: z.boolean().optional(),
|
skipTlsVerification: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
});
|
});
|
||||||
@@ -122,19 +128,37 @@ export async function updateAiProvider(
|
|||||||
? body.authType
|
? body.authType
|
||||||
: (existing.authType as AiProviderAuthType);
|
: (existing.authType as AiProviderAuthType);
|
||||||
|
|
||||||
|
if (body.capabilities !== undefined && providerType !== "custom") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Capabilities can only be updated for custom providers"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCapabilities =
|
||||||
|
providerType === "custom"
|
||||||
|
? body.capabilities !== undefined
|
||||||
|
? body.capabilities
|
||||||
|
: parseCapabilities(existing.capabilities)
|
||||||
|
: parseCapabilities(existing.capabilities);
|
||||||
|
|
||||||
const validation = z
|
const validation = z
|
||||||
.object({
|
.object({
|
||||||
type: aiProviderTypeSchema,
|
type: aiProviderTypeSchema,
|
||||||
upstreamUrl: z.string().nullable().optional(),
|
upstreamUrl: z.string().nullable().optional(),
|
||||||
authType: aiAuthTypeSchema,
|
authType: aiAuthTypeSchema,
|
||||||
routingMode: aiRoutingModeSchema.optional()
|
routingMode: aiRoutingModeSchema.optional(),
|
||||||
|
capabilities: aiCapabilitiesSchema.optional()
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
|
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
|
||||||
.safeParse({
|
.safeParse({
|
||||||
type: providerType,
|
type: providerType,
|
||||||
upstreamUrl: nextUpstreamUrl,
|
upstreamUrl: nextUpstreamUrl,
|
||||||
authType: nextAuthType,
|
authType: nextAuthType,
|
||||||
routingMode: nextRoutingMode
|
routingMode: nextRoutingMode,
|
||||||
|
capabilities: nextCapabilities
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
@@ -168,6 +192,9 @@ export async function updateAiProvider(
|
|||||||
if (body.authType !== undefined) {
|
if (body.authType !== undefined) {
|
||||||
updateData.authType = body.authType;
|
updateData.authType = body.authType;
|
||||||
}
|
}
|
||||||
|
if (providerType === "custom" && body.capabilities !== undefined) {
|
||||||
|
updateData.capabilities = serializeCapabilities(body.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.apiKey !== undefined) {
|
if (body.apiKey !== undefined) {
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type AiProviderRoutingMode,
|
type AiProviderRoutingMode,
|
||||||
type AiProviderType
|
type AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
export const aiProviderTypeSchema = z.enum([
|
export const aiProviderTypeSchema = z.enum([
|
||||||
"openai",
|
"openai",
|
||||||
@@ -23,12 +24,17 @@ export const aiAuthTypeSchema = z.enum(AI_PROVIDER_AUTH_TYPES);
|
|||||||
|
|
||||||
export const aiRoutingModeSchema = z.enum(["url", "target"]);
|
export const aiRoutingModeSchema = z.enum(["url", "target"]);
|
||||||
|
|
||||||
|
export const aiCapabilitySchema = z.enum(AI_CAPABILITIES);
|
||||||
|
|
||||||
|
export const aiCapabilitiesSchema = z.array(aiCapabilitySchema);
|
||||||
|
|
||||||
export function refineProviderUpstreamFields(
|
export function refineProviderUpstreamFields(
|
||||||
data: {
|
data: {
|
||||||
type: AiProviderType;
|
type: AiProviderType;
|
||||||
upstreamUrl?: string | null;
|
upstreamUrl?: string | null;
|
||||||
authType?: AiProviderAuthType | null;
|
authType?: AiProviderAuthType | null;
|
||||||
routingMode?: AiProviderRoutingMode | null;
|
routingMode?: AiProviderRoutingMode | null;
|
||||||
|
capabilities?: z.infer<typeof aiCapabilitiesSchema> | null;
|
||||||
},
|
},
|
||||||
ctx: z.RefinementCtx
|
ctx: z.RefinementCtx
|
||||||
) {
|
) {
|
||||||
@@ -52,4 +58,16 @@ export function refineProviderUpstreamFields(
|
|||||||
path: ["upstreamUrl"]
|
path: ["upstreamUrl"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === "custom") {
|
||||||
|
const caps = data.capabilities;
|
||||||
|
if (!caps || caps.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"At least one capability is required for custom providers",
|
||||||
|
path: ["capabilities"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
|
import {
|
||||||
|
AiProviderCapabilitiesSelect,
|
||||||
|
capabilityLabelKey
|
||||||
|
} from "@app/components/AiProviderCapabilitiesSelect";
|
||||||
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";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -28,6 +33,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 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";
|
||||||
@@ -43,17 +49,32 @@ export default function AiProviderGeneralPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const isCustom = provider.type === "custom";
|
||||||
|
|
||||||
const generalSchema = useMemo(
|
const generalSchema = useMemo(
|
||||||
() =>
|
() =>
|
||||||
z.object({
|
z
|
||||||
name: z
|
.object({
|
||||||
.string()
|
name: z
|
||||||
.trim()
|
.string()
|
||||||
.min(1, { message: t("nameRequired") }),
|
.trim()
|
||||||
enabled: z.boolean()
|
.min(1, { message: t("nameRequired") }),
|
||||||
}),
|
enabled: z.boolean(),
|
||||||
[t]
|
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (
|
||||||
|
isCustom &&
|
||||||
|
(!data.capabilities || data.capabilities.length === 0)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: t("aiProviderErrorCapabilitiesRequired"),
|
||||||
|
path: ["capabilities"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[t, isCustom]
|
||||||
);
|
);
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof generalSchema>;
|
type GeneralFormValues = z.infer<typeof generalSchema>;
|
||||||
@@ -62,24 +83,35 @@ export default function AiProviderGeneralPage() {
|
|||||||
resolver: zodResolver(generalSchema),
|
resolver: zodResolver(generalSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
enabled: provider.enabled
|
enabled: provider.enabled,
|
||||||
|
capabilities: provider.capabilities ?? []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: GeneralFormValues) {
|
async function onSubmit(values: GeneralFormValues) {
|
||||||
setSaveLoading(true);
|
setSaveLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<
|
const body: {
|
||||||
AxiosResponse<CreateOrEditAiProviderResponse>
|
name: string;
|
||||||
>(`/ai-provider/${provider.providerId}`, {
|
enabled: boolean;
|
||||||
|
capabilities?: AiCapability[];
|
||||||
|
} = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
enabled: values.enabled
|
enabled: values.enabled
|
||||||
});
|
};
|
||||||
|
if (isCustom) {
|
||||||
|
body.capabilities = values.capabilities ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.post<
|
||||||
|
AxiosResponse<CreateOrEditAiProviderResponse>
|
||||||
|
>(`/ai-provider/${provider.providerId}`, body);
|
||||||
const updated = res.data.data.provider;
|
const updated = res.data.data.provider;
|
||||||
updateProvider(updated);
|
updateProvider(updated);
|
||||||
form.reset({
|
form.reset({
|
||||||
name: updated.name,
|
name: updated.name,
|
||||||
enabled: updated.enabled
|
enabled: updated.enabled,
|
||||||
|
capabilities: updated.capabilities ?? []
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: t("success"),
|
title: t("success"),
|
||||||
@@ -166,6 +198,65 @@ export default function AiProviderGeneralPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="capabilities"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"aiProviderCapabilities"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{isCustom ? (
|
||||||
|
<AiProviderCapabilitiesSelect
|
||||||
|
value={
|
||||||
|
field.value ??
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
onChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
provider.capabilities ??
|
||||||
|
[]
|
||||||
|
).map((cap) => (
|
||||||
|
<span
|
||||||
|
key={
|
||||||
|
cap
|
||||||
|
}
|
||||||
|
className="inline-flex items-center rounded-md border border-input bg-muted/40 px-2.5 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
capabilityLabelKey(
|
||||||
|
cap
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{isCustom
|
||||||
|
? t(
|
||||||
|
"aiProviderCapabilitiesCustomDescription"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"aiProviderCapabilitiesDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
</SettingsFormGrid>
|
</SettingsFormGrid>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
|
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
|
||||||
|
import {
|
||||||
|
AiProviderCapabilitiesSelect,
|
||||||
|
capabilityLabelKey
|
||||||
|
} from "@app/components/AiProviderCapabilitiesSelect";
|
||||||
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
|
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
@@ -40,6 +44,7 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|||||||
import {
|
import {
|
||||||
createAiProviderCreateFormSchema,
|
createAiProviderCreateFormSchema,
|
||||||
defaultAuthTypeForProvider,
|
defaultAuthTypeForProvider,
|
||||||
|
defaultCapabilitiesForProvider,
|
||||||
emptyUpstreamForType,
|
emptyUpstreamForType,
|
||||||
showsUpstreamUrlField,
|
showsUpstreamUrlField,
|
||||||
toAiProviderCreatePayload,
|
toAiProviderCreatePayload,
|
||||||
@@ -76,6 +81,7 @@ export default function CreateAiProviderPage() {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
authType: defaultAuthTypeForProvider("openai"),
|
authType: defaultAuthTypeForProvider("openai"),
|
||||||
routingMode: "url",
|
routingMode: "url",
|
||||||
|
capabilities: defaultCapabilitiesForProvider("openai"),
|
||||||
skipTlsVerification: false,
|
skipTlsVerification: false,
|
||||||
enabled: true
|
enabled: true
|
||||||
}
|
}
|
||||||
@@ -84,12 +90,14 @@ export default function CreateAiProviderPage() {
|
|||||||
const providerType = form.watch("type");
|
const providerType = form.watch("type");
|
||||||
const routingMode = form.watch("routingMode");
|
const routingMode = form.watch("routingMode");
|
||||||
const authType = form.watch("authType");
|
const authType = form.watch("authType");
|
||||||
|
const capabilities = form.watch("capabilities");
|
||||||
|
|
||||||
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
|
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
|
||||||
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
|
const requireUpstream = upstreamUrlRequired(providerType, routingMode);
|
||||||
const showRoutingMode = providerType === "custom";
|
const showRoutingMode = providerType === "custom";
|
||||||
const showTargets = providerType === "custom" && routingMode === "target";
|
const showTargets = providerType === "custom" && routingMode === "target";
|
||||||
const showApiKey = authTypeRequiresApiKey(authType ?? "bearer");
|
const showApiKey = authTypeRequiresApiKey(authType ?? "bearer");
|
||||||
|
const showCapabilitiesSelect = providerType === "custom";
|
||||||
|
|
||||||
async function createTargets(
|
async function createTargets(
|
||||||
providerId: number,
|
providerId: number,
|
||||||
@@ -277,6 +285,12 @@ export default function CreateAiProviderPage() {
|
|||||||
value
|
value
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
form.setValue(
|
||||||
|
"capabilities",
|
||||||
|
defaultCapabilitiesForProvider(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
value !==
|
value !==
|
||||||
"custom"
|
"custom"
|
||||||
@@ -296,6 +310,67 @@ export default function CreateAiProviderPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="capabilities"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"aiProviderCapabilities"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{showCapabilitiesSelect ? (
|
||||||
|
<AiProviderCapabilitiesSelect
|
||||||
|
value={
|
||||||
|
field.value ??
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
onChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
capabilities ??
|
||||||
|
defaultCapabilitiesForProvider(
|
||||||
|
providerType
|
||||||
|
)
|
||||||
|
).map((cap) => (
|
||||||
|
<span
|
||||||
|
key={
|
||||||
|
cap
|
||||||
|
}
|
||||||
|
className="inline-flex items-center rounded-md border border-input bg-muted/40 px-2.5 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
capabilityLabelKey(
|
||||||
|
cap
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{showCapabilitiesSelect
|
||||||
|
? t(
|
||||||
|
"aiProviderCapabilitiesCustomDescription"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"aiProviderCapabilitiesDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
</SettingsFormGrid>
|
</SettingsFormGrid>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||||
|
import { AI_CAPABILITIES, type AiCapability } from "@server/lib/aiCapabilities";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type CapabilityOption = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiProviderCapabilitiesSelectProps = {
|
||||||
|
value: AiCapability[];
|
||||||
|
onChange: (capabilities: AiCapability[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
|
||||||
|
openai_chat: "aiCapabilityOpenaiChat",
|
||||||
|
openai_responses: "aiCapabilityOpenaiResponses",
|
||||||
|
anthropic_messages: "aiCapabilityAnthropicMessages",
|
||||||
|
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
|
||||||
|
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
|
||||||
|
google_generate_content: "aiCapabilityGoogleGenerateContent",
|
||||||
|
google_raw_predict: "aiCapabilityGoogleRawPredict",
|
||||||
|
bedrock_converse: "aiCapabilityBedrockConverse"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function capabilityLabelKey(capability: AiCapability): string {
|
||||||
|
return CAPABILITY_LABEL_KEYS[capability];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiProviderCapabilitiesSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled
|
||||||
|
}: AiProviderCapabilitiesSelectProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const options: CapabilityOption[] = useMemo(
|
||||||
|
() =>
|
||||||
|
AI_CAPABILITIES.map((id) => ({
|
||||||
|
id,
|
||||||
|
text: t(CAPABILITY_LABEL_KEYS[id])
|
||||||
|
})),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
return options.filter(
|
||||||
|
(o) =>
|
||||||
|
o.text.toLowerCase().includes(q) ||
|
||||||
|
o.id.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [options, searchQuery]);
|
||||||
|
|
||||||
|
const selected: CapabilityOption[] = value.map((id) => ({
|
||||||
|
id,
|
||||||
|
text: t(CAPABILITY_LABEL_KEYS[id])
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectTagInput
|
||||||
|
buttonText={t("aiProviderCapabilitiesSelect")}
|
||||||
|
emptyPlaceholder={t("aiProviderCapabilitiesEmpty")}
|
||||||
|
searchPlaceholder={t("aiProviderCapabilitiesSearch")}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
options={filtered}
|
||||||
|
value={selected}
|
||||||
|
onChange={(next) =>
|
||||||
|
onChange(
|
||||||
|
next
|
||||||
|
.map((item) => item.id)
|
||||||
|
.filter((id): id is AiCapability =>
|
||||||
|
(AI_CAPABILITIES as readonly string[]).includes(id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSearch={setSearchQuery}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
type AiProviderAuthType,
|
type AiProviderAuthType,
|
||||||
type AiProviderType
|
type AiProviderType
|
||||||
} from "@server/lib/aiProviderDefaults";
|
} from "@server/lib/aiProviderDefaults";
|
||||||
|
import {
|
||||||
|
AI_CAPABILITIES,
|
||||||
|
defaultsForProviderType,
|
||||||
|
type AiCapability
|
||||||
|
} from "@server/lib/aiCapabilities";
|
||||||
|
|
||||||
type TranslateFn = (key: string) => string;
|
type TranslateFn = (key: string) => string;
|
||||||
|
|
||||||
@@ -22,6 +27,8 @@ export const aiProviderTypeValues = [
|
|||||||
"custom"
|
"custom"
|
||||||
] as const satisfies readonly AiProviderType[];
|
] as const satisfies readonly AiProviderType[];
|
||||||
|
|
||||||
|
export const aiCapabilityValues = AI_CAPABILITIES;
|
||||||
|
|
||||||
export function createAiProviderFormSchema(t: TranslateFn) {
|
export function createAiProviderFormSchema(t: TranslateFn) {
|
||||||
return z
|
return z
|
||||||
.object({
|
.object({
|
||||||
@@ -34,6 +41,7 @@ export function createAiProviderFormSchema(t: TranslateFn) {
|
|||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
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(),
|
||||||
skipTlsVerification: z.boolean().optional(),
|
skipTlsVerification: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
@@ -84,6 +92,17 @@ export function createAiProviderFormSchema(t: TranslateFn) {
|
|||||||
path: ["authType"]
|
path: ["authType"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.type === "custom" &&
|
||||||
|
(!data.capabilities || data.capabilities.length === 0)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: t("aiProviderErrorCapabilitiesRequired"),
|
||||||
|
path: ["capabilities"]
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +133,12 @@ export function defaultAuthTypeForProvider(
|
|||||||
return AI_PROVIDER_DEFAULTS[type].authType;
|
return AI_PROVIDER_DEFAULTS[type].authType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function defaultCapabilitiesForProvider(
|
||||||
|
type: AiProviderType
|
||||||
|
): AiCapability[] {
|
||||||
|
return [...defaultsForProviderType(type)];
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyUpstreamForType(type: AiProviderType): string {
|
export function emptyUpstreamForType(type: AiProviderType): string {
|
||||||
if (type === "custom") {
|
if (type === "custom") {
|
||||||
return "";
|
return "";
|
||||||
@@ -158,6 +183,8 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) {
|
|||||||
upstreamUrl,
|
upstreamUrl,
|
||||||
apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined,
|
apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined,
|
||||||
authType: values.authType ?? "bearer",
|
authType: values.authType ?? "bearer",
|
||||||
|
capabilities:
|
||||||
|
values.type === "custom" ? (values.capabilities ?? []) : undefined,
|
||||||
skipTlsVerification: values.skipTlsVerification,
|
skipTlsVerification: values.skipTlsVerification,
|
||||||
enabled: values.enabled ?? true
|
enabled: values.enabled ?? true
|
||||||
};
|
};
|
||||||
@@ -183,6 +210,10 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
|
|||||||
enabled: values.enabled ?? true
|
enabled: values.enabled ?? true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (values.type === "custom" && values.capabilities) {
|
||||||
|
payload.capabilities = values.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
if (values.apiKey?.trim()) {
|
if (values.apiKey?.trim()) {
|
||||||
payload.apiKey = values.apiKey.trim();
|
payload.apiKey = values.apiKey.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user