ai-chatbot/scripts/consolidate-ai-providers.ts

449 lines
17 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",
]);
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<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 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 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<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) {
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 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);
});