356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
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();
|
||
}
|
||
}
|