You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1683 lines
58 KiB
1683 lines
58 KiB
import { GenerationInput, GenerationOutput, ModelCapability, ProviderModel } from "@/lib/providers/types";
|
|
import { DEFAULT_NEWAPIWG_BASE_URL } from "@/store/utils/localStorage";
|
|
import { resolveTemporaryImageUrlToDataUrl } from "@/lib/images/temporaryUrls";
|
|
import { validateMediaUrl } from "@/utils/urlValidation";
|
|
import { buildGatewayHeaders } from "@/app/api/_auth";
|
|
import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging";
|
|
import type { SelectedModel } from "@/types";
|
|
import { filterReferenceSubjectList, modelSupportsReferenceMedia } from "@/utils/generationInputSupport";
|
|
|
|
type NewApiWGModelObject = {
|
|
id?: string;
|
|
name?: string;
|
|
description?: string | null;
|
|
owned_by?: string;
|
|
points?: unknown;
|
|
point?: unknown;
|
|
point_cost?: unknown;
|
|
pointCost?: unknown;
|
|
credits?: unknown;
|
|
credit?: unknown;
|
|
quota?: unknown;
|
|
cost?: unknown;
|
|
price?: unknown;
|
|
model_price?: unknown;
|
|
modelPrice?: unknown;
|
|
fixed_price?: unknown;
|
|
fixedPrice?: unknown;
|
|
pricing?: unknown;
|
|
supported_endpoint_types?: unknown;
|
|
capabilities?: unknown;
|
|
capability?: unknown;
|
|
tags?: unknown;
|
|
tag?: unknown;
|
|
modalities?: unknown;
|
|
metadata?: {
|
|
capabilities?: unknown;
|
|
tags?: unknown;
|
|
modalities?: unknown;
|
|
description?: string;
|
|
points?: unknown;
|
|
point?: unknown;
|
|
point_cost?: unknown;
|
|
pointCost?: unknown;
|
|
credits?: unknown;
|
|
credit?: unknown;
|
|
quota?: unknown;
|
|
cost?: unknown;
|
|
price?: unknown;
|
|
model_price?: unknown;
|
|
modelPrice?: unknown;
|
|
fixed_price?: unknown;
|
|
fixedPrice?: unknown;
|
|
pricing?: unknown;
|
|
};
|
|
};
|
|
|
|
type NewApiWGModelsResponse = {
|
|
data?: NewApiWGModelObject[];
|
|
models?: NewApiWGModelObject[];
|
|
};
|
|
|
|
type NewApiWGPricingObject = {
|
|
model_name?: string;
|
|
name?: string;
|
|
model?: string;
|
|
owner_by?: string;
|
|
owned_by?: string;
|
|
quota_type?: unknown;
|
|
model_ratio?: unknown;
|
|
model_price?: unknown;
|
|
fixed_price?: unknown;
|
|
price?: unknown;
|
|
supported_endpoint_types?: unknown;
|
|
capabilities?: unknown;
|
|
tags?: unknown;
|
|
modalities?: unknown;
|
|
};
|
|
|
|
type NewApiWGPricingResponse = {
|
|
data?: NewApiWGPricingObject[];
|
|
};
|
|
|
|
type NewApiWGBillingCatalogItem = {
|
|
scene?: string;
|
|
model_name?: string;
|
|
channel_types?: unknown;
|
|
base_points_per_call?: unknown;
|
|
base_points?: unknown;
|
|
variants?: unknown;
|
|
supported_endpoint_types?: unknown;
|
|
capabilities?: unknown;
|
|
tags?: unknown;
|
|
modalities?: unknown;
|
|
};
|
|
|
|
type NewApiWGBillingCatalogResponse = {
|
|
data?: {
|
|
page?: number;
|
|
page_size?: number;
|
|
total?: number;
|
|
items?: NewApiWGBillingCatalogItem[];
|
|
};
|
|
success?: boolean;
|
|
};
|
|
|
|
const NEWAPIWG_USD_TO_POPI_POINTS = 70;
|
|
const NEWAPIWG_QUOTA_PER_USD = 500_000;
|
|
|
|
const NEWAPIWG_FALLBACK_MODELS: ProviderModel[] = [
|
|
{
|
|
id: "gpt-image-1",
|
|
name: "GPT Image 1",
|
|
description: "Fallback OpenAI-compatible image generation model. Remote /models is currently unavailable.",
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image", "image-to-image"],
|
|
},
|
|
{
|
|
id: "dall-e-3",
|
|
name: "DALL-E 3",
|
|
description: "Fallback OpenAI-compatible text-to-image model. Remote /models is currently unavailable.",
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image"],
|
|
},
|
|
{
|
|
id: "sora-2",
|
|
name: "Sora 2",
|
|
description: "Fallback OpenAI-compatible video generation model. Remote /models is currently unavailable.",
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-video", "image-to-video"],
|
|
},
|
|
{
|
|
id: "tts-1",
|
|
name: "TTS 1",
|
|
description: "Fallback OpenAI-compatible speech model. Remote /models is currently unavailable.",
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-audio"],
|
|
},
|
|
];
|
|
|
|
const DEFAULT_NEWAPIWG_CAPABILITIES: ModelCapability[] = [
|
|
"text-to-image",
|
|
"image-to-image",
|
|
"text-to-video",
|
|
"image-to-video",
|
|
"text-to-3d",
|
|
"image-to-3d",
|
|
"text-to-audio",
|
|
"audio-to-video",
|
|
];
|
|
|
|
export function normalizeNewApiWGBaseUrl(baseUrl?: string | null): string {
|
|
return (baseUrl || process.env.NEWAPIWG_BASE_URL || DEFAULT_NEWAPIWG_BASE_URL).replace(/\/+$/, "");
|
|
}
|
|
|
|
function isDoubaoChatModelName(text: string): boolean {
|
|
return /(^|[\s_\-/])doubao[\s_\-/](?!seedance|seedream)(?:seed|lite|pro|vision|flash|thinking|1|[0-9])/i.test(text);
|
|
}
|
|
|
|
function flattenTags(value: unknown): string[] {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) return value.flatMap(flattenTags);
|
|
if (typeof value === "string") return value.split(/[,\s]+/).filter(Boolean);
|
|
if (typeof value === "object") return Object.values(value as Record<string, unknown>).flatMap(flattenTags);
|
|
return [];
|
|
}
|
|
|
|
function addCapability(capabilities: Set<ModelCapability>, capability: string): void {
|
|
if ([
|
|
"text-to-image",
|
|
"image-to-image",
|
|
"text-to-video",
|
|
"image-to-video",
|
|
"text-to-3d",
|
|
"image-to-3d",
|
|
"text-to-audio",
|
|
"audio-to-video",
|
|
].includes(capability)) {
|
|
capabilities.add(capability as ModelCapability);
|
|
}
|
|
}
|
|
|
|
function numberFromUnknown(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string") {
|
|
const parsed = Number.parseFloat(value.trim());
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function usdToPopiPoints(usd: number): number {
|
|
if (usd <= 0) return 0;
|
|
return Math.max(1, Math.round(usd * NEWAPIWG_USD_TO_POPI_POINTS));
|
|
}
|
|
|
|
function quotaToPopiPoints(quota: number): number {
|
|
return usdToPopiPoints(quota / NEWAPIWG_QUOTA_PER_USD);
|
|
}
|
|
|
|
function pointCostFromRecord(value: unknown): number | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const record = value as Record<string, unknown>;
|
|
const directCost = [
|
|
record.points,
|
|
record.point,
|
|
record.point_cost,
|
|
record.pointCost,
|
|
record.credits,
|
|
record.credit,
|
|
record.cost,
|
|
record.amount,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
if (directCost !== null) return directCost;
|
|
|
|
const quotaCost = [
|
|
record.quota,
|
|
record.quota_cost,
|
|
record.quotaCost,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
if (quotaCost !== null) return quotaToPopiPoints(quotaCost);
|
|
|
|
const usdPrice = [
|
|
record.model_price,
|
|
record.modelPrice,
|
|
record.fixed_price,
|
|
record.fixedPrice,
|
|
record.price,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
return usdPrice !== null ? usdToPopiPoints(usdPrice) : null;
|
|
}
|
|
|
|
function getNewApiWGModelPointCost(model: NewApiWGModelObject): number | null {
|
|
const directCost = [
|
|
model.points,
|
|
model.point,
|
|
model.point_cost,
|
|
model.pointCost,
|
|
model.credits,
|
|
model.credit,
|
|
model.cost,
|
|
pointCostFromRecord(model.pricing),
|
|
model.metadata?.points,
|
|
model.metadata?.point,
|
|
model.metadata?.point_cost,
|
|
model.metadata?.pointCost,
|
|
model.metadata?.credits,
|
|
model.metadata?.credit,
|
|
model.metadata?.cost,
|
|
pointCostFromRecord(model.metadata?.pricing),
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
if (directCost !== null) return directCost;
|
|
|
|
const quotaCost = [
|
|
model.quota,
|
|
model.metadata?.quota,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
if (quotaCost !== null) return quotaToPopiPoints(quotaCost);
|
|
|
|
const usdPrice = [
|
|
model.model_price,
|
|
model.modelPrice,
|
|
model.fixed_price,
|
|
model.fixedPrice,
|
|
model.price,
|
|
model.metadata?.model_price,
|
|
model.metadata?.modelPrice,
|
|
model.metadata?.fixed_price,
|
|
model.metadata?.fixedPrice,
|
|
model.metadata?.price,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
return usdPrice !== null ? usdToPopiPoints(usdPrice) : null;
|
|
}
|
|
|
|
function normalizeNewApiWGServerRootUrl(baseUrl?: string | null): string {
|
|
return normalizeNewApiWGBaseUrl(baseUrl).replace(/\/v1(?:beta)?$/i, "");
|
|
}
|
|
|
|
function pricingModelName(row: NewApiWGPricingObject): string | null {
|
|
const value = row.model_name ?? row.model ?? row.name;
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function getNewApiWGPricingPointCost(row: NewApiWGPricingObject): number | null {
|
|
const quotaType = numberFromUnknown(row.quota_type);
|
|
const fixedPrice = [
|
|
row.model_price,
|
|
row.fixed_price,
|
|
row.price,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
|
|
if (fixedPrice !== null && (quotaType === 1 || row.quota_type === undefined)) {
|
|
return usdToPopiPoints(fixedPrice);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function billingCatalogModelName(row: NewApiWGBillingCatalogItem): string | null {
|
|
return typeof row.model_name === "string" && row.model_name.length > 0 ? row.model_name : null;
|
|
}
|
|
|
|
function getBillingCatalogVariantPointCost(variant: Record<string, unknown>): number | null {
|
|
return [
|
|
variant.effective_points,
|
|
variant.base_points,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
}
|
|
|
|
function getBillingCatalogPointCost(row: NewApiWGBillingCatalogItem): number | null {
|
|
const defaultVariant = Array.isArray(row.variants)
|
|
? row.variants.find((variant) =>
|
|
variant &&
|
|
typeof variant === "object" &&
|
|
(variant as Record<string, unknown>).variant_key === "default"
|
|
) as Record<string, unknown> | undefined
|
|
: undefined;
|
|
return [
|
|
defaultVariant ? getBillingCatalogVariantPointCost(defaultVariant) : null,
|
|
row.base_points,
|
|
].map(numberFromUnknown).find((amount): amount is number => amount !== null) ?? null;
|
|
}
|
|
|
|
function billingCatalogDimensionValue(value: unknown): string | number | boolean | null {
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "boolean") return value;
|
|
return null;
|
|
}
|
|
|
|
function billingCatalogVariantDimensions(value: unknown): Record<string, string | number | boolean> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.map(([key, rawValue]) => [key, billingCatalogDimensionValue(rawValue)] as const)
|
|
.filter((entry): entry is readonly [string, string | number | boolean] => entry[1] !== null);
|
|
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
|
}
|
|
|
|
function getBillingCatalogPricing(row: NewApiWGBillingCatalogItem): ProviderModel["pricing"] | undefined {
|
|
const pointCost = getBillingCatalogPointCost(row);
|
|
if (pointCost === null) return undefined;
|
|
|
|
const variants = Array.isArray(row.variants)
|
|
? row.variants
|
|
.map((variant) => {
|
|
if (!variant || typeof variant !== "object") return null;
|
|
const record = variant as Record<string, unknown>;
|
|
const dimensions = billingCatalogVariantDimensions(record.dimensions);
|
|
const amount = getBillingCatalogVariantPointCost(record);
|
|
return dimensions && amount !== null ? { dimensions, amount } : null;
|
|
})
|
|
.filter((variant): variant is { dimensions: Record<string, string | number | boolean>; amount: number } => Boolean(variant))
|
|
: [];
|
|
|
|
return {
|
|
type: "per-run",
|
|
amount: pointCost,
|
|
currency: "points",
|
|
variants: variants.length > 0 ? variants : undefined,
|
|
};
|
|
}
|
|
|
|
function capabilitiesFromBillingScene(row: NewApiWGBillingCatalogItem, modelName: string): ModelCapability[] {
|
|
if (row.scene === "image") return ["text-to-image", "image-to-image"];
|
|
if (row.scene === "video") return ["text-to-video", "image-to-video"];
|
|
if (row.scene === "audio") return ["text-to-audio"];
|
|
return inferNewApiWGCapabilities({
|
|
id: modelName,
|
|
name: modelName,
|
|
supported_endpoint_types: row.supported_endpoint_types,
|
|
capabilities: row.capabilities,
|
|
tags: row.tags,
|
|
modalities: row.modalities,
|
|
});
|
|
}
|
|
|
|
async function fetchNewApiWGBillingCatalogModels(
|
|
baseUrl?: string | null,
|
|
searchQuery?: string
|
|
): Promise<ProviderModel[]> {
|
|
const rootUrl = normalizeNewApiWGServerRootUrl(baseUrl);
|
|
const pageSize = 1000;
|
|
const allItems: NewApiWGBillingCatalogItem[] = [];
|
|
|
|
for (let page = 1; page <= 20; page++) {
|
|
const params = new URLSearchParams({
|
|
p: String(page),
|
|
page_size: String(pageSize),
|
|
});
|
|
if (searchQuery) params.set("keyword", searchQuery);
|
|
|
|
const response = await Promise.resolve(fetch(`${rootUrl}/api/billing_catalog?${params.toString()}`)).catch(() => null);
|
|
if (!response?.ok) break;
|
|
|
|
const data = (await response.json().catch(() => ({}))) as NewApiWGBillingCatalogResponse;
|
|
const items = Array.isArray(data.data?.items) ? data.data.items : [];
|
|
allItems.push(...items);
|
|
|
|
const total = numberFromUnknown(data.data?.total) ?? allItems.length;
|
|
if (items.length === 0 || allItems.length >= total) break;
|
|
}
|
|
|
|
return allItems
|
|
.map((row) => {
|
|
const modelName = billingCatalogModelName(row);
|
|
if (!modelName) return null;
|
|
const capabilities = capabilitiesFromBillingScene(row, modelName);
|
|
if (capabilities.length === 0) return null;
|
|
const pricing = getBillingCatalogPricing(row);
|
|
const model: ProviderModel = {
|
|
id: modelName,
|
|
name: modelName,
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities,
|
|
pricing,
|
|
};
|
|
return model;
|
|
})
|
|
.filter((model): model is ProviderModel => Boolean(model));
|
|
}
|
|
|
|
async function fetchNewApiWGPricingModels(baseUrl?: string | null): Promise<ProviderModel[]> {
|
|
const url = `${normalizeNewApiWGServerRootUrl(baseUrl)}/api/pricing`;
|
|
const response = await Promise.resolve(fetch(url)).catch(() => null);
|
|
if (!response?.ok) return [];
|
|
|
|
const data = (await response.json().catch(() => ({}))) as NewApiWGPricingResponse;
|
|
const rows = Array.isArray(data.data) ? data.data : [];
|
|
return rows
|
|
.map((row) => {
|
|
const modelName = pricingModelName(row);
|
|
if (!modelName) return null;
|
|
const capabilities = inferNewApiWGCapabilities({
|
|
id: modelName,
|
|
name: modelName,
|
|
owned_by: row.owned_by ?? row.owner_by,
|
|
supported_endpoint_types: row.supported_endpoint_types,
|
|
capabilities: row.capabilities,
|
|
tags: row.tags,
|
|
modalities: row.modalities,
|
|
});
|
|
if (capabilities.length === 0) return null;
|
|
const pointCost = getNewApiWGPricingPointCost(row);
|
|
const model: ProviderModel = {
|
|
id: modelName,
|
|
name: modelName,
|
|
description: null,
|
|
provider: "newapiwg" as const,
|
|
capabilities,
|
|
pricing: pointCost !== null
|
|
? { type: "per-run" as const, amount: pointCost, currency: "points" }
|
|
: undefined,
|
|
};
|
|
return model;
|
|
})
|
|
.filter((model): model is ProviderModel => Boolean(model));
|
|
}
|
|
|
|
function mergeNewApiWGPricing(models: ProviderModel[], pricingModels: ProviderModel[]): ProviderModel[] {
|
|
if (pricingModels.length === 0) return models;
|
|
const pricingById = new Map(
|
|
pricingModels
|
|
.filter((model) => model.pricing)
|
|
.map((model) => [model.id, model.pricing!])
|
|
);
|
|
const existingIds = new Set(models.map((model) => model.id));
|
|
const mergedModels = models.map((model) => {
|
|
const pricing = pricingById.get(model.id) ?? model.pricing;
|
|
return pricing ? { ...model, pricing } : model;
|
|
});
|
|
return [
|
|
...mergedModels,
|
|
...pricingModels.filter((model) => !existingIds.has(model.id)),
|
|
];
|
|
}
|
|
|
|
export function inferNewApiWGCapabilities(model: NewApiWGModelObject): ModelCapability[] {
|
|
const rawTags = [
|
|
...flattenTags(model.capabilities),
|
|
...flattenTags(model.capability),
|
|
...flattenTags(model.tags),
|
|
...flattenTags(model.tag),
|
|
...flattenTags(model.modalities),
|
|
...flattenTags(model.metadata?.capabilities),
|
|
...flattenTags(model.metadata?.tags),
|
|
...flattenTags(model.metadata?.modalities),
|
|
].map((tag) => tag.toLowerCase());
|
|
|
|
const capabilities = new Set<ModelCapability>();
|
|
rawTags.forEach((tag) => addCapability(capabilities, tag));
|
|
if (capabilities.size > 0) return [...capabilities];
|
|
|
|
const text = [
|
|
model.id,
|
|
model.name,
|
|
model.owned_by,
|
|
model.description,
|
|
model.metadata?.description,
|
|
...flattenTags(model.supported_endpoint_types),
|
|
...rawTags,
|
|
].filter(Boolean).join(" ").toLowerCase();
|
|
const modelNameText = [model.id, model.name].filter(Boolean).join(" ").toLowerCase();
|
|
const isDoubaoChatOnly = isDoubaoChatModelName(modelNameText);
|
|
if (isDoubaoChatOnly) return [];
|
|
|
|
const isImageNamedSora = /(^|[\s_\-/])sora[_\-/]?image(?=$|[\s_\-/])/.test(text);
|
|
const hasImage = /image|img|vision|picture|photo|flux|sdxl|stable-diffusion|midjourney|dall-e|gpt-image|imagen|seedream|banana|ernie-image/.test(text);
|
|
const hasVideo = !isImageNamedSora && /video|vidu|veo|sora|kling|runway|luma|wan|hailuo|seedance|dreamactor|gen[_-]?video|animate|motion/.test(text);
|
|
const hasAudio = /audio|speech|voice|tts|music|suno|sound/.test(text);
|
|
const has3d = /3d|mesh|tripo|hunyuan3d|point-e|shap-e/.test(text);
|
|
const isChatOnly = /kimi|moonshot|deepseek|qwen|glm|claude|chat|embedding|rerank|(^|[\s_\-/])gpt-[45]/.test(text) ||
|
|
/minimax-m\d/i.test(text);
|
|
const hasInputImage = /image-to-|image_to_|img2|i2|edit|inpaint|upscale|restore|controlnet|vision|gpt-image|banana|seedream|gemini.*image|image-preview/.test(text);
|
|
|
|
if (has3d) capabilities.add(hasInputImage ? "image-to-3d" : "text-to-3d");
|
|
if (hasVideo) {
|
|
capabilities.add("text-to-video");
|
|
capabilities.add("image-to-video");
|
|
if (/audio|sound|music|speech|voice/.test(text)) capabilities.add("audio-to-video");
|
|
}
|
|
if (hasAudio && !hasVideo) capabilities.add("text-to-audio");
|
|
if (hasImage && !hasVideo && !hasAudio && !has3d) {
|
|
capabilities.add("text-to-image");
|
|
if (hasInputImage) capabilities.add("image-to-image");
|
|
}
|
|
|
|
if (capabilities.size === 0 && isChatOnly) return [];
|
|
|
|
return capabilities.size > 0 ? [...capabilities] : DEFAULT_NEWAPIWG_CAPABILITIES;
|
|
}
|
|
|
|
export async function fetchNewApiWGModels(
|
|
apiKey: string,
|
|
baseUrl?: string | null,
|
|
searchQuery?: string,
|
|
loginToken?: string | null
|
|
): Promise<ProviderModel[]> {
|
|
const url = `${normalizeNewApiWGBaseUrl(baseUrl)}/models`;
|
|
const init: RequestInit = {
|
|
headers: buildGatewayHeaders(apiKey, loginToken),
|
|
};
|
|
const startedAt = Date.now();
|
|
const context = { searchQuery };
|
|
logGatewayRequest("Calling NewApiWG models API", url, init, context);
|
|
const response = await fetch(url, init);
|
|
await logGatewayResponse("NewApiWG models API response", url, init, response, context, startedAt);
|
|
|
|
if (!response.ok) {
|
|
const message = await response.text().catch(() => "");
|
|
const catalogModels = await fetchNewApiWGBillingCatalogModels(baseUrl, searchQuery);
|
|
if (catalogModels.length > 0) {
|
|
return searchQuery ? filterNewApiWGModels(catalogModels, searchQuery) : catalogModels;
|
|
}
|
|
const pricingModels = await fetchNewApiWGPricingModels(baseUrl);
|
|
if (pricingModels.length > 0) {
|
|
return searchQuery ? filterNewApiWGModels(pricingModels, searchQuery) : pricingModels;
|
|
}
|
|
if (response.status === 401 && message.includes("missing bearer key")) {
|
|
return searchQuery ? filterNewApiWGFallbackModels(searchQuery) : NEWAPIWG_FALLBACK_MODELS;
|
|
}
|
|
throw new Error(`NewApiWG models API error: ${response.status}${message ? ` ${message.slice(0, 160)}` : ""}`);
|
|
}
|
|
|
|
const data = (await response.json()) as NewApiWGModelsResponse;
|
|
const catalogModels = await fetchNewApiWGBillingCatalogModels(baseUrl, searchQuery);
|
|
const pricingModels = catalogModels.length > 0
|
|
? catalogModels
|
|
: await fetchNewApiWGPricingModels(baseUrl);
|
|
const rawModels = data.data ?? data.models ?? [];
|
|
const normalized = rawModels
|
|
.filter((model) => model.id)
|
|
.map((model) => {
|
|
const capabilities = inferNewApiWGCapabilities(model);
|
|
const pointCost = getNewApiWGModelPointCost(model);
|
|
return {
|
|
id: model.id!,
|
|
name: model.name || model.id!,
|
|
description: model.description ?? model.metadata?.description ?? null,
|
|
provider: "newapiwg" as const,
|
|
capabilities,
|
|
pricing: pointCost !== null
|
|
? { type: "per-run" as const, amount: pointCost, currency: "points" }
|
|
: undefined,
|
|
};
|
|
})
|
|
.filter((model) => model.capabilities.length > 0);
|
|
const pricedModels = mergeNewApiWGPricing(normalized, pricingModels);
|
|
|
|
if (!searchQuery) return pricedModels;
|
|
return filterNewApiWGModels(pricedModels, searchQuery);
|
|
}
|
|
|
|
function filterNewApiWGModels(models: ProviderModel[], searchQuery: string): ProviderModel[] {
|
|
const query = searchQuery.toLowerCase();
|
|
return models.filter((model) =>
|
|
model.id.toLowerCase().includes(query) ||
|
|
model.name.toLowerCase().includes(query) ||
|
|
!!model.description?.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
function filterNewApiWGFallbackModels(searchQuery: string): ProviderModel[] {
|
|
return filterNewApiWGModels(NEWAPIWG_FALLBACK_MODELS, searchQuery);
|
|
}
|
|
|
|
function firstString(value: unknown): string | undefined {
|
|
if (typeof value === "string") return value;
|
|
if (Array.isArray(value)) return value.map(firstString).find(Boolean);
|
|
if (value && typeof value === "object") {
|
|
return firstString(Object.values(value as Record<string, unknown>));
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getNewApiWGErrorMessage(data: unknown, fallbackError: string): string {
|
|
if (data && typeof data === "object") {
|
|
const record = data as Record<string, unknown>;
|
|
const error = record.error;
|
|
if (error && typeof error === "object") {
|
|
const errorRecord = error as Record<string, unknown>;
|
|
if (typeof errorRecord.message === "string" && errorRecord.message.length > 0) {
|
|
return errorRecord.message;
|
|
}
|
|
if (typeof errorRecord.code === "string" && errorRecord.code.length > 0) {
|
|
return errorRecord.code;
|
|
}
|
|
}
|
|
}
|
|
|
|
return firstString(data) || fallbackError;
|
|
}
|
|
|
|
const NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS = [500, 1500];
|
|
|
|
function isNewApiWGTransientError(message: string): boolean {
|
|
return /system\s+disk\s+overloaded/i.test(message);
|
|
}
|
|
|
|
function isNewApiWGInsufficientPointsError(message: string): boolean {
|
|
return /insufficient[_\s-]*(points?|credits?|balance|quota)|not enough (points?|credits?|balance|quota)|余额不足|积分不足/i.test(message);
|
|
}
|
|
|
|
export function normalizeNewApiWGError(message: string, modelName = "NewApiWG"): string {
|
|
const normalized = message.trim();
|
|
if (isNewApiWGTransientError(normalized)) {
|
|
return `${modelName}: provider is temporarily overloaded. Please retry in a moment or switch models.`;
|
|
}
|
|
if (isNewApiWGInsufficientPointsError(normalized)) {
|
|
return `${modelName}: NewApiWG account has insufficient points for this generation. Add balance, switch to a cheaper model, or configure another API key.`;
|
|
}
|
|
if (
|
|
/InputImageSensitiveContentDetected\.PrivacyInformation/i.test(normalized) ||
|
|
/input image may contain real person/i.test(normalized)
|
|
) {
|
|
return `${modelName}: input image was rejected by the provider safety review because it may contain a real person or privacy information. Use a non-person image, anonymize the face, or try text-to-video.`;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function wait(ms: number): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function fetchNewApiWGJsonWithRetry(
|
|
url: string,
|
|
init: RequestInit,
|
|
modelName: string,
|
|
fallbackError: string
|
|
): Promise<{ response: Response; data: unknown }> {
|
|
for (let attempt = 0; attempt <= NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
|
|
const context = {
|
|
modelName,
|
|
attempt: attempt + 1,
|
|
};
|
|
const startedAt = Date.now();
|
|
logGatewayRequest("Calling NewApiWG JSON API", url, init, context);
|
|
const response = await fetch(url, init);
|
|
await logGatewayResponse("NewApiWG JSON API response", url, init, response, context, startedAt);
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
if (response.ok) {
|
|
return { response, data };
|
|
}
|
|
|
|
const message = getNewApiWGErrorMessage(data, fallbackError);
|
|
const canRetry =
|
|
attempt < NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS.length &&
|
|
isNewApiWGTransientError(message);
|
|
|
|
if (!canRetry) {
|
|
return { response, data };
|
|
}
|
|
|
|
console.warn(
|
|
`[NewApiWG] ${modelName} returned transient overload; retrying attempt ${attempt + 2}`
|
|
);
|
|
await wait(NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS[attempt]);
|
|
}
|
|
|
|
throw new Error("unreachable");
|
|
}
|
|
|
|
function getNewApiWGTaskId(data: unknown): string | null {
|
|
if (!data || typeof data !== "object") return null;
|
|
const record = data as Record<string, unknown>;
|
|
const id = record.task_id ?? record.id;
|
|
return typeof id === "string" ? id : null;
|
|
}
|
|
|
|
function getNewApiWGVideoUrl(data: unknown): string | null {
|
|
if (!data || typeof data !== "object") return null;
|
|
const record = data as Record<string, unknown>;
|
|
const nestedData = record.data && typeof record.data === "object" ? record.data as Record<string, unknown> : null;
|
|
const metadata = nestedData?.metadata && typeof nestedData.metadata === "object" ? nestedData.metadata as Record<string, unknown> : null;
|
|
const taskData = nestedData?.data && typeof nestedData.data === "object" ? nestedData.data as Record<string, unknown> : null;
|
|
const creations = taskData?.creations;
|
|
const firstCreation = Array.isArray(creations) && creations[0] && typeof creations[0] === "object"
|
|
? creations[0] as Record<string, unknown>
|
|
: null;
|
|
const candidates = [
|
|
record.url,
|
|
record.video_url,
|
|
nestedData?.result_url,
|
|
metadata?.url,
|
|
firstCreation?.url,
|
|
];
|
|
return candidates.find((value): value is string => typeof value === "string" && value.length > 0) ?? null;
|
|
}
|
|
|
|
function getNewApiWGTaskStatus(data: unknown): string {
|
|
if (!data || typeof data !== "object") return "";
|
|
const record = data as Record<string, unknown>;
|
|
const nestedData = record.data && typeof record.data === "object" ? record.data as Record<string, unknown> : null;
|
|
const taskData = nestedData?.data && typeof nestedData.data === "object" ? nestedData.data as Record<string, unknown> : null;
|
|
return String(taskData?.state ?? nestedData?.status ?? record.status ?? "").toLowerCase();
|
|
}
|
|
|
|
async function pollNewApiWGVideo(
|
|
apiKey: string,
|
|
baseUrl: string,
|
|
taskId: string,
|
|
loginToken?: string | null
|
|
): Promise<GenerationOutput> {
|
|
const deadline = Date.now() + 9 * 60 * 1000;
|
|
|
|
while (Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
const url = `${baseUrl}/video/generations/${encodeURIComponent(taskId)}`;
|
|
const init: RequestInit = {
|
|
headers: buildGatewayHeaders(apiKey, loginToken),
|
|
};
|
|
const context = { taskId };
|
|
const startedAt = Date.now();
|
|
logGatewayRequest("Polling NewApiWG video generation", url, init, context);
|
|
const response = await fetch(url, init);
|
|
await logGatewayResponse("NewApiWG video poll response", url, init, response, context, startedAt);
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => "");
|
|
let message = text || `NewApiWG video polling error: ${response.status}`;
|
|
try {
|
|
message = getNewApiWGErrorMessage(JSON.parse(text), message);
|
|
} catch {
|
|
// Keep the plain text response.
|
|
}
|
|
return { success: false, error: normalizeNewApiWGError(message) };
|
|
}
|
|
|
|
const data = await response.json();
|
|
const videoUrl = getNewApiWGVideoUrl(data);
|
|
if (videoUrl) {
|
|
return { success: true, outputs: [{ type: "video", data: "", url: videoUrl }] };
|
|
}
|
|
|
|
const status = getNewApiWGTaskStatus(data);
|
|
if (status === "failed" || status === "fail" || status === "error") {
|
|
const message = getNewApiWGErrorMessage(data, "NewApiWG video generation failed");
|
|
return { success: false, error: normalizeNewApiWGError(message) };
|
|
}
|
|
}
|
|
|
|
return { success: false, error: "NewApiWG video generation timed out" };
|
|
}
|
|
|
|
function promptFromInput(input: GenerationInput): string {
|
|
const dynamicPrompt = input.dynamicInputs?.prompt;
|
|
if (typeof dynamicPrompt === "string") return dynamicPrompt;
|
|
if (Array.isArray(dynamicPrompt)) return dynamicPrompt.find((value) => typeof value === "string" && value.length > 0) ?? input.prompt;
|
|
return input.prompt;
|
|
}
|
|
|
|
function dynamicInputsWithoutPrompt(input: GenerationInput): Record<string, string | string[]> {
|
|
const dynamicInputs = { ...(input.dynamicInputs || {}) };
|
|
delete dynamicInputs.prompt;
|
|
return dynamicInputs;
|
|
}
|
|
|
|
const NEWAPIWG_VIDEO_IMAGE_INPUTS = new Set([
|
|
"image",
|
|
"images",
|
|
"image_url",
|
|
"image_urls",
|
|
"first_frame",
|
|
"first_frame_url",
|
|
"last_frame",
|
|
"last_frame_url",
|
|
"tail_image_url",
|
|
"reference_image",
|
|
"reference_image_url",
|
|
"reference_image_urls",
|
|
]);
|
|
|
|
const NEWAPIWG_VIDEO_INPUTS = new Set([
|
|
"video",
|
|
"videos",
|
|
"video_url",
|
|
"video_urls",
|
|
"reference_video",
|
|
"reference_video_url",
|
|
"reference_video_urls",
|
|
]);
|
|
|
|
const NEWAPIWG_VIDEO_AUDIO_INPUTS = new Set([
|
|
"voices",
|
|
]);
|
|
|
|
const NEWAPIWG_LEGACY_VIDEO_AUDIO_INPUTS = new Set([
|
|
"audio",
|
|
"audios",
|
|
"audio_url",
|
|
"audio_urls",
|
|
"reference_audio",
|
|
"reference_audio_url",
|
|
"reference_audio_urls",
|
|
]);
|
|
|
|
const NEWAPIWG_IMAGE_RESOLUTION_PIXELS: Record<string, number> = {
|
|
"512": 512 * 512,
|
|
"1K": 1024 * 1024,
|
|
"2K": 2560 * 1440,
|
|
"4K": 3840 * 2160,
|
|
};
|
|
const NEWAPIWG_SEEDREAM_MIN_IMAGE_PIXELS = NEWAPIWG_IMAGE_RESOLUTION_PIXELS["2K"];
|
|
const REFERENCE_IMAGE_FETCH_TIMEOUT_MS = 30_000;
|
|
const MAX_REFERENCE_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
|
|
function asStringArray(value: string | string[] | undefined): string[] {
|
|
if (typeof value === "string") return value.length > 0 ? [value] : [];
|
|
if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
|
|
return [];
|
|
}
|
|
|
|
function uniqueStrings(values: string[]): string[] {
|
|
return [...new Set(values)];
|
|
}
|
|
|
|
function isViduVideoModel(modelId: string): boolean {
|
|
return /vidu/i.test(modelId);
|
|
}
|
|
|
|
function appendDynamicInputArray(
|
|
inputs: Record<string, string | string[]>,
|
|
key: string,
|
|
values: string[]
|
|
): void {
|
|
const merged = uniqueStrings([
|
|
...asStringArray(inputs[key]),
|
|
...values,
|
|
]);
|
|
if (merged.length > 0) inputs[key] = merged;
|
|
}
|
|
|
|
function normalizeNewApiWGVideoInputs(
|
|
input: GenerationInput,
|
|
dynamicInputs: Record<string, string | string[]>
|
|
): {
|
|
dynamicInputs: Record<string, string | string[]>;
|
|
mediaInputs: Record<string, string[]>;
|
|
} {
|
|
const normalizedDynamicInputs: Record<string, string | string[]> = {};
|
|
const images = [...(input.images || [])];
|
|
const videos: string[] = [];
|
|
const voices: string[] = [];
|
|
const shouldUseViduReferenceVideoField = isViduVideoModel(input.model.id);
|
|
const selectedModel = toSelectedModel(input.model);
|
|
const supportsVideoReference = modelSupportsReferenceMedia(selectedModel, "video", undefined, input.parameters);
|
|
const supportsAudioReference = modelSupportsReferenceMedia(selectedModel, "audio", undefined, input.parameters);
|
|
|
|
for (const [key, value] of Object.entries(dynamicInputs)) {
|
|
if (NEWAPIWG_VIDEO_IMAGE_INPUTS.has(key)) {
|
|
images.push(...asStringArray(value));
|
|
} else if (NEWAPIWG_VIDEO_INPUTS.has(key)) {
|
|
if (!supportsVideoReference) continue;
|
|
const videoValues = asStringArray(value);
|
|
if (shouldUseViduReferenceVideoField) {
|
|
appendDynamicInputArray(normalizedDynamicInputs, "reference_video_urls", videoValues);
|
|
} else {
|
|
videos.push(...videoValues);
|
|
}
|
|
} else if (NEWAPIWG_VIDEO_AUDIO_INPUTS.has(key)) {
|
|
if (!supportsAudioReference) continue;
|
|
voices.push(...asStringArray(value));
|
|
} else if (NEWAPIWG_LEGACY_VIDEO_AUDIO_INPUTS.has(key)) {
|
|
continue;
|
|
} else {
|
|
normalizedDynamicInputs[key] = value;
|
|
}
|
|
}
|
|
|
|
const mediaInputs: Record<string, string[]> = {};
|
|
const uniqueImages = uniqueStrings(images);
|
|
const uniqueVideos = uniqueStrings(videos);
|
|
const uniqueVoices = uniqueStrings(voices);
|
|
if (uniqueImages.length > 0) mediaInputs.images = uniqueImages;
|
|
if (uniqueVideos.length > 0) mediaInputs.videos = uniqueVideos;
|
|
if (uniqueVoices.length > 0) mediaInputs.voices = uniqueVoices;
|
|
|
|
return { dynamicInputs: normalizedDynamicInputs, mediaInputs };
|
|
}
|
|
|
|
function toSelectedModel(model: GenerationInput["model"]): SelectedModel {
|
|
return {
|
|
provider: model.provider,
|
|
modelId: model.id,
|
|
displayName: model.name,
|
|
capabilities: model.capabilities,
|
|
metadata: model.metadata,
|
|
};
|
|
}
|
|
|
|
function sanitizeNewApiWGVideoParameters(
|
|
model: GenerationInput["model"],
|
|
parameters: Record<string, unknown> | undefined
|
|
): Record<string, unknown> | undefined {
|
|
if (!parameters) return undefined;
|
|
const normalized = { ...parameters };
|
|
for (const key of NEWAPIWG_LEGACY_VIDEO_AUDIO_INPUTS) {
|
|
delete normalized[key];
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(normalized, "referenceSubjectList")) {
|
|
normalized.referenceSubjectList = filterReferenceSubjectList(
|
|
normalized.referenceSubjectList,
|
|
toSelectedModel(model),
|
|
undefined,
|
|
parameters
|
|
);
|
|
if (Array.isArray(normalized.referenceSubjectList) && normalized.referenceSubjectList.length === 0) {
|
|
delete normalized.referenceSubjectList;
|
|
}
|
|
}
|
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
}
|
|
|
|
function buildNewApiWGImageInputs(input: GenerationInput): Record<string, string | string[]> {
|
|
if (!input.images?.length) return {};
|
|
return { image: input.images.length === 1 ? input.images[0] : input.images };
|
|
}
|
|
|
|
function getStringParameter(parameters: Record<string, unknown> | undefined, key: string): string | null {
|
|
const value = parameters?.[key];
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function parseAspectRatio(value: string | null): { width: number; height: number } {
|
|
const match = value?.match(/^(\d+):(\d+)$/);
|
|
if (!match) return { width: 1, height: 1 };
|
|
const width = Number(match[1]);
|
|
const height = Number(match[2]);
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
return { width: 1, height: 1 };
|
|
}
|
|
return { width, height };
|
|
}
|
|
|
|
function ceilToMultiple(value: number, multiple: number): number {
|
|
return Math.ceil(value / multiple) * multiple;
|
|
}
|
|
|
|
function isSeedreamImageModel(modelId: string): boolean {
|
|
return /seedream/i.test(modelId);
|
|
}
|
|
|
|
function parsePixelSize(value: string): { width: number; height: number } | null {
|
|
const match = value.match(/^(\d+)x(\d+)$/i);
|
|
if (!match) return null;
|
|
const width = Number(match[1]);
|
|
const height = Number(match[2]);
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
return null;
|
|
}
|
|
return { width, height };
|
|
}
|
|
|
|
function pixelSizeForAspectRatio(targetPixels: number, ratio: { width: number; height: number }): string {
|
|
const width = ceilToMultiple(Math.sqrt(targetPixels * ratio.width / ratio.height), 8);
|
|
const height = ceilToMultiple(Math.sqrt(targetPixels * ratio.height / ratio.width), 8);
|
|
return `${width}x${height}`;
|
|
}
|
|
|
|
function clampPixelSize(size: string, minPixels: number): string {
|
|
if (minPixels <= 0) return size;
|
|
const parsed = parsePixelSize(size);
|
|
if (!parsed || parsed.width * parsed.height >= minPixels) return size;
|
|
return pixelSizeForAspectRatio(minPixels, parsed);
|
|
}
|
|
|
|
function resolveNewApiWGImageSize(
|
|
parameters: Record<string, unknown> | undefined,
|
|
modelId: string
|
|
): string {
|
|
const minPixels = isSeedreamImageModel(modelId) ? NEWAPIWG_SEEDREAM_MIN_IMAGE_PIXELS : 0;
|
|
const requestedSize =
|
|
getStringParameter(parameters, "imageSize") ??
|
|
getStringParameter(parameters, "resolution");
|
|
if (requestedSize) {
|
|
if (/^\d+x\d+$/i.test(requestedSize)) return clampPixelSize(requestedSize, minPixels);
|
|
|
|
const targetPixels = NEWAPIWG_IMAGE_RESOLUTION_PIXELS[requestedSize];
|
|
if (targetPixels) {
|
|
const ratio = parseAspectRatio(
|
|
getStringParameter(parameters, "aspectRatio") ??
|
|
getStringParameter(parameters, "aspect_ratio")
|
|
);
|
|
return pixelSizeForAspectRatio(Math.max(targetPixels, minPixels), ratio);
|
|
}
|
|
}
|
|
|
|
const explicitSize = getStringParameter(parameters, "size");
|
|
if (explicitSize) return clampPixelSize(explicitSize, minPixels);
|
|
return clampPixelSize("1024x1024", minPixels);
|
|
}
|
|
|
|
function isImagenGenerationModel(modelId: string): boolean {
|
|
return /(^|[\/_\-.])imagen(\d|[\/_\-.]|$)/i.test(modelId);
|
|
}
|
|
|
|
function isOpenAIChatImageModel(modelId: string): boolean {
|
|
return /(^|[_\-/])sora[_\-/]?image$/i.test(modelId) ||
|
|
modelId === "gpt-image-2-all" ||
|
|
modelId === "gpt-image-2-vip";
|
|
}
|
|
|
|
const GPT_IMAGE_2_1K_SIZE_BY_RATIO: Record<string, string> = {
|
|
"1:1": "1024x1024",
|
|
"3:4": "768x1024",
|
|
"4:3": "1024x768",
|
|
"9:16": "720x1280",
|
|
"16:9": "1280x720",
|
|
};
|
|
|
|
function aspectRatioParameter(
|
|
parameters: Record<string, unknown> | undefined
|
|
): string | null {
|
|
return getStringParameter(parameters, "aspectRatio") ??
|
|
getStringParameter(parameters, "aspect_ratio");
|
|
}
|
|
|
|
function aspectRatioKey(ratio: { width: number; height: number }): string {
|
|
return `${ratio.width}:${ratio.height}`;
|
|
}
|
|
|
|
function pixelSizeForLongEdge(
|
|
ratio: { width: number; height: number },
|
|
longEdge: number
|
|
): string {
|
|
if (ratio.width >= ratio.height) {
|
|
return `${longEdge}x${ceilToMultiple((longEdge * ratio.height) / ratio.width, 8)}`;
|
|
}
|
|
return `${ceilToMultiple((longEdge * ratio.width) / ratio.height, 8)}x${longEdge}`;
|
|
}
|
|
|
|
function resolveGptImage2Size(
|
|
modelId: string,
|
|
parameters: Record<string, unknown> | undefined
|
|
): string | null {
|
|
const resolution =
|
|
getStringParameter(parameters, "resolution") ??
|
|
getStringParameter(parameters, "imageSize") ??
|
|
getStringParameter(parameters, "image_size") ??
|
|
getStringParameter(parameters, "output_size") ??
|
|
getStringParameter(parameters, "size");
|
|
const normalizedResolution = resolution?.trim().toUpperCase();
|
|
const ratio = parseAspectRatio(aspectRatioParameter(parameters) ?? "16:9");
|
|
const ratioKey = aspectRatioKey(ratio);
|
|
if (normalizedResolution === "1K") {
|
|
return GPT_IMAGE_2_1K_SIZE_BY_RATIO[ratioKey] ?? pixelSizeForLongEdge(ratio, 1024);
|
|
}
|
|
if (normalizedResolution === "2K") return pixelSizeForLongEdge(ratio, 2048);
|
|
if (normalizedResolution === "4K" && modelId === "gpt-image-2-all") {
|
|
return pixelSizeForLongEdge(ratio, 2048);
|
|
}
|
|
if (normalizedResolution === "4K") return pixelSizeForLongEdge(ratio, 3840);
|
|
|
|
return getStringParameter(parameters, "size");
|
|
}
|
|
|
|
function resolveOpenAIChatImageSize(
|
|
modelId: string,
|
|
parameters: Record<string, unknown> | undefined
|
|
): string | null {
|
|
if (modelId === "gpt-image-2-all" || modelId === "gpt-image-2-vip") {
|
|
return resolveGptImage2Size(modelId, parameters) ?? "2048x1152";
|
|
}
|
|
|
|
const aspectRatio = aspectRatioParameter(parameters);
|
|
if (aspectRatio) {
|
|
const ratio = parseAspectRatio(aspectRatio);
|
|
if (ratio.width < ratio.height) return "1024x1536";
|
|
if (ratio.width > ratio.height) return "1536x1024";
|
|
return "1024x1024";
|
|
}
|
|
|
|
return getStringParameter(parameters, "size");
|
|
}
|
|
|
|
function normalizeOpenAIChatImageParameters(
|
|
modelId: string,
|
|
parameters: Record<string, unknown> | undefined
|
|
): Record<string, unknown> {
|
|
const normalized = { ...(parameters || {}) };
|
|
const size = resolveOpenAIChatImageSize(modelId, parameters);
|
|
if (size) normalized.size = size;
|
|
delete normalized.aspectRatio;
|
|
delete normalized.aspect_ratio;
|
|
delete normalized.resolution;
|
|
delete normalized.imageSize;
|
|
delete normalized.image_size;
|
|
delete normalized.output_size;
|
|
delete normalized.dimensions;
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeNewApiWGImageGenerationParameters(
|
|
parameters: Record<string, unknown> | undefined
|
|
): Record<string, unknown> {
|
|
const normalized = { ...(parameters || {}) };
|
|
delete normalized.size;
|
|
delete normalized.image_size;
|
|
delete normalized.output_size;
|
|
delete normalized.dimensions;
|
|
delete normalized.aspectRatio;
|
|
delete normalized.aspect_ratio;
|
|
delete normalized.resolution;
|
|
delete normalized.imageSize;
|
|
return normalized;
|
|
}
|
|
|
|
function isGeminiNativeImageModel(modelId: string): boolean {
|
|
return /gemini-.*image-preview/i.test(modelId) || /nano[_\-/]?banana/i.test(modelId);
|
|
}
|
|
|
|
function toGeminiNativeModelName(modelId: string): string {
|
|
if (/nano[_\-/]?banana[_\-/]?pro/i.test(modelId)) return "gemini-3-pro-image-preview";
|
|
if (/nano[_\-/]?banana[_\-/]?2/i.test(modelId)) return "gemini-3.1-flash-image-preview";
|
|
return modelId;
|
|
}
|
|
|
|
function normalizeNewApiWGGeminiBaseUrl(baseUrl: string): string {
|
|
return baseUrl.replace(/\/v1$/, "/v1beta");
|
|
}
|
|
|
|
function getStringCandidate(value: unknown): string | null {
|
|
if (typeof value !== "string" || value.length === 0) return null;
|
|
if (value.startsWith("data:image/")) return value;
|
|
if (/^https?:\/\/\S+\.(png|jpe?g|webp)(\?\S*)?$/i.test(value)) return value;
|
|
return null;
|
|
}
|
|
|
|
function getImageUrlCandidate(value: unknown): string | null {
|
|
if (typeof value !== "string" || value.length === 0) return null;
|
|
if (value.startsWith("data:image/")) return value;
|
|
if (value.startsWith("http://") || value.startsWith("https://")) return value;
|
|
return null;
|
|
}
|
|
|
|
function isHttpUrl(value: string): boolean {
|
|
return value.startsWith("http://") || value.startsWith("https://");
|
|
}
|
|
|
|
function extractImageFromText(text: string): string | null {
|
|
const dataUrl = text.match(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/i)?.[0];
|
|
if (dataUrl) return dataUrl;
|
|
|
|
const markdownUrl = text.match(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/i)?.[1];
|
|
if (markdownUrl) return markdownUrl;
|
|
|
|
const plainUrl = text.match(/https?:\/\/\S+/i)?.[0];
|
|
if (plainUrl) return plainUrl;
|
|
|
|
try {
|
|
return extractNewApiWGImage(JSON.parse(text));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractNewApiWGImage(value: unknown): string | null {
|
|
const direct = getStringCandidate(value);
|
|
if (direct) return direct;
|
|
if (!value || typeof value !== "object") return null;
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = extractNewApiWGImage(item);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const record = value as Record<string, unknown>;
|
|
const b64 = record.b64_json ?? record.base64 ?? record.image_base64;
|
|
if (typeof b64 === "string" && b64.length > 0) {
|
|
return b64.startsWith("data:") ? b64 : `data:image/png;base64,${b64}`;
|
|
}
|
|
|
|
const nestedImageUrl = record.image_url;
|
|
if (nestedImageUrl && typeof nestedImageUrl === "object") {
|
|
const url = (nestedImageUrl as Record<string, unknown>).url;
|
|
const found = getImageUrlCandidate(url);
|
|
if (found) return found;
|
|
}
|
|
|
|
const candidates = [
|
|
record.url,
|
|
record.image,
|
|
record.image_url,
|
|
record.output_image_url,
|
|
record.result_url,
|
|
record.content,
|
|
record.text,
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === "string") {
|
|
const found = getImageUrlCandidate(candidate) ?? extractImageFromText(candidate);
|
|
if (found) return found;
|
|
} else {
|
|
const found = extractNewApiWGImage(candidate);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
|
|
for (const nested of Object.values(record)) {
|
|
const found = extractNewApiWGImage(nested);
|
|
if (found) return found;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const MISSING_TEMPORARY_REFERENCE_IMAGE_ERROR =
|
|
"Local temporary reference image was not found. Please retry the generation.";
|
|
|
|
function isTemporaryImageRouteUrl(image: string): boolean {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(image);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
!parsed.search &&
|
|
!parsed.hash &&
|
|
/^\/api\/images\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(parsed.pathname)
|
|
);
|
|
}
|
|
|
|
function prepareNewApiWGTemporaryMediaUrl(image: string): string {
|
|
const temporaryDataUrl = resolveTemporaryImageUrlToDataUrl(image);
|
|
if (temporaryDataUrl) return temporaryDataUrl;
|
|
if (isTemporaryImageRouteUrl(image)) {
|
|
throw new Error(MISSING_TEMPORARY_REFERENCE_IMAGE_ERROR);
|
|
}
|
|
return image;
|
|
}
|
|
|
|
function prepareNewApiWGTemporaryMediaInputs(
|
|
inputs: Record<string, string | string[]>
|
|
): Record<string, string | string[]> {
|
|
const prepared: Record<string, string | string[]> = {};
|
|
for (const [key, value] of Object.entries(inputs)) {
|
|
prepared[key] = Array.isArray(value)
|
|
? value.map(prepareNewApiWGTemporaryMediaUrl)
|
|
: prepareNewApiWGTemporaryMediaUrl(value);
|
|
}
|
|
return prepared;
|
|
}
|
|
|
|
function prepareOpenAIChatImageUrl(image: string): string {
|
|
return prepareNewApiWGTemporaryMediaUrl(image);
|
|
}
|
|
|
|
async function generateImageViaNewApiWGChat(
|
|
apiKey: string,
|
|
baseUrl: string,
|
|
input: GenerationInput,
|
|
prompt: string,
|
|
loginToken?: string | null
|
|
): Promise<GenerationOutput> {
|
|
let images = input.images || [];
|
|
try {
|
|
images = images.map(prepareOpenAIChatImageUrl);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to prepare reference image",
|
|
};
|
|
}
|
|
|
|
const content: string | Array<{ type: string; text?: string; image_url?: { url: string } }> =
|
|
images.length > 0
|
|
? [
|
|
{ type: "text", text: prompt },
|
|
...images.map((image) => ({ type: "image_url", image_url: { url: image } })),
|
|
]
|
|
: prompt;
|
|
|
|
const { response, data } = await fetchNewApiWGJsonWithRetry(`${baseUrl}/chat/completions`, {
|
|
method: "POST",
|
|
headers: buildGatewayHeaders(apiKey, loginToken, { "Content-Type": "application/json" }),
|
|
body: JSON.stringify({
|
|
model: input.model.id,
|
|
messages: [{ role: "user", content }],
|
|
stream: false,
|
|
...dynamicInputsWithoutPrompt(input),
|
|
...normalizeOpenAIChatImageParameters(input.model.id, input.parameters),
|
|
}),
|
|
}, input.model.name, "NewApiWG chat image API error");
|
|
|
|
if (!response.ok) {
|
|
const message = getNewApiWGErrorMessage(data, `NewApiWG chat image API error: ${response.status}`);
|
|
return { success: false, error: normalizeNewApiWGError(message, input.model.name) };
|
|
}
|
|
|
|
const image = extractNewApiWGImage(data);
|
|
if (!image) {
|
|
const text = data && typeof data === "object"
|
|
? firstString((data as Record<string, unknown>).choices)
|
|
: undefined;
|
|
return {
|
|
success: false,
|
|
error: typeof text === "string" && text.length > 0
|
|
? `NewApiWG image response did not include an image: ${text.slice(0, 240)}`
|
|
: "NewApiWG image response did not include an image",
|
|
};
|
|
}
|
|
|
|
if (image.startsWith("http")) {
|
|
return { success: true, outputs: [{ type: "image", data: "", url: image }] };
|
|
}
|
|
|
|
return { success: true, outputs: [{ type: "image", data: image }] };
|
|
}
|
|
|
|
function dataUrlToInlineData(image: string): { mimeType: string; data: string } | null {
|
|
const match = image.match(/^data:([^;,]+);base64,(.+)$/);
|
|
if (!match) return null;
|
|
return { mimeType: match[1], data: match[2] };
|
|
}
|
|
|
|
function isLoopbackTemporaryImageUrl(image: string): boolean {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(image);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
const isLoopbackHost =
|
|
hostname === "localhost" ||
|
|
hostname === "127.0.0.1" ||
|
|
hostname === "[::1]" ||
|
|
hostname === "::1";
|
|
|
|
return (
|
|
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
isLoopbackHost &&
|
|
isTemporaryImageRouteUrl(image)
|
|
);
|
|
}
|
|
|
|
async function imageUrlToInlineData(image: string): Promise<{ mimeType: string; data: string } | null> {
|
|
if (!isHttpUrl(image)) return null;
|
|
|
|
const temporaryDataUrl = resolveTemporaryImageUrlToDataUrl(image);
|
|
const temporaryInlineData = temporaryDataUrl ? dataUrlToInlineData(temporaryDataUrl) : null;
|
|
if (temporaryInlineData) return temporaryInlineData;
|
|
|
|
const isTrustedTemporaryUrl = isLoopbackTemporaryImageUrl(image);
|
|
const urlCheck = validateMediaUrl(image);
|
|
if (!urlCheck.valid && !isTrustedTemporaryUrl) {
|
|
throw new Error(`Blocked reference image URL: ${urlCheck.error}`);
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), REFERENCE_IMAGE_FETCH_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(image, { signal: controller.signal });
|
|
if (!response.ok) throw new Error(`Failed to fetch reference image: ${response.status}`);
|
|
|
|
const contentLength = response.headers.get("content-length");
|
|
if (contentLength && Number(contentLength) > MAX_REFERENCE_IMAGE_BYTES) {
|
|
throw new Error(`Reference image exceeds maximum size ${MAX_REFERENCE_IMAGE_BYTES} bytes`);
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim();
|
|
if (contentType && !contentType.startsWith("image/")) {
|
|
throw new Error(`Reference URL did not return an image: ${contentType}`);
|
|
}
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
if (buffer.byteLength > MAX_REFERENCE_IMAGE_BYTES) {
|
|
throw new Error(`Reference image exceeds maximum size ${MAX_REFERENCE_IMAGE_BYTES} bytes`);
|
|
}
|
|
return { mimeType: contentType || "image/png", data: buffer.toString("base64") };
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
async function imageToInlineData(image: string): Promise<{ mimeType: string; data: string } | null> {
|
|
return dataUrlToInlineData(image) ?? imageUrlToInlineData(image);
|
|
}
|
|
|
|
function extractGeminiNativeImage(value: unknown): string | null {
|
|
const direct = extractNewApiWGImage(value);
|
|
if (direct) return direct;
|
|
if (!value || typeof value !== "object") return null;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = extractGeminiNativeImage(item);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const record = value as Record<string, unknown>;
|
|
const inlineData = record.inlineData ?? record.inline_data;
|
|
if (inlineData && typeof inlineData === "object") {
|
|
const inline = inlineData as Record<string, unknown>;
|
|
if (typeof inline.data === "string" && inline.data.length > 0) {
|
|
const mimeType = typeof inline.mimeType === "string"
|
|
? inline.mimeType
|
|
: typeof inline.mime_type === "string"
|
|
? inline.mime_type
|
|
: "image/png";
|
|
return inline.data.startsWith("data:") ? inline.data : `data:${mimeType};base64,${inline.data}`;
|
|
}
|
|
}
|
|
|
|
const fileData = record.fileData ?? record.file_data;
|
|
if (fileData && typeof fileData === "object") {
|
|
const file = fileData as Record<string, unknown>;
|
|
const fileUri = file.fileUri ?? file.file_uri ?? file.uri ?? file.url;
|
|
const found = getImageUrlCandidate(fileUri);
|
|
if (found) return found;
|
|
}
|
|
|
|
for (const nested of Object.values(record)) {
|
|
const found = extractGeminiNativeImage(nested);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function generateImageViaNewApiWGGemini(
|
|
apiKey: string,
|
|
baseUrl: string,
|
|
input: GenerationInput,
|
|
prompt: string,
|
|
loginToken?: string | null
|
|
): Promise<GenerationOutput> {
|
|
const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: prompt }];
|
|
for (const image of input.images || []) {
|
|
let inlineData: { mimeType: string; data: string } | null;
|
|
try {
|
|
inlineData = await imageToInlineData(image);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to prepare reference image",
|
|
};
|
|
}
|
|
if (inlineData) parts.push({ inlineData });
|
|
}
|
|
|
|
const aspectRatio =
|
|
typeof input.parameters?.aspectRatio === "string"
|
|
? input.parameters.aspectRatio
|
|
: typeof input.parameters?.aspect_ratio === "string"
|
|
? input.parameters.aspect_ratio
|
|
: "1:1";
|
|
const imageSize =
|
|
typeof input.parameters?.imageSize === "string"
|
|
? input.parameters.imageSize
|
|
: typeof input.parameters?.resolution === "string"
|
|
? input.parameters.resolution
|
|
: "2K";
|
|
const modelName = toGeminiNativeModelName(input.model.id);
|
|
|
|
const { response, data } = await fetchNewApiWGJsonWithRetry(`${normalizeNewApiWGGeminiBaseUrl(baseUrl)}/models/${encodeURIComponent(modelName)}:generateContent`, {
|
|
method: "POST",
|
|
headers: buildGatewayHeaders(apiKey, loginToken, { "Content-Type": "application/json" }),
|
|
body: JSON.stringify({
|
|
contents: [{ parts }],
|
|
generationConfig: {
|
|
responseModalities: ["IMAGE"],
|
|
imageConfig: {
|
|
aspectRatio,
|
|
imageSize,
|
|
},
|
|
responseFormat: {
|
|
image: {
|
|
aspectRatio,
|
|
imageSize,
|
|
},
|
|
},
|
|
},
|
|
...dynamicInputsWithoutPrompt(input),
|
|
}),
|
|
}, input.model.name, "NewApiWG Gemini image API error");
|
|
|
|
if (!response.ok) {
|
|
const message = getNewApiWGErrorMessage(data, `NewApiWG Gemini image API error: ${response.status}`);
|
|
return { success: false, error: normalizeNewApiWGError(message, input.model.name) };
|
|
}
|
|
|
|
const image = extractGeminiNativeImage(data);
|
|
if (!image) return { success: false, error: "NewApiWG Gemini image response did not include an image" };
|
|
return image.startsWith("http")
|
|
? { success: true, outputs: [{ type: "image", data: "", url: image }] }
|
|
: { success: true, outputs: [{ type: "image", data: image }] };
|
|
}
|
|
|
|
export async function generateWithNewApiWG(
|
|
apiKey: string,
|
|
baseUrl: string | null,
|
|
input: GenerationInput,
|
|
loginToken?: string | null
|
|
): Promise<GenerationOutput> {
|
|
const normalizedBaseUrl = normalizeNewApiWGBaseUrl(baseUrl);
|
|
if (isDoubaoChatModelName(`${input.model.id} ${input.model.name}`)) {
|
|
return {
|
|
success: false,
|
|
error: `${input.model.name}: this is a text/chat model, not an image, video, audio, or 3D generation model. Choose a generation model such as PopiArt Nano Pro, Seedream, or Seedance.`,
|
|
};
|
|
}
|
|
|
|
const isVideo = input.model.capabilities.some((cap) => cap.includes("video"));
|
|
const isAudio = !isVideo && input.model.capabilities.includes("text-to-audio");
|
|
const prompt = promptFromInput(input);
|
|
|
|
if (isAudio) {
|
|
const audioUrl = `${normalizedBaseUrl}/audio/speech`;
|
|
const audioInit: RequestInit = {
|
|
method: "POST",
|
|
headers: buildGatewayHeaders(apiKey, loginToken, { "Content-Type": "application/json" }),
|
|
body: JSON.stringify({
|
|
model: input.model.id,
|
|
input: prompt,
|
|
voice: typeof input.parameters?.voice === "string" ? input.parameters.voice : "alloy",
|
|
...dynamicInputsWithoutPrompt(input),
|
|
...(input.parameters || {}),
|
|
}),
|
|
};
|
|
const context = {
|
|
modelName: input.model.name,
|
|
};
|
|
const startedAt = Date.now();
|
|
logGatewayRequest("Calling NewApiWG audio API", audioUrl, audioInit, context);
|
|
const response = await fetch(audioUrl, audioInit);
|
|
await logGatewayResponse("NewApiWG audio API response", audioUrl, audioInit, response, context, startedAt);
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => "");
|
|
let message = text || `NewApiWG audio API error: ${response.status}`;
|
|
try {
|
|
message = getNewApiWGErrorMessage(JSON.parse(text), message);
|
|
} catch {
|
|
// Keep the plain text response.
|
|
}
|
|
return { success: false, error: normalizeNewApiWGError(message, input.model.name) };
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type") || "audio/mpeg";
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
return {
|
|
success: true,
|
|
outputs: [{ type: "audio", data: `data:${contentType};base64,${buffer.toString("base64")}` }],
|
|
};
|
|
}
|
|
|
|
if (!isVideo && !isAudio && isGeminiNativeImageModel(input.model.id)) {
|
|
return generateImageViaNewApiWGGemini(apiKey, normalizedBaseUrl, input, prompt, loginToken);
|
|
}
|
|
|
|
if (!isVideo && !isAudio && isOpenAIChatImageModel(input.model.id)) {
|
|
return generateImageViaNewApiWGChat(apiKey, normalizedBaseUrl, input, prompt, loginToken);
|
|
}
|
|
|
|
const endpoint = `${normalizedBaseUrl}/${isVideo ? "video" : "images"}/generations`;
|
|
const imageSize = resolveNewApiWGImageSize(input.parameters, input.model.id);
|
|
const imageParameters = normalizeNewApiWGImageGenerationParameters(input.parameters);
|
|
const rawDynamicInputs = dynamicInputsWithoutPrompt(input);
|
|
const normalizedVideoInputs = isVideo
|
|
? normalizeNewApiWGVideoInputs(input, rawDynamicInputs)
|
|
: null;
|
|
let normalizedDynamicInputs: Record<string, string | string[]>;
|
|
let mediaInputs: Record<string, string | string[]>;
|
|
try {
|
|
normalizedDynamicInputs = prepareNewApiWGTemporaryMediaInputs(
|
|
normalizedVideoInputs?.dynamicInputs ?? rawDynamicInputs
|
|
);
|
|
mediaInputs = prepareNewApiWGTemporaryMediaInputs(
|
|
normalizedVideoInputs?.mediaInputs ?? buildNewApiWGImageInputs(input)
|
|
);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to prepare reference media",
|
|
};
|
|
}
|
|
|
|
const { response, data } = await fetchNewApiWGJsonWithRetry(endpoint, {
|
|
method: "POST",
|
|
headers: buildGatewayHeaders(apiKey, loginToken, { "Content-Type": "application/json" }),
|
|
body: JSON.stringify({
|
|
model: input.model.id,
|
|
prompt,
|
|
n: 1,
|
|
...normalizedDynamicInputs,
|
|
...mediaInputs,
|
|
...(!isVideo ? { size: imageSize, response_format: "b64_json" } : {}),
|
|
...(isVideo ? sanitizeNewApiWGVideoParameters(input.model, input.parameters) : imageParameters),
|
|
}),
|
|
}, input.model.name, `NewApiWG ${isVideo ? "video" : "image"} API error`);
|
|
|
|
if (!response.ok) {
|
|
const message = getNewApiWGErrorMessage(data, `NewApiWG ${isVideo ? "video" : "image"} API error: ${response.status}`);
|
|
return { success: false, error: normalizeNewApiWGError(message, input.model.name) };
|
|
}
|
|
|
|
if (isVideo) {
|
|
const immediateUrl = getNewApiWGVideoUrl(data);
|
|
if (immediateUrl) {
|
|
return { success: true, outputs: [{ type: "video", data: "", url: immediateUrl }] };
|
|
}
|
|
|
|
const taskId = getNewApiWGTaskId(data);
|
|
if (taskId) {
|
|
return pollNewApiWGVideo(apiKey, normalizedBaseUrl, taskId, loginToken);
|
|
}
|
|
}
|
|
|
|
const dataRecord = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
const dataItems = Array.isArray(dataRecord.data) ? dataRecord.data : [];
|
|
const imageItems = Array.isArray(dataRecord.images) ? dataRecord.images : [];
|
|
const videoItems = Array.isArray(dataRecord.videos) ? dataRecord.videos : [];
|
|
const outputItems = Array.isArray(dataRecord.output) ? dataRecord.output : [];
|
|
const first = dataItems[0] ?? imageItems[0] ?? videoItems[0] ?? outputItems[0] ?? data;
|
|
const firstRecord = first && typeof first === "object" ? first as Record<string, unknown> : {};
|
|
const b64 = firstRecord.b64_json || firstRecord.base64 || firstRecord.image_base64;
|
|
const url = firstRecord.url || firstRecord.image_url || firstRecord.video_url || firstString(first);
|
|
|
|
if (typeof b64 === "string" && b64.length > 0) {
|
|
const mime = isVideo ? "video/mp4" : "image/png";
|
|
const prefix = b64.startsWith("data:") ? "" : `data:${mime};base64,`;
|
|
return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: `${prefix}${b64}` }] };
|
|
}
|
|
|
|
if (typeof url === "string" && url.length > 0) {
|
|
if (url.startsWith("data:")) {
|
|
return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: url }] };
|
|
}
|
|
if (isHttpUrl(url)) {
|
|
return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: "", url }] };
|
|
}
|
|
}
|
|
|
|
return { success: false, error: `No ${isVideo ? "video" : "image"} returned from NewApiWG` };
|
|
}
|
|
|