265 lines
9.3 KiB
TypeScript

import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin";
import {
getAllAiModels,
getAiModelById,
getAiModelsByProviderId,
createAiModel,
updateAiModel,
deleteAiModel,
getAiProviderById,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import {
getAiModelAgentCodingModelId,
isAiModelAgentAuto,
isAiModelMultimodal,
} from "@/lib/db/ai-model-flags";
import {
buildVideoDurationCostMap,
getConfiguredVideoDurationOptions,
normalizeVideoResolutionCostMap,
} from "@/lib/ai/video-model-config";
import { normalizeImageGenPricingConfig } from "@/lib/ai/image-model-pricing";
// 🆕 图片生成模型特性配置 Schema
const imageGenConfigSchema = z.object({
maxImages: z.number().min(1).max(10).optional(),
supportsSize: z.boolean().optional(),
supports1K: z.boolean().optional(),
supports2K: z.boolean().optional(),
supports4K: z.boolean().optional(),
supportsPromptExtend: z.boolean().optional(),
supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
supportsQuality: z.boolean().optional(),
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
maxReferenceImages: z.number().min(0).max(64).optional(),
maxVideoReferences: z.number().min(0).max(16).optional(),
ruxaTaskInputMode: z
.enum(["size-image", "resolution-based"])
.optional(),
creditCostPerImage: z.number().min(0).max(100000).optional(),
creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(),
creditDiscountRate: z.number().min(0.1).max(10).optional(),
creditCostPerVideo: z.number().min(0).max(100000).optional(),
videoDurationOptions: z.array(z.number().min(1).max(600)).max(20).optional(),
creditCostPerVideoByDuration: z.record(z.string(), z.number().min(0).max(100000)).optional(),
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(),
vip98k: z.record(z.string(), z.unknown()).optional(),
}).optional();
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) {
if (!config) return config;
const videoDurationOptions = getConfiguredVideoDurationOptions(config);
const creditCostPerVideoByDuration = buildVideoDurationCostMap({
durationOptions: videoDurationOptions,
costMap: config.creditCostPerVideoByDuration,
fallbackCost: config.creditCostPerVideo,
});
return normalizeImageGenPricingConfig({
...config,
videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined,
creditCostPerVideoByDuration,
creditCostPerVideoByResolution: normalizeVideoResolutionCostMap(
config.creditCostPerVideoByResolution
),
});
}
const createModelSchema = z.object({
providerId: z.string().uuid(),
modelId: z.string().min(1).max(128),
displayName: z.string().min(1).max(128),
avatarUrl: z.string().url().max(1000).optional().or(z.literal("")),
description: z.string().optional(),
supportsVision: z.boolean().optional(),
supportsImageGen: z.boolean().optional(),
isEnabled: z.boolean().optional(),
isHidden: z.boolean().optional(),
isDefault: z.boolean().optional(),
isDefaultTextEditModel: z.boolean().optional(),
sortOrder: z.number().optional(),
fallbackModelId1: z.string().max(128).optional(),
fallbackModelId2: z.string().max(128).optional(),
excludeFromFailover: z.boolean().optional(),
excludeFromHealthBan: z.boolean().optional(),
isAgentModel: z.boolean().optional(),
agentCodingModelId: z.string().max(128).optional(),
isMultimodal: z.boolean().optional(),
capabilities: capabilitiesSchema,
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
});
const updateModelSchema = z.object({
id: z.string().uuid(),
providerId: z.string().uuid().optional(),
modelId: z.string().min(1).max(128).optional(),
displayName: z.string().min(1).max(128).optional(),
avatarUrl: z.string().url().max(1000).optional().or(z.literal("")),
description: z.string().optional(),
supportsVision: z.boolean().optional(),
supportsImageGen: z.boolean().optional(),
isEnabled: z.boolean().optional(),
isHidden: z.boolean().optional(),
isDefault: z.boolean().optional(),
isDefaultTextEditModel: z.boolean().optional(),
sortOrder: z.number().optional(),
fallbackModelId1: z.string().max(128).optional(),
fallbackModelId2: z.string().max(128).optional(),
excludeFromFailover: z.boolean().optional(),
excludeFromHealthBan: z.boolean().optional(),
isAgentModel: z.boolean().optional(),
agentCodingModelId: z.string().max(128).optional(),
isMultimodal: z.boolean().optional(),
capabilities: capabilitiesSchema,
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
});
function normalizeModel(model: any) {
return {
...model,
isAgentModel: isAiModelAgentAuto(model),
agentCodingModelId: getAiModelAgentCodingModelId(model) || "",
isMultimodal: isAiModelMultimodal(model),
};
}
function sortModelsForDisplay<T extends { sortOrder?: number | null; displayName?: string | null }>(
models: T[]
) {
return [...models].sort((a, b) => {
const leftOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : 0;
const rightOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : 0;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
const leftName = String(a.displayName || "");
const rightName = String(b.displayName || "");
return leftName.localeCompare(rightName, "zh-Hans-CN");
});
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const providerId = searchParams.get("providerId");
if (id) {
const model = await getAiModelById({ id });
if (!model) {
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
}
return Response.json({ model: normalizeModel(model) });
}
if (providerId) {
const models = await getAiModelsByProviderId({ providerId });
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
}
const models = await getAllAiModels();
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const data = createModelSchema.parse(body);
// 验证 provider 是否存在
const provider = await getAiProviderById({ id: data.providerId });
if (!provider) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
}
const model = await createAiModel({
...data,
imageGenConfig: normalizeImageGenConfig(data.imageGenConfig),
});
return Response.json({ model: normalizeModel(model[0]) });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function PUT(request: Request) {
try {
await requireAdmin();
const body = await request.json();
console.log("[Admin API] Update model request body:", JSON.stringify(body)); // Debug log
const data = updateModelSchema.parse(body);
console.log("[Admin API] Validation passed, updating model..."); // Debug log
const model = await updateAiModel({
...data,
imageGenConfig: normalizeImageGenConfig(data.imageGenConfig),
});
if (model.length === 0) {
console.error("[Admin API] Model not found:", data.id); // Debug log
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
}
console.log("[Admin API] Model updated successfully:", model[0].id); // Debug log
return Response.json({ model: normalizeModel(model[0]) });
} catch (error) {
console.error("[Admin API] Update model error:", error); // Debug log
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
console.error("[Admin API] Validation error details:", error.errors); // Debug log
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function DELETE(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError("bad_request:api", "Model ID is required").toResponse();
}
await deleteAiModel({ id });
return Response.json({ success: true });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}