356 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin";
import {
getAllAiProviders,
getAiProviderById,
createAiProvider,
updateAiProvider,
deleteAiProvider,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const providerTypes = [
"alibaba",
"openai",
"custom",
"local",
"gemini",
"bltcy-video",
"grsai-gpt-image",
"grsai-video",
"huanke-gemini",
"qianwen-video",
"doubao-video",
"ruxa-video",
"volcano-vision",
"volcano-video",
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"midjourney",
"fal-video",
"zhipu",
"tripo-3d",
"worldlabs-3d",
"kittycad-cad",
"runway",
"krea",
"volcano-multimodal",
] as const;
type ProviderType = (typeof providerTypes)[number];
type StoredProviderType = Exclude<
ProviderType,
"zhipu" | "volcano-multimodal" | "bltcy-video"
>;
const createProviderSchema = z.object({
name: z.string().min(1).max(128),
type: z.enum(providerTypes),
baseUrl: z.string().min(1), // 改为简单的字符串验证,支持 localhost
apiKey: z.string().min(1),
isEnabled: z.boolean().optional(),
});
const updateProviderSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(128).optional(),
type: z.enum(providerTypes).optional(),
baseUrl: z.string().min(1).optional(), // 改为简单的字符串验证,支持 localhost
apiKey: z.string().min(1).optional(),
isEnabled: z.boolean().optional(),
});
const ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
const VISIONARY_BASE_URL = "https://api.visionary.beer";
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai";
const GRSAI_GPT_IMAGE_BASE_URL = "https://grsai.dakka.com.cn";
const GRSAI_VIDEO_BASE_URL = "https://grsai.dakka.com.cn";
const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/;
function normalizeProviderTypeForStorage(
type?: ProviderType
): StoredProviderType | undefined {
if (!type) return undefined;
if (type === "zhipu" || type === "volcano-multimodal" || type === "bltcy-video") {
return "custom";
}
return type;
}
function normalizeRunwayApiKey(value: string) {
return String(value || "")
.replace(/\u00a0/g, " ")
.trim()
.normalize("NFKC")
.replace(/^bearer\s+/i, "")
.replace(/^key\s+/i, "")
.replace(/^(?:runway[_-]?api[_-]?key|api[_-]?key|token)\s*[::]\s*/i, "")
.replace(/[^\x20-\x7E]/g, "");
}
function validateRunwayApiKey(apiKey: string) {
if (!RUNWAY_API_KEY_PATTERN.test(apiKey)) {
throw new ChatSDKError(
"bad_request:api",
"Runway API Key 格式无效,应为 key_ 开头并跟随 128 位十六进制字符"
);
}
}
function isZhipuBaseUrl(baseUrl: string) {
return baseUrl.includes("open.bigmodel.cn");
}
function isVolcanoMultimodalBaseUrl(baseUrl: string) {
return /ark\.cn-beijing\.volces\.com/i.test(baseUrl);
}
function normalizeProviderTypeForResponse(provider: Record<string, any>) {
if (
provider.type === "custom" &&
typeof provider.baseUrl === "string" &&
isBltcyVideoBaseUrl(provider.baseUrl)
) {
return { ...provider, type: "bltcy-video" };
}
if (provider.type === "custom" && typeof provider.baseUrl === "string" && isZhipuBaseUrl(provider.baseUrl)) {
return { ...provider, type: "zhipu" };
}
if (
provider.type === "custom" &&
typeof provider.baseUrl === "string" &&
isVolcanoMultimodalBaseUrl(provider.baseUrl)
) {
return { ...provider, type: "volcano-multimodal" };
}
return provider;
}
function maskApiKey(apiKey?: string) {
if (!apiKey) return undefined;
if (apiKey.length <= 8) return "****";
return `${apiKey.slice(0, 4)}****${apiKey.slice(-4)}`;
}
function sanitizeProviderPayload(payload: unknown) {
if (!payload || typeof payload !== "object") return payload;
const cloned = { ...(payload as Record<string, unknown>) };
if (typeof cloned.apiKey === "string") {
cloned.apiKey = maskApiKey(cloned.apiKey);
}
return cloned;
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (id) {
const provider = await getAiProviderById({ id });
if (!provider) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
}
// 隐藏 API Key 的部分内容
return Response.json({
provider: normalizeProviderTypeForResponse({
...provider,
apiKey: provider.apiKey.slice(0, 8) + "****" + provider.apiKey.slice(-4),
}),
});
}
const providers = await getAllAiProviders();
// 隐藏 API Key 的部分内容
return Response.json({
providers: providers.map((p) =>
normalizeProviderTypeForResponse({
...p,
apiKey: p.apiKey.slice(0, 8) + "****" + p.apiKey.slice(-4),
})
),
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function POST(request: Request) {
try {
const session = await requireAdmin();
const body = await request.json();
console.log("[Admin API] Create provider request body:", JSON.stringify(sanitizeProviderPayload(body)));
const data = createProviderSchema.parse(body);
const normalizedApiKey =
data.type === "runway" ? normalizeRunwayApiKey(data.apiKey) : data.apiKey;
if (data.type === "runway") {
validateRunwayApiKey(normalizedApiKey);
}
console.log("[Admin API] Validation passed, creating provider...");
const storageType = normalizeProviderTypeForStorage(data.type) ?? "custom";
const provider = await createAiProvider({
...data,
apiKey: normalizedApiKey,
type: storageType,
baseUrl:
data.type === "zhipu"
? data.baseUrl || ZHIPU_BASE_URL
: data.type === "bltcy-video"
? data.baseUrl || BLTCY_VIDEO_BASE_URL
: data.type === "volcano-multimodal"
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
: data.type === "doubao-video"
? data.baseUrl || DOUBAO_VIDEO_BASE_URL
: data.type === "ruxa-video"
? data.baseUrl || RUXA_VIDEO_BASE_URL
: data.type === "bfl-image"
? data.baseUrl || BFL_IMAGE_BASE_URL
: data.type === "ruxa-image"
? data.baseUrl || RUXA_IMAGE_BASE_URL
: data.type === "snapgen-image"
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
: data.type === "openai-image"
? data.baseUrl || OPENAI_IMAGE_BASE_URL
: data.type === "bananaimage"
? data.baseUrl || BANANAIMAGE_BASE_URL
: data.type === "visionary"
? data.baseUrl || VISIONARY_BASE_URL
: data.type === "atlascloud"
? data.baseUrl || ATLASCLOUD_BASE_URL
: data.type === "stepfun-image"
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
: data.type === "midjourney"
? data.baseUrl || MIDJOURNEY_BASE_URL
: data.type === "tripo-3d"
? data.baseUrl || TRIPO_3D_BASE_URL
: data.type === "worldlabs-3d"
? data.baseUrl || WORLDLABS_3D_BASE_URL
: data.type === "kittycad-cad"
? data.baseUrl || KITTYCAD_CAD_BASE_URL
: data.type === "runway"
? data.baseUrl || RUNWAY_BASE_URL
: data.type === "krea"
? data.baseUrl || KREA_BASE_URL
: data.type === "grsai-gpt-image"
? data.baseUrl || GRSAI_GPT_IMAGE_BASE_URL
: data.type === "grsai-video"
? data.baseUrl || GRSAI_VIDEO_BASE_URL
: data.baseUrl,
updatedBy: session.user.id,
});
console.log("[Admin API] Provider created successfully:", provider[0].id);
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} catch (error) {
console.error("[Admin API] Create provider error:", error);
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
console.error("[Admin API] Validation error details:", error.errors);
return new ChatSDKError("bad_request:api", JSON.stringify(error.errors)).toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function PUT(request: Request) {
try {
const session = await requireAdmin();
const body = await request.json();
const data = updateProviderSchema.parse(body);
const existingProvider = await getAiProviderById({ id: data.id });
if (!existingProvider) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
}
const effectiveType = data.type || (existingProvider.type as ProviderType);
const normalizedApiKey =
effectiveType === "runway" && data.apiKey !== undefined
? normalizeRunwayApiKey(data.apiKey)
: data.apiKey;
if (effectiveType === "runway" && normalizedApiKey !== undefined) {
validateRunwayApiKey(normalizedApiKey);
}
const provider = await updateAiProvider({
...data,
apiKey: normalizedApiKey,
type: normalizeProviderTypeForStorage(data.type),
updatedBy: session.user.id,
});
if (provider.length === 0) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
}
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} 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 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", "Provider ID is required").toResponse();
}
await deleteAiProvider({ id });
clearScopedSiteSettingsCache();
return Response.json({ success: true });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
return new ChatSDKError("offline:admin").toResponse();
}
}