生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。 验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { z } from "zod";
|
|
import { db } from "@/lib/db";
|
|
import { aiModel, aiProvider } from "@/lib/db/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { requireAdmin } from "@/lib/middleware/admin";
|
|
import {
|
|
getAiModelById,
|
|
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 (searchParams.get("view") === "options") {
|
|
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
|
|
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
|
|
return Response.json({ models: sortModelsForDisplay(models) });
|
|
}
|
|
|
|
if (id) {
|
|
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
|
|
if (!model) {
|
|
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
|
}
|
|
return Response.json({ model: normalizeModel(model) });
|
|
}
|
|
|
|
if (providerId) {
|
|
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
|
|
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
|
}
|
|
|
|
const models = await db.select().from(aiModel);
|
|
const normalized = models.map(normalizeModel);
|
|
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
|
|
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
|
|
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
|
|
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
|
|
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
|
|
}) : normalized;
|
|
return Response.json({ models: sortModelsForDisplay(output) });
|
|
} 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();
|
|
}
|
|
}
|