502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
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",
|
|
"kittycad-cad",
|
|
]);
|
|
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;
|
|
const PROTECTED_SYSTEM_SETTING_KEYS = new Set([
|
|
"realtime_canvas_config",
|
|
]);
|
|
|
|
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;
|
|
isHidden: 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 toJsonValue(value: unknown): postgres.JSONValue {
|
|
return JSON.parse(JSON.stringify(value)) as postgres.JSONValue;
|
|
}
|
|
|
|
function sortById<T extends { id: string }>(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<Vip98kModelPreset[]> {
|
|
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<string, string[]> = {
|
|
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<string, ProviderRow>;
|
|
oldModels: Map<string, ModelRow>;
|
|
targetProviderId: string;
|
|
targetModelByKind: Record<string, { id: string; modelId: string }>;
|
|
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<string, unknown>).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<Record<string, number>>((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<ProviderRow[]>`select * from "AiProvider" order by "createdAt"`;
|
|
const models = await sql<ModelRow[]>`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 protectedSettingsBefore = await sql<{ key: string; value: unknown }[]>`
|
|
select key, value from "SystemSettings"
|
|
where key = any(${Array.from(PROTECTED_SYSTEM_SETTING_KEYS)}::text[])
|
|
order by key
|
|
`;
|
|
const protectedSettingsFingerprintBefore = stableHash(protectedSettingsBefore);
|
|
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<string, string>();
|
|
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 capabilitiesJson = tx.json(toJsonValue(preset.capabilities));
|
|
const defaultParamsJson = tx.json(toJsonValue(preset.defaultParams));
|
|
const imageGenConfigJson = preset.imageGenConfig
|
|
? tx.json(toJsonValue(preset.imageGenConfig))
|
|
: null;
|
|
if (existing[0]) {
|
|
await tx`
|
|
update "AiModel" set
|
|
"displayName" = ${preset.displayName}, description = ${preset.description},
|
|
"supportsVision" = ${preset.supportsVision}, "supportsImageGen" = ${preset.supportsImageGen},
|
|
"isEnabled" = true, "isHidden" = ${preset.isHidden}, "sortOrder" = ${preset.sortOrder},
|
|
capabilities = ${capabilitiesJson}, "defaultParams" = ${defaultParamsJson},
|
|
"imageGenConfig" = ${imageGenConfigJson}, "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}, ${preset.description},
|
|
${preset.supportsVision}, ${preset.supportsImageGen}, true, ${preset.isHidden},
|
|
${preset.sortOrder}, ${capabilitiesJson}, ${defaultParamsJson},
|
|
${imageGenConfigJson}, 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<string, { id: string; modelId: string }>;
|
|
|
|
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) {
|
|
if (PROTECTED_SYSTEM_SETTING_KEYS.has(setting.key)) continue;
|
|
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<ProviderRow[]>`select * from "AiProvider" order by "createdAt"`;
|
|
const remainingModels = await sql<ModelRow[]>`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 protectedSettingsAfter = await sql<{ key: string; value: unknown }[]>`
|
|
select key, value from "SystemSettings"
|
|
where key = any(${Array.from(PROTECTED_SYSTEM_SETTING_KEYS)}::text[])
|
|
order by key
|
|
`;
|
|
if (stableHash(protectedSettingsAfter) !== protectedSettingsFingerprintBefore) {
|
|
throw new Error("Realtime canvas configuration changed; restore the verified database backup");
|
|
}
|
|
const vipProviderIds = new Set(
|
|
remainingProviders
|
|
.filter((provider) => provider.type === "vip98k")
|
|
.map((provider) => provider.id),
|
|
);
|
|
const invalidVipModels = remainingModels.filter((model) => {
|
|
if (!vipProviderIds.has(model.providerId)) return false;
|
|
if (
|
|
!model.capabilities ||
|
|
typeof model.capabilities !== "object" ||
|
|
Array.isArray(model.capabilities)
|
|
) {
|
|
return true;
|
|
}
|
|
const capabilities = model.capabilities as Record<string, unknown>;
|
|
const kind = String(capabilities?.kind || "").trim().toLowerCase();
|
|
if (["video", "model-3d", "world", "chat"].includes(kind)) {
|
|
if (model.supportsImageGen) return true;
|
|
}
|
|
return ["image-tool", "model-3d"].includes(kind) && !model.isHidden;
|
|
});
|
|
if (invalidVipModels.length > 0) {
|
|
throw new Error(
|
|
`VIP98K model classification invalid: ${invalidVipModels
|
|
.slice(0, 10)
|
|
.map((model) => model.modelId)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
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,
|
|
realtimeCanvasConfigUnchanged: true,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
} finally {
|
|
await sql.end();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
});
|