ai-chatbot/lib/ai/vip98k-models.ts

627 lines
18 KiB
TypeScript

import "server-only";
export const VIP98K_BASE_URL = "https://api.vip98k.com";
export type Vip98kCatalogModelType =
| "image"
| "video"
| "image_tool"
| "document_tool"
| "model"
| "audio"
| "world"
| string;
export interface Vip98kCatalogModel {
modelId?: string;
id?: string;
name?: string;
displayName?: string;
display_name?: string;
description?: string;
type?: Vip98kCatalogModelType;
modelType?: Vip98kCatalogModelType;
model_type?: Vip98kCatalogModelType;
category?: Vip98kCatalogModelType;
capabilities?: unknown;
inputModes?: unknown;
input_modes?: unknown;
outputModes?: unknown;
output_modes?: unknown;
supportedResolutions?: unknown;
supported_resolutions?: unknown;
resolutions?: unknown;
durations?: unknown;
durationOptions?: unknown;
duration_options?: unknown;
defaultDuration?: unknown;
default_duration?: unknown;
supportsAsync?: unknown;
supports_async?: unknown;
pollIntervalSeconds?: unknown;
poll_interval_seconds?: unknown;
maxPollSeconds?: unknown;
max_poll_seconds?: unknown;
resultTTLSeconds?: unknown;
result_ttl_seconds?: unknown;
billingMode?: string;
billing_mode?: string;
pricing?: unknown;
price?: unknown;
status?: string;
[key: string]: unknown;
}
export interface Vip98kModelPreset {
modelId: string;
displayName: string;
description: string;
type: Vip98kCatalogModelType;
supportsVision: boolean;
supportsImageGen: boolean;
isHidden: boolean;
capabilities: Record<string, unknown>;
defaultParams: Record<string, unknown>;
imageGenConfig: Record<string, unknown> | null;
sortOrder: number;
}
const DEFAULT_ASPECT_RATIOS = [
"auto",
"1:1",
"4:3",
"3:4",
"16:9",
"9:16",
"3:2",
"2:3",
"4:5",
"5:4",
] as const;
const DEFAULT_VIDEO_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4"] as const;
const DEFAULT_IMAGE_SIZES = ["1K", "2K", "4K"] as const;
const TYPE_SORT_ORDER: Record<string, number> = {
image: 100,
video: 200,
world: 300,
model: 400,
image_tool: 800,
audio: 900,
document_tool: 950,
};
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function parseStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter(Boolean),
),
);
}
function parseNumberArray(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0),
),
).sort((a, b) => a - b);
}
function parseNumber(value: unknown): number | undefined {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : undefined;
}
function normalizeCatalogType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
return String(
model.type || model.modelType || model.model_type || model.category || "model",
)
.trim()
.toLowerCase();
}
function normalizeImageSize(value: string): string | null {
const normalized = value.trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
const kMatch = normalized.match(/^(\d+(?:\.\d+)?)\s*K$/);
if (kMatch?.[1]) return `${Number(kMatch[1]).toString()}K`;
return null;
}
function normalizeVideoResolution(value: string): string | null {
const normalized = value.trim().toLowerCase();
if (normalized === "720p" || normalized === "1080p" || normalized === "4k") {
return normalized;
}
if (normalized === "720" || normalized === "720P".toLowerCase()) return "720p";
if (normalized === "1080" || normalized === "1080P".toLowerCase()) return "1080p";
if (normalized === "2160p" || normalized === "2160" || normalized === "4K".toLowerCase()) {
return "4k";
}
return normalized || null;
}
function getSupportedResolutions(model: Vip98kCatalogModel) {
return parseStringArray(
model.supportedResolutions || model.supported_resolutions || model.resolutions,
);
}
function getSupportedImageSizes(model: Vip98kCatalogModel) {
const sizes = getSupportedResolutions(model)
.map(normalizeImageSize)
.filter((value): value is string => Boolean(value));
return sizes.length ? sizes : [...DEFAULT_IMAGE_SIZES];
}
function getSupportedVideoResolutions(model: Vip98kCatalogModel) {
const resolutions = getSupportedResolutions(model)
.map(normalizeVideoResolution)
.filter((value): value is string => Boolean(value));
return resolutions.length ? resolutions : ["720p"];
}
function getDurations(model: Vip98kCatalogModel) {
return parseNumberArray(
model.durations || model.durationOptions || model.duration_options,
);
}
function getCapabilities(model: Vip98kCatalogModel) {
return parseStringArray(model.capabilities).map((item) =>
item.trim().toLowerCase().replace(/_/g, "-"),
);
}
function getInputModes(model: Vip98kCatalogModel) {
return parseStringArray(model.inputModes || model.input_modes).map((item) =>
item.trim().toLowerCase(),
);
}
function getOutputModes(model: Vip98kCatalogModel) {
return parseStringArray(model.outputModes || model.output_modes).map((item) =>
item.trim().toLowerCase(),
);
}
function getDisplayName(model: Vip98kCatalogModel, modelId: string) {
return String(
model.displayName || model.display_name || model.name || modelId,
).trim();
}
function getModelId(model: Vip98kCatalogModel) {
return String(model.modelId || model.id || model.name || "").trim();
}
function getEffectivePrice(model: Vip98kCatalogModel) {
const pricing = toRecord(model.pricing);
return parseNumber(
model.price ||
pricing?.effectivePrice ||
pricing?.effective_price ||
pricing?.price ||
pricing?.requestPrice ||
pricing?.request_price,
);
}
function getBillingMode(model: Vip98kCatalogModel) {
return String(model.billingMode || model.billing_mode || "per_request").trim();
}
function getVip98kMetadata(model: Vip98kCatalogModel, type: string) {
return {
modelType: type,
taskApi: "external-task",
supportsAsync: Boolean(model.supportsAsync ?? model.supports_async ?? true),
pollIntervalSeconds: parseNumber(
model.pollIntervalSeconds || model.poll_interval_seconds,
) || 8,
maxPollSeconds: parseNumber(model.maxPollSeconds || model.max_poll_seconds) || 600,
resultTTLSeconds: parseNumber(
model.resultTTLSeconds || model.result_ttl_seconds,
),
billingMode: getBillingMode(model),
effectivePrice: getEffectivePrice(model),
pricing: model.pricing || null,
status: model.status || null,
};
}
function createImagePreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
const capabilities = getCapabilities(model);
const inputModes = getInputModes(model);
const outputModes = getOutputModes(model);
const supportedImageSizes = getSupportedImageSizes(model);
const supportsReferenceImages =
capabilities.includes("image-to-image") ||
inputModes.includes("url") ||
inputModes.includes("file");
const supportedModes = [
...(capabilities.includes("text-to-image") || capabilities.length === 0
? ["text-to-image"]
: []),
...(supportsReferenceImages ? ["image-to-image", "reference-images"] : []),
];
const maxReferenceImages = supportsReferenceImages ? 8 : 0;
const defaultImageSize =
supportedImageSizes.find((size) => size === "2K") ||
supportedImageSizes[0] ||
"1K";
return {
modelId,
displayName,
description: model.description || `VIP98K image model (${modelId})`,
type: "image",
supportsVision: supportsReferenceImages,
supportsImageGen: true,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "image",
mediaType: "image",
modelType: "image",
outputType: "image",
supportedModes,
inputModes,
outputModes,
supportedImageSizes,
supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS],
supportsMultiImageReference: supportsReferenceImages,
maxReferenceImages,
vip98k: getVip98kMetadata(model, "image"),
},
defaultParams: {
provider: "vip98k",
kind: "image",
endpoint: modelId,
apiFormat: "openai-images-compatible",
taskApi: "openai-images",
supportedModes,
imageSize: defaultImageSize,
aspectRatio: "auto",
imageCount: 1,
},
imageGenConfig: {
maxImages: 1,
maxReferenceImages,
supportsSize: true,
supports1K: supportedImageSizes.includes("1K"),
supports2K: supportedImageSizes.includes("2K"),
supports4K: supportedImageSizes.includes("4K"),
supportedImageSizes,
supportedAspectRatios: [...DEFAULT_ASPECT_RATIOS],
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
creditCostMultiplierBySize: {
"1K": 1,
"2K": 1,
"4K": 1,
},
enableLoadingPlaceholder: true,
loadingColor: "yellow",
vip98k: getVip98kMetadata(model, "image"),
},
sortOrder: TYPE_SORT_ORDER.image + index,
};
}
function createVideoPreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
const capabilities = getCapabilities(model);
const inputModes = getInputModes(model);
const outputModes = getOutputModes(model);
const supportedResolutions = getSupportedVideoResolutions(model);
const durations = getDurations(model);
const defaultDuration =
parseNumber(model.defaultDuration || model.default_duration) ||
durations[durations.length - 1] ||
8;
const supportsReferenceImages =
capabilities.includes("image-to-video") ||
inputModes.includes("url") ||
inputModes.includes("file");
const supportedModes = [
...(capabilities.includes("text-to-video") || capabilities.length === 0
? ["text-to-video"]
: []),
...(supportsReferenceImages ? ["image-to-video", "first-frame"] : []),
];
return {
modelId,
displayName,
description: model.description || `VIP98K video model (${modelId})`,
type: "video",
supportsVision: supportsReferenceImages,
supportsImageGen: false,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "video",
mediaType: "video",
modelType: "video",
outputType: "video",
supportedModes,
inputModes,
outputModes,
supportedResolutions,
supportedRatios: [...DEFAULT_VIDEO_RATIOS],
durationOptions: durations,
durationRange: durations.length
? { min: durations[0], max: durations[durations.length - 1] }
: { min: defaultDuration, max: defaultDuration },
defaultDuration,
supportWatermark: false,
supportCameraFixed: false,
maxReferenceImages: supportsReferenceImages ? 4 : 0,
vip98k: getVip98kMetadata(model, "video"),
},
defaultParams: {
provider: "vip98k",
kind: "video",
endpoint: modelId,
taskApi: "external-task",
supportedModes,
duration: defaultDuration,
resolution: supportedResolutions[0] || "720p",
ratio: "16:9",
},
imageGenConfig: {
creditCostPerVideo: 0,
videoDurationOptions: durations,
creditCostPerVideoByDuration: Object.fromEntries(
durations.map((duration) => [String(duration), 0]),
),
creditCostPerVideoByResolution: Object.fromEntries(
supportedResolutions.map((resolution) => [resolution, 0]),
),
enableLoadingPlaceholder: true,
loadingColor: "blue",
vip98k: getVip98kMetadata(model, "video"),
},
sortOrder: TYPE_SORT_ORDER.video + index,
};
}
function createWorldPreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
return {
modelId,
displayName,
description: model.description || `VIP98K 3D world model (${modelId})`,
type: "world",
supportsVision: false,
supportsImageGen: false,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "world",
mediaType: "world",
modelType: "world-generation",
outputType: "3d",
supportedModes: ["world-generation", "text-to-3d"],
vip98k: getVip98kMetadata(model, "world"),
},
defaultParams: {
provider: "vip98k",
kind: "world",
endpoint: modelId,
taskApi: "external-task",
},
imageGenConfig: null,
sortOrder: TYPE_SORT_ORDER.world + index,
};
}
function createChatPreset(
model: Vip98kCatalogModel,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
return {
modelId,
displayName,
description: model.description || `VIP98K text model (${modelId})`,
type: "model",
supportsVision: false,
supportsImageGen: false,
isHidden: false,
capabilities: {
provider: "vip98k",
kind: "chat",
mediaType: "text",
modelType: "model",
outputType: "text",
apiFormat: "openai-compatible",
vip98k: getVip98kMetadata(model, "model"),
},
defaultParams: {
provider: "vip98k",
kind: "chat",
endpoint: modelId,
apiFormat: "openai-compatible",
},
imageGenConfig: null,
sortOrder: TYPE_SORT_ORDER.model + index,
};
}
function createToolPreset(
model: Vip98kCatalogModel,
type: string,
index: number,
): Vip98kModelPreset {
const modelId = getModelId(model);
const displayName = getDisplayName(model, modelId);
const inputModes = getInputModes(model);
const outputModes = getOutputModes(model);
const isImageTool = type === "image_tool";
return {
modelId,
displayName,
description: model.description || `VIP98K ${type} model (${modelId})`,
type,
supportsVision: isImageTool,
supportsImageGen: false,
isHidden: true,
capabilities: {
provider: "vip98k",
kind: type.replace(/_/g, "-"),
mediaType: isImageTool ? "image" : type === "audio" ? "audio" : "document",
modelType: type,
outputType: outputModes[0] || (isImageTool ? "image" : "text"),
inputModes,
outputModes,
supportedModes: getCapabilities(model),
vip98k: getVip98kMetadata(model, type),
},
defaultParams: {
provider: "vip98k",
kind: type.replace(/_/g, "-"),
endpoint: modelId,
taskApi: type === "audio" ? "openai-compatible" : "external-task",
},
imageGenConfig: null,
sortOrder: (TYPE_SORT_ORDER[type] || 990) + index,
};
}
export function buildVip98kModelPresets(
models: Vip98kCatalogModel[],
): Vip98kModelPreset[] {
return models
.map((model, index) => {
const modelId = getModelId(model);
if (!modelId) return null;
const type = normalizeCatalogType(model);
if (type === "image") return createImagePreset(model, index);
if (type === "video") return createVideoPreset(model, index);
if (type === "world") return createWorldPreset(model, index);
if (type === "model") return createChatPreset(model, index);
return createToolPreset(model, type, index);
})
.filter((item): item is Vip98kModelPreset => Boolean(item))
.sort((a, b) => a.sortOrder - b.sortOrder || a.displayName.localeCompare(b.displayName));
}
export function normalizeVip98kBaseUrl(baseURL?: string | null) {
const normalized = String(baseURL || VIP98K_BASE_URL).trim().replace(/\/+$/, "");
if (!normalized) return VIP98K_BASE_URL;
if (/\/v1$/i.test(normalized)) return normalized.replace(/\/v1$/i, "");
if (/\/api\/external\/task$/i.test(normalized)) {
return normalized.replace(/\/api\/external\/task$/i, "");
}
return normalized;
}
function extractCatalogModels(payload: unknown): Vip98kCatalogModel[] {
const record = toRecord(payload);
const data = record?.data;
const dataRecord = toRecord(data);
const candidates = [
dataRecord?.models,
dataRecord?.items,
dataRecord?.list,
data,
record?.models,
record?.items,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate as Vip98kCatalogModel[];
}
return [];
}
async function parseJsonResponse(response: Response) {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function extractErrorMessage(payload: unknown, fallback = "VIP98K model catalog request failed") {
if (typeof payload === "string" && payload.trim()) return payload.trim();
const record = toRecord(payload);
const data = toRecord(record?.data);
const error = toRecord(record?.error);
const value =
data?.message ||
data?.error ||
error?.message ||
record?.message ||
record?.error ||
fallback;
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
export async function fetchVip98kCatalog(params: {
baseURL?: string | null;
apiKey: string;
}): Promise<Vip98kCatalogModel[]> {
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const response = await fetch(`${baseURL}/api/external/task/models`, {
cache: "no-store",
headers: {
Authorization: `Bearer ${params.apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(60_000),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
}
const models = extractCatalogModels(payload);
if (models.length === 0) {
throw new Error("VIP98K model catalog is empty or has an unsupported response shape");
}
return models;
}
export async function fetchVip98kModelPresets(params: {
baseURL?: string | null;
apiKey: string;
}) {
const models = await fetchVip98kCatalog(params);
return buildVip98kModelPresets(models);
}