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 { VIP98K_BASE_URL } from "@/lib/ai/vip98k-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", "vip98k", "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 VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL; 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) { 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) }; 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 === "vip98k" ? data.baseUrl || VIP98K_PROVIDER_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), baseUrl: data.baseUrl !== undefined ? data.baseUrl : data.type === "vip98k" ? VIP98K_PROVIDER_BASE_URL : undefined, 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(); } }