diff --git a/messages/en-US.json b/messages/en-US.json index aff3be28b..24d10ab95 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1424,6 +1424,16 @@ "actionDeleteSite": "Delete Site", "actionGetSite": "Get Site", "actionListSites": "List Sites", + "actionCreateAiProvider": "Create AI Provider", + "actionDeleteAiProvider": "Delete AI Provider", + "actionGetAiProvider": "Get AI Provider", + "actionListAiProviders": "List AI Providers", + "actionUpdateAiProvider": "Update AI Provider", + "actionCreateAiModel": "Create AI Model", + "actionDeleteAiModel": "Delete AI Model", + "actionGetAiModel": "Get AI Model", + "actionListAiModels": "List AI Models", + "actionUpdateAiModel": "Update AI Model", "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 741d7a057..944adbff4 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -182,7 +182,17 @@ export enum ActionsEnum { setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", setResourcePolicyRules = "setResourcePolicyRules", - createOrgWideLauncherView = "createOrgWideLauncherView" + createOrgWideLauncherView = "createOrgWideLauncherView", + createAiProvider = "createAiProvider", + deleteAiProvider = "deleteAiProvider", + getAiProvider = "getAiProvider", + listAiProviders = "listAiProviders", + updateAiProvider = "updateAiProvider", + createAiModel = "createAiModel", + deleteAiModel = "deleteAiModel", + getAiModel = "getAiModel", + listAiModels = "listAiModels", + updateAiModel = "updateAiModel" } export async function checkUserActionPermission( diff --git a/server/index.ts b/server/index.ts index 53b3e9a69..88612b86a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,8 @@ import { createIntegrationApiServer } from "./integrationApiServer"; import { ApiKey, ApiKeyOrg, + AiModel, + AiProvider, RemoteExitNode, Session, SiteResource, @@ -83,6 +85,8 @@ declare global { userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; siteResource?: SiteResource; + aiProvider?: AiProvider; + aiModel?: AiModel; orgPolicyAllowed?: boolean; } } diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts new file mode 100644 index 000000000..d89be99a0 --- /dev/null +++ b/server/lib/aiProviderDefaults.ts @@ -0,0 +1,48 @@ +export type AiProviderType = "openai" | "anthropic" | "bedrock" | "custom"; +export type AiProviderAuthType = "bearer"; +export type AiBudgetUnit = "usd" | "tokens"; + +type AiProviderDefaults = { + upstreamUrl: string; + authType: AiProviderAuthType; +}; + +export const AI_PROVIDER_DEFAULTS: Record< + Exclude, + AiProviderDefaults +> = { + openai: { + upstreamUrl: "https://api.openai.com/v1", + authType: "bearer" + }, + anthropic: { + upstreamUrl: "https://api.anthropic.com", + authType: "bearer" + }, + bedrock: { + upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + authType: "bearer" + } +}; + +export function resolveAiProviderConfig(input: { + type: AiProviderType; + upstreamUrl: string | null; + authType: AiProviderAuthType | null; +}): { + upstreamUrl: string | null; + authType: AiProviderAuthType | null; +} { + if (input.type === "custom") { + return { + upstreamUrl: input.upstreamUrl, + authType: input.authType + }; + } + + const defaults = AI_PROVIDER_DEFAULTS[input.type]; + return { + upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl, + authType: input.authType ?? defaults.authType + }; +} diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index a7f3ae125..8f6b50e33 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -27,6 +27,8 @@ export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; export * from "./verifySiteProvisioningKeyAccess"; export * from "./verifyDomainAccess"; +export * from "./verifyAiProviderAccess"; +export * from "./verifyAiModelAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserFromResourceSession"; export * from "./verifySiteResourceAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index be9d400cf..63df4b2bb 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -16,5 +16,7 @@ export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyDomainAccess"; +export * from "./verifyApiKeyAiProviderAccess"; +export * from "./verifyApiKeyAiModelAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeySiteProvisioningKeyAccess"; diff --git a/server/middlewares/integration/verifyApiKeyAiModelAccess.ts b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts new file mode 100644 index 000000000..75a56f37e --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts @@ -0,0 +1,113 @@ +import { Request, Response, NextFunction } from "express"; +import { aiModels, aiProviders, apiKeyOrg, db } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyAiModelAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const modelIdRaw = getFirstString(req.params.modelId); + const modelId = Number.parseInt(modelIdRaw ?? "", 10); + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + if (Number.isNaN(modelId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") + ); + } + + const [row] = await db + .select({ + model: aiModels, + provider: aiProviders + }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + if (apiKey.isRoot) { + req.aiProvider = row.provider; + req.aiModel = row.model; + return next(); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.aiProvider = row.provider; + req.aiModel = row.model; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI model access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts new file mode 100644 index 000000000..6b9f9b0ec --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts @@ -0,0 +1,115 @@ +import { Request, Response, NextFunction } from "express"; +import { aiProviders, apiKeyOrg, db } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeyAiProviderAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + if (apiKey.isRoot) { + const [provider] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + req.aiProvider = provider; + return next(); + } + + const [provider] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.aiProvider = provider; + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI provider access" + ) + ); + } +} diff --git a/server/middlewares/verifyAiModelAccess.ts b/server/middlewares/verifyAiModelAccess.ts new file mode 100644 index 000000000..99693b982 --- /dev/null +++ b/server/middlewares/verifyAiModelAccess.ts @@ -0,0 +1,125 @@ +import { Request, Response, NextFunction } from "express"; +import { aiModels, aiProviders, db, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyAiModelAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const modelIdRaw = getFirstString(req.params.modelId); + const modelId = Number.parseInt(modelIdRaw ?? "", 10); + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + if (Number.isNaN(modelId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") + ); + } + + const [row] = await db + .select({ + model: aiModels, + provider: aiProviders + }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "" + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.aiProvider = row.provider; + req.aiModel = row.model; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI model access" + ) + ); + } +} diff --git a/server/middlewares/verifyAiProviderAccess.ts b/server/middlewares/verifyAiProviderAccess.ts new file mode 100644 index 000000000..604e3e893 --- /dev/null +++ b/server/middlewares/verifyAiProviderAccess.ts @@ -0,0 +1,108 @@ +import { Request, Response, NextFunction } from "express"; +import { aiProviders, db, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyAiProviderAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const providerIdRaw = getFirstString(req.params.providerId); + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const orgId = getFirstString(req.params.orgId); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + const [provider] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "" + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.aiProvider = provider; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying AI provider access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 27c26b2da..1bcaa23ab 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -28,7 +28,9 @@ export enum OpenAPITags { HealthCheck = "Health Check", PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)", PublicResourceLegacy = "Public Resource (Legacy)", - PrivateResourceLegacy = "Private Resource (Legacy)" + PrivateResourceLegacy = "Private Resource (Legacy)", + AiProvider = "AI Provider", + AiModel = "AI Model" } // Order here controls the order tags are displayed in Swagger UI diff --git a/server/routers/aiProvider/createAiModel.ts b/server/routers/aiProvider/createAiModel.ts new file mode 100644 index 000000000..a770a5ed8 --- /dev/null +++ b/server/routers/aiProvider/createAiModel.ts @@ -0,0 +1,157 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; +import { + aiBudgetUnitSchema, + refineBudgetFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +const bodySchema = z + .strictObject({ + modelKey: z.string().nonempty(), + name: z.string().nonempty(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/ai-provider/{providerId}/model", + description: "Create an AI model under a provider.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + const { modelKey, name, budgetAmount, budgetUnit, enabled } = + parsedBody.data; + + const [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + const [existing] = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + and( + eq(aiModels.providerId, providerId), + eq(aiModels.modelKey, modelKey) + ) + ) + .limit(1); + + if (existing) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Model with key ${modelKey} already exists for this provider` + ) + ); + } + + const now = Date.now(); + const [model] = await db + .insert(aiModels) + .values({ + providerId, + modelKey, + name, + budgetAmount: budgetAmount ?? null, + budgetUnit: budgetUnit ?? null, + enabled: enabled ?? true, + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { model }, + success: true, + error: false, + message: "AI model created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts new file mode 100644 index 000000000..dad11d18d --- /dev/null +++ b/server/routers/aiProvider/createAiProvider.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; +import { + aiAuthTypeSchema, + aiBudgetUnitSchema, + aiProviderTypeSchema, + refineBudgetFields, + refineCustomProviderFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + type: aiProviderTypeSchema, + upstreamUrl: z.url().optional().nullable(), + apiKey: z.string().optional(), + authType: aiAuthTypeSchema.optional().nullable(), + skipTlsVerification: z.boolean().optional(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineCustomProviderFields(data, ctx); + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/ai-provider", + description: "Create an AI provider for an organization.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + name, + type, + upstreamUrl, + apiKey, + authType, + skipTlsVerification, + budgetAmount, + budgetUnit, + enabled + } = parsedBody.data; + + const key = config.getRawConfig().server.secret!; + const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null; + const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null; + const now = Date.now(); + + const [provider] = await db + .insert(aiProviders) + .values({ + orgId, + name, + type, + upstreamUrl: upstreamUrl ?? null, + apiKey: encryptedApiKey, + apiKeyLastChars, + authType: authType ?? null, + skipTlsVerification: skipTlsVerification ?? false, + budgetAmount: budgetAmount ?? null, + budgetUnit: budgetUnit ?? null, + enabled: enabled ?? true, + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { provider: toPublicAiProvider(provider) }, + success: true, + error: false, + message: "AI provider created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/deleteAiModel.ts b/server/routers/aiProvider/deleteAiModel.ts new file mode 100644 index 000000000..be784275e --- /dev/null +++ b/server/routers/aiProvider/deleteAiModel.ts @@ -0,0 +1,98 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive(), + modelId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + description: "Delete an AI model.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId, modelId } = parsedParams.data; + + const [existing] = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + await db + .delete(aiModels) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "AI model deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/deleteAiProvider.ts b/server/routers/aiProvider/deleteAiProvider.ts new file mode 100644 index 000000000..b5416c23c --- /dev/null +++ b/server/routers/aiProvider/deleteAiProvider.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/ai-provider/{providerId}", + description: "Delete an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + + const [existing] = await db + .select({ providerId: aiProviders.providerId }) + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + await db + .delete(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "AI provider deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/getAiModel.ts b/server/routers/aiProvider/getAiModel.ts new file mode 100644 index 000000000..6ea8795ee --- /dev/null +++ b/server/routers/aiProvider/getAiModel.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import type { GetAiModelResponse } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive(), + modelId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + description: "Get an AI model by ID.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId, modelId } = parsedParams.data; + + const [model] = + req.aiModel && req.aiModel.modelId === modelId + ? [req.aiModel] + : await db + .select({ model: aiModels }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1) + .then((rows) => rows.map((r) => r.model)); + + if (!model) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + return response(res, { + data: { model }, + success: true, + error: false, + message: "AI model retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts new file mode 100644 index 000000000..e2abd3948 --- /dev/null +++ b/server/routers/aiProvider/getAiProvider.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{providerId}", + description: "Get an AI provider by ID.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + + const [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + return response(res, { + data: { provider: toPublicAiProvider(provider) }, + success: true, + error: false, + message: "AI provider retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/index.ts b/server/routers/aiProvider/index.ts new file mode 100644 index 000000000..34c900139 --- /dev/null +++ b/server/routers/aiProvider/index.ts @@ -0,0 +1,11 @@ +export * from "./createAiProvider"; +export * from "./listAiProviders"; +export * from "./getAiProvider"; +export * from "./updateAiProvider"; +export * from "./deleteAiProvider"; +export * from "./createAiModel"; +export * from "./listAiModels"; +export * from "./getAiModel"; +export * from "./updateAiModel"; +export * from "./deleteAiModel"; +export * from "./types"; diff --git a/server/routers/aiProvider/listAiModels.ts b/server/routers/aiProvider/listAiModels.ts new file mode 100644 index 000000000..a7794efca --- /dev/null +++ b/server/routers/aiProvider/listAiModels.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, like, sql } from "drizzle-orm"; +import type { ListAiModelsResponse } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{providerId}/models", + description: "List AI models for a provider.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiModels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + + const [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select({ providerId: aiProviders.providerId }) + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + const { pageSize, page, query } = parsedQuery.data; + const conditions = [eq(aiModels.providerId, providerId)]; + + if (query) { + conditions.push( + like( + sql`LOWER(${aiModels.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = db + .select() + .from(aiModels) + .where(and(...conditions)); + + const countQuery = db.$count( + db + .select() + .from(aiModels) + .where(and(...conditions)) + .as("filtered_ai_models") + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(aiModels.name)) + ]); + + return response(res, { + data: { + models: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "AI models retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/listAiProviders.ts b/server/routers/aiProvider/listAiProviders.ts new file mode 100644 index 000000000..7ec89df51 --- /dev/null +++ b/server/routers/aiProvider/listAiProviders.ts @@ -0,0 +1,152 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, like, sql } from "drizzle-orm"; +import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listSchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-providers", + description: "List AI providers for an organization.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listAiProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { pageSize, page, query } = parsedQuery.data; + const conditions = [eq(aiProviders.orgId, orgId)]; + + if (query) { + conditions.push( + like( + sql`LOWER(${aiProviders.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = db + .select() + .from(aiProviders) + .where(and(...conditions)); + + const countQuery = db.$count( + db + .select() + .from(aiProviders) + .where(and(...conditions)) + .as("filtered_ai_providers") + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(aiProviders.name)) + ]); + + return response(res, { + data: { + providers: rows.map(toPublicAiProvider), + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "AI providers retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts new file mode 100644 index 000000000..4b8461b5e --- /dev/null +++ b/server/routers/aiProvider/types.ts @@ -0,0 +1,51 @@ +import type { AiModel, AiProvider } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { + resolveAiProviderConfig, + type AiProviderAuthType, + type AiProviderType +} from "@server/lib/aiProviderDefaults"; + +export type AiProviderPublic = Omit & { + effectiveUpstreamUrl: string | null; + effectiveAuthType: AiProviderAuthType | null; +}; + +export type ListAiProvidersResponse = PaginatedResponse<{ + providers: AiProviderPublic[]; +}>; + +export type GetAiProviderResponse = { + provider: AiProviderPublic; +}; + +export type CreateOrEditAiProviderResponse = { + provider: AiProviderPublic; +}; + +export type ListAiModelsResponse = PaginatedResponse<{ + models: AiModel[]; +}>; + +export type GetAiModelResponse = { + model: AiModel; +}; + +export type CreateOrEditAiModelResponse = { + model: AiModel; +}; + +export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { + const { apiKey: _apiKey, ...rest } = provider; + const resolved = resolveAiProviderConfig({ + type: provider.type as AiProviderType, + upstreamUrl: provider.upstreamUrl, + authType: provider.authType as AiProviderAuthType | null + }); + + return { + ...rest, + effectiveUpstreamUrl: resolved.upstreamUrl, + effectiveAuthType: resolved.authType + }; +} diff --git a/server/routers/aiProvider/updateAiModel.ts b/server/routers/aiProvider/updateAiModel.ts new file mode 100644 index 000000000..ad09ee823 --- /dev/null +++ b/server/routers/aiProvider/updateAiModel.ts @@ -0,0 +1,185 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiModels, aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, ne } from "drizzle-orm"; +import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types"; +import { + aiBudgetUnitSchema, + refineBudgetFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive(), + modelId: z.coerce.number().int().positive() +}); + +const bodySchema = z + .strictObject({ + modelKey: z.string().nonempty().optional(), + name: z.string().nonempty().optional(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + description: "Update an AI model.", + tags: [OpenAPITags.AiModel], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateAiModel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, providerId, modelId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = + req.aiModel && req.aiModel.modelId === modelId + ? [req.aiModel] + : await db + .select({ model: aiModels }) + .from(aiModels) + .innerJoin( + aiProviders, + eq(aiModels.providerId, aiProviders.providerId) + ) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1) + .then((rows) => rows.map((r) => r.model)); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI model with ID ${modelId} not found` + ) + ); + } + + if ( + body.modelKey !== undefined && + body.modelKey !== existing.modelKey + ) { + const [conflict] = await db + .select({ modelId: aiModels.modelId }) + .from(aiModels) + .where( + and( + eq(aiModels.providerId, providerId), + eq(aiModels.modelKey, body.modelKey), + ne(aiModels.modelId, modelId) + ) + ) + .limit(1); + + if (conflict) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Model with key ${body.modelKey} already exists for this provider` + ) + ); + } + } + + const updateData: Partial = { + updatedAt: Date.now() + }; + + if (body.modelKey !== undefined) { + updateData.modelKey = body.modelKey; + } + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.budgetAmount !== undefined) { + updateData.budgetAmount = body.budgetAmount; + } + if (body.budgetUnit !== undefined) { + updateData.budgetUnit = body.budgetUnit; + } + if (body.enabled !== undefined) { + updateData.enabled = body.enabled; + } + + const [model] = await db + .update(aiModels) + .set(updateData) + .where( + and( + eq(aiModels.modelId, modelId), + eq(aiModels.providerId, providerId) + ) + ) + .returning(); + + return response(res, { + data: { model }, + success: true, + error: false, + message: "AI model updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts new file mode 100644 index 000000000..8525e1dd3 --- /dev/null +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -0,0 +1,208 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { aiProviders, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; +import { toPublicAiProvider } from "@server/routers/aiProvider/types"; +import type { AiProviderType } from "@server/lib/aiProviderDefaults"; +import { + aiAuthTypeSchema, + aiBudgetUnitSchema, + refineBudgetFields, + refineCustomProviderFields +} from "@server/routers/aiProvider/validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + providerId: z.coerce.number().int().positive() +}); + +const bodySchema = z + .strictObject({ + name: z.string().nonempty().optional(), + upstreamUrl: z.url().optional().nullable(), + apiKey: z.string().optional(), + authType: aiAuthTypeSchema.optional().nullable(), + skipTlsVerification: z.boolean().optional(), + budgetAmount: z.number().positive().optional().nullable(), + budgetUnit: aiBudgetUnitSchema.optional().nullable(), + enabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + refineBudgetFields(data, ctx); + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/ai-provider/{providerId}", + description: "Update an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateAiProvider( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, providerId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + const providerType = existing.type as AiProviderType; + + if (providerType === "custom") { + const nextUpstreamUrl = + body.upstreamUrl !== undefined + ? body.upstreamUrl + : existing.upstreamUrl; + const nextAuthType = + body.authType !== undefined ? body.authType : existing.authType; + + const validation = z + .object({ + type: z.literal("custom"), + upstreamUrl: z.string().nullable().optional(), + authType: aiAuthTypeSchema.nullable().optional() + }) + .superRefine((data, ctx) => + refineCustomProviderFields(data, ctx) + ) + .safeParse({ + type: "custom", + upstreamUrl: nextUpstreamUrl, + authType: nextAuthType + }); + + if (!validation.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(validation.error).toString() + ) + ); + } + } + + const updateData: Partial = { + updatedAt: Date.now() + }; + + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.skipTlsVerification !== undefined) { + updateData.skipTlsVerification = body.skipTlsVerification; + } + if (body.enabled !== undefined) { + updateData.enabled = body.enabled; + } + if (body.budgetAmount !== undefined) { + updateData.budgetAmount = body.budgetAmount; + } + if (body.budgetUnit !== undefined) { + updateData.budgetUnit = body.budgetUnit; + } + if (body.upstreamUrl !== undefined) { + updateData.upstreamUrl = body.upstreamUrl; + } + if (body.authType !== undefined) { + updateData.authType = body.authType; + } + + if (body.apiKey !== undefined) { + const key = config.getRawConfig().server.secret!; + updateData.apiKey = encrypt(body.apiKey, key); + updateData.apiKeyLastChars = body.apiKey.slice(-4); + } + + const [provider] = await db + .update(aiProviders) + .set(updateData) + .where( + and( + eq(aiProviders.providerId, providerId), + eq(aiProviders.orgId, orgId) + ) + ) + .returning(); + + return response(res, { + data: { provider: toPublicAiProvider(provider) }, + success: true, + error: false, + message: "AI provider updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts new file mode 100644 index 000000000..62b442c59 --- /dev/null +++ b/server/routers/aiProvider/validation.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import type { + AiBudgetUnit, + AiProviderType +} from "@server/lib/aiProviderDefaults"; + +export const aiProviderTypeSchema = z.enum([ + "openai", + "anthropic", + "bedrock", + "custom" +]); + +export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]); + +export const aiAuthTypeSchema = z.enum(["bearer"]); + +export function refineBudgetFields( + data: { + budgetAmount?: number | null; + budgetUnit?: AiBudgetUnit | null; + }, + ctx: z.RefinementCtx +) { + const hasAmount = + data.budgetAmount !== undefined && data.budgetAmount !== null; + const hasUnit = data.budgetUnit !== undefined && data.budgetUnit !== null; + + if (hasAmount !== hasUnit) { + ctx.addIssue({ + code: "custom", + message: + "budgetAmount and budgetUnit must both be set or both omitted", + path: hasAmount ? ["budgetUnit"] : ["budgetAmount"] + }); + } +} + +export function refineCustomProviderFields( + data: { + type: AiProviderType; + upstreamUrl?: string | null; + authType?: "bearer" | null; + }, + ctx: z.RefinementCtx +) { + if (data.type !== "custom") { + return; + } + + if (!data.upstreamUrl) { + ctx.addIssue({ + code: "custom", + message: "upstreamUrl is required for custom providers", + path: ["upstreamUrl"] + }); + } + + if (!data.authType) { + ctx.addIssue({ + code: "custom", + message: "authType is required for custom providers", + path: ["authType"] + }); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index ffe0a809b..1d33d6166 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -45,7 +45,9 @@ import { verifySiteResourceAccess, verifyOlmAccess, verifyLimits, - verifyResourcePolicyAccess + verifyResourcePolicyAccess, + verifyAiProviderAccess, + verifyAiModelAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -55,6 +57,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore"; import { logActionAudit } from "#dynamic/middlewares"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; +import * as aiProvider from "@server/routers/aiProvider"; // Root routes export const unauthenticated = Router(); @@ -1366,6 +1369,90 @@ authenticated.get( authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); +authenticated.put( + "/org/:orgId/ai-provider", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createAiProvider), + logActionAudit(ActionsEnum.createAiProvider), + aiProvider.createAiProvider +); + +authenticated.get( + "/org/:orgId/ai-providers", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listAiProviders), + aiProvider.listAiProviders +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.updateAiProvider), + logActionAudit(ActionsEnum.updateAiProvider), + aiProvider.updateAiProvider +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.deleteAiProvider), + logActionAudit(ActionsEnum.deleteAiProvider), + aiProvider.deleteAiProvider +); + +authenticated.put( + "/org/:orgId/ai-provider/:providerId/model", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.createAiModel), + logActionAudit(ActionsEnum.createAiModel), + aiProvider.createAiModel +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/models", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.listAiModels), + aiProvider.listAiModels +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyOrgAccess, + verifyAiModelAccess, + verifyUserHasAction(ActionsEnum.getAiModel), + aiProvider.getAiModel +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyOrgAccess, + verifyAiModelAccess, + verifyUserHasAction(ActionsEnum.updateAiModel), + logActionAudit(ActionsEnum.updateAiModel), + aiProvider.updateAiModel +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyOrgAccess, + verifyAiModelAccess, + verifyUserHasAction(ActionsEnum.deleteAiModel), + logActionAudit(ActionsEnum.deleteAiModel), + aiProvider.deleteAiModel +); + authenticated.get( "/org/:orgId/labels", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 13498ad25..3a17079c6 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; +import * as aiProvider from "./aiProvider"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -31,6 +32,8 @@ import { verifyLimits, verifyApiKeyDomainAccess, verifyApiKeyResourcePolicyAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyAiModelAccess, verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; @@ -1366,3 +1369,87 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.listResources), resource.listAllResourceNames ); + +authenticated.put( + "/org/:orgId/ai-provider", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createAiProvider), + logActionAudit(ActionsEnum.createAiProvider), + aiProvider.createAiProvider +); + +authenticated.get( + "/org/:orgId/ai-providers", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listAiProviders), + aiProvider.listAiProviders +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.updateAiProvider), + logActionAudit(ActionsEnum.updateAiProvider), + aiProvider.updateAiProvider +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAiProvider), + logActionAudit(ActionsEnum.deleteAiProvider), + aiProvider.deleteAiProvider +); + +authenticated.put( + "/org/:orgId/ai-provider/:providerId/model", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.createAiModel), + logActionAudit(ActionsEnum.createAiModel), + aiProvider.createAiModel +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/models", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.listAiModels), + aiProvider.listAiModels +); + +authenticated.get( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyApiKeyOrgAccess, + verifyApiKeyAiModelAccess, + verifyApiKeyHasAction(ActionsEnum.getAiModel), + aiProvider.getAiModel +); + +authenticated.post( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyApiKeyOrgAccess, + verifyApiKeyAiModelAccess, + verifyApiKeyHasAction(ActionsEnum.updateAiModel), + logActionAudit(ActionsEnum.updateAiModel), + aiProvider.updateAiModel +); + +authenticated.delete( + "/org/:orgId/ai-provider/:providerId/model/:modelId", + verifyApiKeyOrgAccess, + verifyApiKeyAiModelAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAiModel), + logActionAudit(ActionsEnum.deleteAiModel), + aiProvider.deleteAiModel +); diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 48868722e..fe33337d4 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -150,6 +150,22 @@ function getActionsCategories(root: boolean) { [t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys", [t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey", [t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey" + }, + + "AI Provider": { + [t("actionCreateAiProvider")]: "createAiProvider", + [t("actionDeleteAiProvider")]: "deleteAiProvider", + [t("actionGetAiProvider")]: "getAiProvider", + [t("actionListAiProviders")]: "listAiProviders", + [t("actionUpdateAiProvider")]: "updateAiProvider" + }, + + "AI Model": { + [t("actionCreateAiModel")]: "createAiModel", + [t("actionDeleteAiModel")]: "deleteAiModel", + [t("actionGetAiModel")]: "getAiModel", + [t("actionListAiModels")]: "listAiModels", + [t("actionUpdateAiModel")]: "updateAiModel" } };