import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import dotenv from "dotenv"; import postgres from "postgres"; import { buildVip98kModelPresets, fetchVip98kModelPresets, type Vip98kCatalogModel, type Vip98kModelPreset, } from "../lib/ai/vip98k-models"; dotenv.config({ path: ".env.local" }); dotenv.config(); const KEEP_PROVIDER_TYPES = new Set([ "vip98k", "fal-image", "fal-video", "tripo-3d", ]); const REQUIRED_CAPABILITY_MODELS = [ "nano-banana-2", "remove-background", "enhance", "image-expansion", "world-generation", "tencent/hunyuan3d-pro/text-to-3d", "tencent/hunyuan3d-pro/image-to-3d", ] as const; type ProviderRow = { id: string; name: string; type: string; baseUrl: string; apiKey: string; isEnabled: boolean; }; type ModelRow = { id: string; providerId: string; modelId: string; displayName: string; supportsVision: boolean; supportsImageGen: boolean; capabilities: unknown; imageGenConfig: unknown; }; function hasFlag(name: string) { return process.argv.includes(name); } function getArgValue(name: string) { const prefix = `${name}=`; return process.argv.find((item) => item.startsWith(prefix))?.slice(prefix.length) || ""; } function stableHash(value: unknown) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } function sortById(rows: T[]) { return [...rows].sort((left, right) => left.id.localeCompare(right.id)); } function isExplicitlyUnavailable(model: Vip98kCatalogModel) { return ["disabled", "unavailable", "offline", "removed"].includes( String(model.status || "").trim().toLowerCase(), ); } async function loadPresets(apiKey: string): Promise { const catalogPath = getArgValue("--catalog") || process.env.VIP98K_CATALOG_FILE || ""; if (catalogPath) { const payload = JSON.parse(await readFile(catalogPath, "utf8")) as { data?: { models?: Vip98kCatalogModel[] } | Vip98kCatalogModel[]; models?: Vip98kCatalogModel[]; }; const dataModels = Array.isArray(payload.data) ? payload.data : payload.data?.models; const models = (dataModels || payload.models || []).filter( (model) => !isExplicitlyUnavailable(model), ); if (models.length === 0) throw new Error("VIP98K catalog file contains no models"); return buildVip98kModelPresets(models); } return fetchVip98kModelPresets({ baseURL: process.env.VIP98K_BASE_URL || "https://api.vip98k.com", apiKey, }); } function classifyOldModel(model: ModelRow, providerType: string) { const serialized = JSON.stringify({ capabilities: model.capabilities, imageGenConfig: model.imageGenConfig, }).toLowerCase(); if (providerType.includes("video") || serialized.includes("video")) return "video"; if ( providerType.includes("3d") || serialized.includes("model-3d") || serialized.includes("text-to-3d") || serialized.includes("image-to-3d") ) { return "model-3d"; } if (providerType.includes("world") || serialized.includes("world-generation")) return "world"; if (model.supportsImageGen || providerType.includes("image")) return "image"; return "chat"; } function chooseTargetModelId(kind: string, presets: Vip98kModelPreset[]) { const preferred: Record = { chat: ["gpt-5.4-mini", "gpt-5.4", "claude-sonnet-4-6"], image: ["nano-banana-2", "nano-banana-pro", "gpt-image-2-vip"], video: ["google/veo-3.1-fast", "google/veo-3.1", "veo-3.1-fast"], world: ["world-generation"], "model-3d": [ "tencent/hunyuan3d-pro/image-to-3d", "tencent/hunyuan3d-pro/text-to-3d", ], }; const candidates = preferred[kind] || preferred.chat; const selected = candidates.find((modelId) => presets.some((preset) => preset.modelId === modelId)); if (selected) return selected; const fallbackType = kind === "model-3d" ? "model_3d" : kind === "chat" ? "model" : kind; return presets.find((preset) => preset.type === fallbackType)?.modelId || ""; } function rewriteSettingValue(params: { value: unknown; oldProviders: Map; oldModels: Map; targetProviderId: string; targetModelByKind: Record; key?: string; }): unknown { const { value, oldProviders, oldModels, targetProviderId, targetModelByKind } = params; if (Array.isArray(value)) { return value.map((item) => rewriteSettingValue({ ...params, value: item })); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record).map(([key, nested]) => [ key, rewriteSettingValue({ ...params, value: nested, key }), ]), ); } if (typeof value !== "string") return value; const oldProvider = oldProviders.get(value); if (oldProvider) { if (/type/i.test(params.key || "")) return "vip98k"; if (/name/i.test(params.key || "")) return "VIP98K"; return targetProviderId; } const oldModel = oldModels.get(value); if (oldModel) { const provider = oldProviders.get(oldModel.providerId); const kind = classifyOldModel(oldModel, provider?.type || ""); // A value found in oldModels is a database UUID, regardless of the JSON key name. return targetModelByKind[kind]?.id || targetModelByKind.chat.id; } if (/providerType/i.test(params.key || "")) { const oldTypes = new Set(Array.from(oldProviders.values()).map((provider) => provider.type)); if (oldTypes.has(value)) return "vip98k"; } for (const [kind, target] of Object.entries(targetModelByKind)) { const oldRuntimeModel = Array.from(oldModels.values()).find((model) => { const provider = oldProviders.get(model.providerId); return ( model.modelId === value && classifyOldModel(model, provider?.type || "") === kind ); }); if (oldRuntimeModel) return target.modelId; } return value; } function summarizePresets(presets: Vip98kModelPreset[]) { return presets.reduce>((summary, preset) => { const type = String(preset.type || "model"); summary[type] = (summary[type] || 0) + 1; return summary; }, {}); } async function main() { const apply = hasFlag("--apply"); const stage = hasFlag("--stage"); if (apply && stage) throw new Error("Use either --stage or --apply, not both"); const databaseUrl = process.env.POSTGRES_URL || ""; const apiKey = process.env.VIP98K_API_KEY || ""; if (!databaseUrl) throw new Error("POSTGRES_URL is required"); if (!apiKey) throw new Error("VIP98K_API_KEY is required and must not be passed on the command line"); const presets = await loadPresets(apiKey); const modelIds = new Set(presets.map((preset) => preset.modelId)); const missingRequired = REQUIRED_CAPABILITY_MODELS.filter((modelId) => !modelIds.has(modelId)); const hasVideo = presets.some((preset) => preset.type === "video"); const hasChat = presets.some((preset) => preset.type === "model"); if (missingRequired.length > 0 || !hasVideo || !hasChat) { throw new Error( `VIP98K capability coverage incomplete: ${[ ...missingRequired, ...(!hasVideo ? ["video"] : []), ...(!hasChat ? ["multimodal/chat"] : []), ].join(", ")}`, ); } const sql = postgres(databaseUrl, { prepare: false, max: 1 }); try { const providers = await sql`select * from "AiProvider" order by "createdAt"`; const models = await sql`select * from "AiModel" order by "createdAt"`; const falProviders = providers.filter((provider) => ["fal-image", "fal-video"].includes(provider.type), ); const falProviderIds = new Set(falProviders.map((provider) => provider.id)); const falFingerprintBefore = stableHash({ providers: sortById(falProviders), models: sortById(models.filter((model) => falProviderIds.has(model.providerId))), }); const removableProviders = providers.filter( (provider) => !KEEP_PROVIDER_TYPES.has(provider.type), ); console.log( JSON.stringify( { mode: apply ? "apply" : stage ? "stage" : "dry-run", vip98kModels: presets.length, vip98kByType: summarizePresets(presets), falProvidersProtected: falProviders.length, falModelsProtected: models.filter((model) => falProviderIds.has(model.providerId)).length, providersToRemove: removableProviders.length, modelsToRemove: models.filter((model) => removableProviders.some((provider) => provider.id === model.providerId), ).length, }, null, 2, ), ); if (!apply && !stage) { console.log( "Dry run complete. Re-run with --stage to add VIP98K without deleting existing providers.", ); return; } await sql.begin(async (tx) => { const existingVipProviders = providers.filter((provider) => provider.type === "vip98k"); let targetProviderId = existingVipProviders[0]?.id || ""; if (targetProviderId) { await tx` update "AiProvider" set name = 'VIP98K', "baseUrl" = 'https://api.vip98k.com', "apiKey" = ${apiKey}, "isEnabled" = true, "updatedAt" = now() where id = ${targetProviderId}::uuid `; } else { const [created] = await tx<{ id: string }[]>` insert into "AiProvider" (name, type, "baseUrl", "apiKey", "isEnabled") values ('VIP98K', 'vip98k', 'https://api.vip98k.com', ${apiKey}, true) returning id `; targetProviderId = created?.id || ""; } if (!targetProviderId) throw new Error("Failed to create VIP98K provider"); const modelDbIds = new Map(); for (const preset of presets) { const existing = await tx<{ id: string }[]>` select id from "AiModel" where "providerId" = ${targetProviderId}::uuid and "modelId" = ${preset.modelId} order by "createdAt" limit 1 `; const values = { description: preset.description, capabilities: JSON.stringify(preset.capabilities), defaultParams: JSON.stringify(preset.defaultParams), imageGenConfig: preset.imageGenConfig ? JSON.stringify(preset.imageGenConfig) : null, }; if (existing[0]) { await tx` update "AiModel" set "displayName" = ${preset.displayName}, description = ${values.description}, "supportsVision" = ${preset.supportsVision}, "supportsImageGen" = ${preset.supportsImageGen}, "isEnabled" = true, "isHidden" = ${preset.isHidden}, "sortOrder" = ${preset.sortOrder}, capabilities = ${values.capabilities}::jsonb, "defaultParams" = ${values.defaultParams}::jsonb, "imageGenConfig" = ${values.imageGenConfig}::jsonb, "updatedAt" = now() where id = ${existing[0].id}::uuid `; modelDbIds.set(preset.modelId, existing[0].id); } else { const [created] = await tx<{ id: string }[]>` insert into "AiModel" ( "providerId", "modelId", "displayName", description, "supportsVision", "supportsImageGen", "isEnabled", "isHidden", "sortOrder", capabilities, "defaultParams", "imageGenConfig", "isDefault", "isDefaultTextEditModel" ) values ( ${targetProviderId}::uuid, ${preset.modelId}, ${preset.displayName}, ${values.description}, ${preset.supportsVision}, ${preset.supportsImageGen}, true, ${preset.isHidden}, ${preset.sortOrder}, ${values.capabilities}::jsonb, ${values.defaultParams}::jsonb, ${values.imageGenConfig}::jsonb, false, false ) returning id `; if (!created) throw new Error(`Failed to create VIP98K model ${preset.modelId}`); modelDbIds.set(preset.modelId, created.id); } } const targetModelByKind = Object.fromEntries( ["chat", "image", "video", "world", "model-3d"].map((kind) => { const modelId = chooseTargetModelId(kind, presets); const id = modelDbIds.get(modelId) || ""; if (!id) throw new Error(`No VIP98K target model for ${kind}`); return [kind, { id, modelId }]; }), ) as Record; if (stage) return; await tx` update "AiModel" set "isDefault" = false, "isDefaultTextEditModel" = false where "providerId" = ${targetProviderId}::uuid `; await tx` update "AiModel" set "isDefault" = true where id = ${targetModelByKind.chat.id}::uuid `; await tx` update "AiModel" set "isDefaultTextEditModel" = true, "fallbackModelId1" = 'nano-banana-pro', "fallbackModelId2" = 'gpt-image-2-vip' where id = ${targetModelByKind.image.id}::uuid `; const duplicateVipProviders = existingVipProviders.slice(1); const replaceableProviders = [...removableProviders, ...duplicateVipProviders]; const oldProviderMap = new Map( replaceableProviders.map((provider) => [provider.id, provider]), ); const oldModels = models.filter((model) => oldProviderMap.has(model.providerId)); const oldModelMap = new Map(oldModels.map((model) => [model.id, model])); if (oldModelMap.size > 0) { for (const presetType of ["image", "video", "text"] as const) { const target = presetType === "video" ? targetModelByKind.video : presetType === "image" ? targetModelByKind.image : targetModelByKind.chat; await tx` update "AgentPreset" set "defaultModelId" = ${target.id}::uuid, "updatedAt" = now() where type = ${presetType} and "defaultModelId" = any(${Array.from(oldModelMap.keys())}::uuid[]) `; } } const settings = await tx<{ id: string; key: string; value: unknown }[]>` select id, key, value from "SystemSettings" `; for (const setting of settings) { let nextValue = rewriteSettingValue({ value: setting.value, oldProviders: oldProviderMap, oldModels: oldModelMap, targetProviderId, targetModelByKind, }); if (setting.key === "rmbg_api_config") nextValue = { provider: "vip98k" }; if (setting.key === "enhance_api_config") { nextValue = { provider: "vip98k", type: "clean", scaleFactor: 4 }; } if (setting.key === "extend_api_config") { nextValue = { provider: "vip98k", xScale: 1.5, yScale: 1.5 }; } if (setting.key === "worldlabs_key_pool_config") { nextValue = { baseUrl: "https://api.vip98k.com", providers: [] }; } if (JSON.stringify(nextValue) !== JSON.stringify(setting.value)) { await tx` update "SystemSettings" set value = ${JSON.stringify(nextValue)}::jsonb, "updatedAt" = now() where id = ${setting.id}::uuid `; } } const removableProviderIds = removableProviders.map((provider) => provider.id); const duplicateVipProviderIds = duplicateVipProviders.map((provider) => provider.id); const deleteIds = [...removableProviderIds, ...duplicateVipProviderIds]; if (deleteIds.length > 0) { await tx`delete from "AiProvider" where id = any(${deleteIds}::uuid[])`; } }); const remainingProviders = await sql`select * from "AiProvider" order by "createdAt"`; const remainingModels = await sql`select * from "AiModel" order by "createdAt"`; const remainingFalProviders = remainingProviders.filter((provider) => ["fal-image", "fal-video"].includes(provider.type), ); const remainingFalIds = new Set(remainingFalProviders.map((provider) => provider.id)); const falFingerprintAfter = stableHash({ providers: sortById(remainingFalProviders), models: sortById( remainingModels.filter((model) => remainingFalIds.has(model.providerId)), ), }); if (falFingerprintAfter !== falFingerprintBefore) { throw new Error("FAL provider/model fingerprint changed; restore the verified database backup"); } const invalidTypes = remainingProviders.filter( (provider) => !KEEP_PROVIDER_TYPES.has(provider.type), ); if (apply && invalidTypes.length > 0) { throw new Error(`Unexpected provider types remain: ${invalidTypes.map((item) => item.type).join(", ")}`); } console.log( JSON.stringify( { success: true, mode: apply ? "apply" : "stage", providerTypes: Array.from(new Set(remainingProviders.map((provider) => provider.type))).sort(), vip98kModels: remainingModels.filter((model) => remainingProviders.some( (provider) => provider.id === model.providerId && provider.type === "vip98k", ), ).length, falFingerprintUnchanged: true, }, null, 2, ), ); } finally { await sql.end(); } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });