add targets and refactor endpoints

This commit is contained in:
miloschwartz
2026-07-31 17:36:04 -04:00
parent 573747c237
commit 28430dde74
31 changed files with 741 additions and 490 deletions
+3 -9
View File
@@ -15,7 +15,6 @@ import {
} from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
providerId: z.coerce.number().int().positive()
});
@@ -33,7 +32,7 @@ const bodySchema = z
registry.registerPath({
method: "put",
path: "/org/{orgId}/ai-provider/{providerId}/model",
path: "/ai-provider/{providerId}/model",
description: "Create an AI model under a provider.",
tags: [OpenAPITags.AiModel],
request: {
@@ -79,7 +78,7 @@ export async function createAiModel(
);
}
const { orgId, providerId } = parsedParams.data;
const { providerId } = parsedParams.data;
const { modelKey, name, budgetAmount, budgetUnit, enabled } =
parsedBody.data;
@@ -89,12 +88,7 @@ export async function createAiModel(
: await db
.select()
.from(aiProviders)
.where(
and(
eq(aiProviders.providerId, providerId),
eq(aiProviders.orgId, orgId)
)
)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
+10 -1
View File
@@ -15,6 +15,7 @@ import {
aiAuthTypeSchema,
aiBudgetUnitSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineBudgetFields,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
@@ -30,6 +31,7 @@ const bodySchema = z
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
@@ -95,6 +97,7 @@ export async function createAiProvider(
upstreamUrl,
apiKey,
authType,
routingMode,
skipTlsVerification,
budgetAmount,
budgetUnit,
@@ -105,6 +108,8 @@ export async function createAiProvider(
const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null;
const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null;
const now = Date.now();
const resolvedRoutingMode =
type === "custom" ? (routingMode ?? "url") : "url";
const [provider] = await db
.insert(aiProviders)
@@ -112,10 +117,14 @@ export async function createAiProvider(
orgId,
name,
type,
upstreamUrl: upstreamUrl ?? null,
upstreamUrl:
resolvedRoutingMode === "target"
? null
: (upstreamUrl ?? null),
apiKey: encryptedApiKey,
apiKeyLastChars,
authType: authType ?? null,
routingMode: resolvedRoutingMode,
skipTlsVerification: skipTlsVerification ?? false,
budgetAmount: budgetAmount ?? null,
budgetUnit: budgetUnit ?? null,
+6 -25
View File
@@ -1,23 +1,21 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, db } from "@server/db";
import { aiModels, 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 { 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}",
path: "/ai-model/{modelId}",
description: "Delete an AI model.",
tags: [OpenAPITags.AiModel],
request: {
@@ -46,22 +44,12 @@ export async function deleteAiModel(
);
}
const { orgId, providerId, modelId } = parsedParams.data;
const { 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)
)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
@@ -73,14 +61,7 @@ export async function deleteAiModel(
);
}
await db
.delete(aiModels)
.where(
and(
eq(aiModels.modelId, modelId),
eq(aiModels.providerId, providerId)
)
);
await db.delete(aiModels).where(eq(aiModels.modelId, modelId));
return response(res, {
data: null,
+5 -16
View File
@@ -7,16 +7,15 @@ 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 { 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}",
path: "/ai-provider/{providerId}",
description: "Delete an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
@@ -45,17 +44,12 @@ export async function deleteAiProvider(
);
}
const { orgId, providerId } = parsedParams.data;
const { providerId } = parsedParams.data;
const [existing] = await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(
and(
eq(aiProviders.providerId, providerId),
eq(aiProviders.orgId, orgId)
)
)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
@@ -69,12 +63,7 @@ export async function deleteAiProvider(
await db
.delete(aiProviders)
.where(
and(
eq(aiProviders.providerId, providerId),
eq(aiProviders.orgId, orgId)
)
);
.where(eq(aiProviders.providerId, providerId));
return response(res, {
data: null,
+7 -20
View File
@@ -1,24 +1,22 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, db } from "@server/db";
import { aiModels, 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 { 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}",
path: "/ai-model/{modelId}",
description: "Get an AI model by ID.",
tags: [OpenAPITags.AiModel],
request: {
@@ -47,27 +45,16 @@ export async function getAiModel(
);
}
const { orgId, providerId, modelId } = parsedParams.data;
const { modelId } = parsedParams.data;
const [model] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select({ model: aiModels })
.select()
.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));
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!model) {
return next(
+4 -10
View File
@@ -7,18 +7,17 @@ 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 { 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}",
path: "/ai-provider/{providerId}",
description: "Get an AI provider by ID.",
tags: [OpenAPITags.AiProvider],
request: {
@@ -47,7 +46,7 @@ export async function getAiProvider(
);
}
const { orgId, providerId } = parsedParams.data;
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
@@ -55,12 +54,7 @@ export async function getAiProvider(
: await db
.select()
.from(aiProviders)
.where(
and(
eq(aiProviders.providerId, providerId),
eq(aiProviders.orgId, orgId)
)
)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
+3 -9
View File
@@ -11,7 +11,6 @@ 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()
});
@@ -45,7 +44,7 @@ const listSchema = z.object({
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-provider/{providerId}/models",
path: "/ai-provider/{providerId}/models",
description: "List AI models for a provider.",
tags: [OpenAPITags.AiModel],
request: {
@@ -85,7 +84,7 @@ export async function listAiModels(
);
}
const { orgId, providerId } = parsedParams.data;
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
@@ -93,12 +92,7 @@ export async function listAiModels(
: await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(
and(
eq(aiProviders.providerId, providerId),
eq(aiProviders.orgId, orgId)
)
)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
+3 -1
View File
@@ -3,6 +3,7 @@ import type { PaginatedResponse } from "@server/types/Pagination";
import {
resolveAiProviderConfig,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
@@ -40,7 +41,8 @@ export function toPublicAiProvider(provider: AiProvider): AiProviderPublic {
const resolved = resolveAiProviderConfig({
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl,
authType: provider.authType as AiProviderAuthType | null
authType: provider.authType as AiProviderAuthType | null,
routingMode: provider.routingMode as AiProviderRoutingMode | null
});
return {
+8 -26
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, db } from "@server/db";
import { aiModels, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -15,8 +15,6 @@ import {
} 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()
});
@@ -34,7 +32,7 @@ const bodySchema = z
registry.registerPath({
method: "post",
path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}",
path: "/ai-model/{modelId}",
description: "Update an AI model.",
tags: [OpenAPITags.AiModel],
request: {
@@ -80,28 +78,17 @@ export async function updateAiModel(
);
}
const { orgId, providerId, modelId } = parsedParams.data;
const { modelId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select({ model: aiModels })
.select()
.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));
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
return next(
@@ -121,7 +108,7 @@ export async function updateAiModel(
.from(aiModels)
.where(
and(
eq(aiModels.providerId, providerId),
eq(aiModels.providerId, existing.providerId),
eq(aiModels.modelKey, body.modelKey),
ne(aiModels.modelId, modelId)
)
@@ -161,12 +148,7 @@ export async function updateAiModel(
const [model] = await db
.update(aiModels)
.set(updateData)
.where(
and(
eq(aiModels.modelId, modelId),
eq(aiModels.providerId, providerId)
)
)
.where(eq(aiModels.modelId, modelId))
.returning();
return response<CreateOrEditAiModelResponse>(res, {
+41 -48
View File
@@ -7,7 +7,7 @@ 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 { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
@@ -16,16 +16,16 @@ import {
aiAuthTypeSchema,
aiBudgetUnitSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineBudgetFields,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import {
providerRequiresUpstreamUrl,
type AiProviderType
import type {
AiProviderRoutingMode,
AiProviderType
} from "@server/lib/aiProviderDefaults";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
providerId: z.coerce.number().int().positive()
});
@@ -35,6 +35,7 @@ const bodySchema = z
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional().nullable(),
routingMode: aiRoutingModeSchema.optional(),
skipTlsVerification: z.boolean().optional(),
budgetAmount: z.number().positive().optional().nullable(),
budgetUnit: aiBudgetUnitSchema.optional().nullable(),
@@ -46,7 +47,7 @@ const bodySchema = z
registry.registerPath({
method: "post",
path: "/org/{orgId}/ai-provider/{providerId}",
path: "/ai-provider/{providerId}",
description: "Update an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
@@ -92,7 +93,7 @@ export async function updateAiProvider(
);
}
const { orgId, providerId } = parsedParams.data;
const { providerId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
@@ -101,12 +102,7 @@ export async function updateAiProvider(
: await db
.select()
.from(aiProviders)
.where(
and(
eq(aiProviders.providerId, providerId),
eq(aiProviders.orgId, orgId)
)
)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
@@ -119,6 +115,11 @@ export async function updateAiProvider(
}
const providerType = existing.type as AiProviderType;
const nextRoutingMode: AiProviderRoutingMode =
providerType === "custom"
? ((body.routingMode ??
existing.routingMode) as AiProviderRoutingMode)
: "url";
const nextUpstreamUrl =
body.upstreamUrl !== undefined
? body.upstreamUrl
@@ -126,38 +127,33 @@ export async function updateAiProvider(
const nextAuthType =
body.authType !== undefined ? body.authType : existing.authType;
if (
providerRequiresUpstreamUrl(providerType) ||
body.upstreamUrl !== undefined ||
body.authType !== undefined
) {
const validation = z
.object({
type: aiProviderTypeSchema,
upstreamUrl: z.string().nullable().optional(),
authType: aiAuthTypeSchema.nullable().optional()
})
.superRefine((data, ctx) =>
refineProviderUpstreamFields(data, ctx)
)
.safeParse({
type: providerType,
upstreamUrl: nextUpstreamUrl,
authType: nextAuthType
});
const validation = z
.object({
type: aiProviderTypeSchema,
upstreamUrl: z.string().nullable().optional(),
authType: aiAuthTypeSchema.nullable().optional(),
routingMode: aiRoutingModeSchema.optional()
})
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
.safeParse({
type: providerType,
upstreamUrl: nextUpstreamUrl,
authType: nextAuthType,
routingMode: nextRoutingMode
});
if (!validation.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(validation.error).toString()
)
);
}
if (!validation.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(validation.error).toString()
)
);
}
const updateData: Partial<typeof aiProviders.$inferInsert> = {
updatedAt: Date.now()
updatedAt: Date.now(),
routingMode: nextRoutingMode
};
if (body.name !== undefined) {
@@ -175,7 +171,9 @@ export async function updateAiProvider(
if (body.budgetUnit !== undefined) {
updateData.budgetUnit = body.budgetUnit;
}
if (body.upstreamUrl !== undefined) {
if (nextRoutingMode === "target") {
updateData.upstreamUrl = null;
} else if (body.upstreamUrl !== undefined) {
updateData.upstreamUrl = body.upstreamUrl;
}
if (body.authType !== undefined) {
@@ -191,12 +189,7 @@ export async function updateAiProvider(
const [provider] = await db
.update(aiProviders)
.set(updateData)
.where(
and(
eq(aiProviders.providerId, providerId),
eq(aiProviders.orgId, orgId)
)
)
.where(eq(aiProviders.providerId, providerId))
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
+19 -2
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import {
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
@@ -21,6 +22,8 @@ export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]);
export const aiAuthTypeSchema = z.enum(["bearer"]);
export const aiRoutingModeSchema = z.enum(["url", "target"]);
export function refineBudgetFields(
data: {
budgetAmount?: number | null;
@@ -47,10 +50,24 @@ export function refineProviderUpstreamFields(
type: AiProviderType;
upstreamUrl?: string | null;
authType?: "bearer" | null;
routingMode?: AiProviderRoutingMode | null;
},
ctx: z.RefinementCtx
) {
if (providerRequiresUpstreamUrl(data.type) && !data.upstreamUrl) {
const routingMode = data.routingMode ?? "url";
if (data.type !== "custom" && routingMode === "target") {
ctx.addIssue({
code: "custom",
message: "routingMode target is only allowed for custom providers",
path: ["routingMode"]
});
}
if (
providerRequiresUpstreamUrl(data.type, routingMode) &&
!data.upstreamUrl
) {
ctx.addIssue({
code: "custom",
message: `upstreamUrl is required for ${data.type} providers`,
@@ -58,7 +75,7 @@ export function refineProviderUpstreamFields(
});
}
if (data.type === "custom" && !data.authType) {
if (data.type === "custom" && routingMode === "url" && !data.authType) {
ctx.addIssue({
code: "custom",
message: "authType is required for custom providers",