627 lines
18 KiB
TypeScript
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);
|
|
}
|