Compare commits
7 Commits
608bd25679
...
002ab8b98d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002ab8b98d | ||
|
|
c69ddb1bb3 | ||
|
|
01b3f6eec1 | ||
|
|
6a30795bb8 | ||
|
|
0a364f537f | ||
|
|
d133a48866 | ||
|
|
434d0a63cd |
@ -1,7 +1,9 @@
|
||||
import "server-only";
|
||||
|
||||
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import sharp from "sharp";
|
||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||
import {
|
||||
bindVip98kImages,
|
||||
selectVip98kTaskInvocation,
|
||||
@ -9,9 +11,21 @@ import {
|
||||
} from "@/lib/ai/vip98k-task-invocation";
|
||||
import { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
|
||||
import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery";
|
||||
import {
|
||||
addVip98kReferenceRetryToken,
|
||||
getStorageObjectKeyFromPublicUrl,
|
||||
getVip98kStagedMediaRetryDelay,
|
||||
shouldStageVip98kReferenceUrl,
|
||||
} from "@/lib/ai/vip98k-reference-staging";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
buildS3PublicUrl,
|
||||
createS3Client,
|
||||
type S3UploadConfig,
|
||||
toDbS3UploadConfig,
|
||||
uploadFile,
|
||||
} from "@/lib/storage/service";
|
||||
|
||||
const logger = createLogger("vip98k-provider");
|
||||
|
||||
@ -48,6 +62,21 @@ type UploadedImage = {
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 8;
|
||||
const DEFAULT_MAX_POLL_SECONDS = 600;
|
||||
const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
|
||||
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const MAX_REFERENCE_DOWNLOAD_BYTES = 40 * 1024 * 1024;
|
||||
const REFERENCE_STAGING_CACHE_MS = 60_000;
|
||||
const REFERENCE_STAGING_KEY_PREFIX = "uploads/vip98k-";
|
||||
|
||||
type ReferenceStagingConfig = {
|
||||
sourcePublicUrl: string;
|
||||
storageConfig: S3UploadConfig;
|
||||
};
|
||||
|
||||
let referenceStagingConfigCache:
|
||||
| { expiresAt: number; value: ReferenceStagingConfig | null }
|
||||
| undefined;
|
||||
const referenceStagingTasks = new Map<string, Promise<string>>();
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
@ -435,13 +464,258 @@ async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
|
||||
);
|
||||
}
|
||||
|
||||
async function ensurePublicMediaUrl(mediaUrl: string) {
|
||||
async function resolveReferenceStagingConfig() {
|
||||
const now = Date.now();
|
||||
if (referenceStagingConfigCache?.expiresAt && referenceStagingConfigCache.expiresAt > now) {
|
||||
return referenceStagingConfigCache.value;
|
||||
}
|
||||
|
||||
let value: ReferenceStagingConfig | null = null;
|
||||
try {
|
||||
const sourceStorage = await getDefaultStorageConfig();
|
||||
const storageConfig = toDbS3UploadConfig(sourceStorage);
|
||||
if (sourceStorage?.publicUrl && storageConfig) {
|
||||
value = {
|
||||
sourcePublicUrl: sourceStorage.publicUrl,
|
||||
storageConfig,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("reference staging config lookup failed", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
referenceStagingConfigCache = {
|
||||
expiresAt: now + REFERENCE_STAGING_CACHE_MS,
|
||||
value,
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
function getUrlHost(value: string) {
|
||||
try {
|
||||
return new URL(value).host;
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
function isStagedReferenceUrl(value: string) {
|
||||
try {
|
||||
return new URL(value).pathname.startsWith(`/${REFERENCE_STAGING_KEY_PREFIX}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadReferenceImage(mediaUrl: string) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(mediaUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
|
||||
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`source returned HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (
|
||||
Number.isFinite(declaredLength) &&
|
||||
declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES
|
||||
) {
|
||||
throw new Error("source image exceeds the staging size limit");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
|
||||
throw new Error("source image exceeds the staging size limit");
|
||||
}
|
||||
if (
|
||||
Number.isFinite(declaredLength) &&
|
||||
declaredLength > 0 &&
|
||||
buffer.byteLength !== declaredLength
|
||||
) {
|
||||
throw new Error("source image download was incomplete");
|
||||
}
|
||||
return {
|
||||
buffer,
|
||||
mimeType: response.headers.get("content-type") || "image/png",
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.warn("reference staging download attempt failed", {
|
||||
attempt,
|
||||
sourceHost: getUrlHost(mediaUrl),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (attempt < REFERENCE_DOWNLOAD_ATTEMPTS) await delay(attempt * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`VIP98K reference staging failed: ${
|
||||
lastError instanceof Error ? lastError.message : "source download failed"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadReferenceImageFromStorage(
|
||||
mediaUrl: string,
|
||||
stagingConfig: ReferenceStagingConfig,
|
||||
) {
|
||||
const objectKey = getStorageObjectKeyFromPublicUrl(
|
||||
mediaUrl,
|
||||
stagingConfig.sourcePublicUrl,
|
||||
);
|
||||
if (!objectKey) return null;
|
||||
|
||||
try {
|
||||
const response = await createS3Client(stagingConfig.storageConfig).send(
|
||||
new GetObjectCommand({
|
||||
Bucket: stagingConfig.storageConfig.bucket,
|
||||
Key: objectKey,
|
||||
}),
|
||||
{ abortSignal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS) },
|
||||
);
|
||||
if (!response.Body) throw new Error("source object body is empty");
|
||||
if (
|
||||
typeof response.ContentLength === "number" &&
|
||||
response.ContentLength > MAX_REFERENCE_DOWNLOAD_BYTES
|
||||
) {
|
||||
throw new Error("source image exceeds the staging size limit");
|
||||
}
|
||||
const buffer = Buffer.from(await response.Body.transformToByteArray());
|
||||
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
|
||||
throw new Error("source image exceeds the staging size limit");
|
||||
}
|
||||
if (
|
||||
typeof response.ContentLength === "number" &&
|
||||
response.ContentLength > 0 &&
|
||||
buffer.byteLength !== response.ContentLength
|
||||
) {
|
||||
throw new Error("source object download was incomplete");
|
||||
}
|
||||
return {
|
||||
buffer,
|
||||
mimeType: response.ContentType || "image/png",
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("reference source-storage download failed; retrying public URL", {
|
||||
sourceHost: getUrlHost(mediaUrl),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadReferenceImage(
|
||||
buffer: Buffer,
|
||||
stagingConfig: ReferenceStagingConfig,
|
||||
) {
|
||||
const key = `${REFERENCE_STAGING_KEY_PREFIX}${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.jpg`;
|
||||
await createS3Client(stagingConfig.storageConfig).send(
|
||||
new PutObjectCommand({
|
||||
Bucket: stagingConfig.storageConfig.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: "image/jpeg",
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
...(stagingConfig.storageConfig.provider === "qiniu-kodo"
|
||||
? {}
|
||||
: { ACL: "public-read" as const }),
|
||||
}),
|
||||
{ abortSignal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS) },
|
||||
);
|
||||
return buildS3PublicUrl(stagingConfig.storageConfig, key);
|
||||
}
|
||||
|
||||
async function normalizeReferenceImage(buffer: Buffer, mimeType: string) {
|
||||
try {
|
||||
const normalized = await sharp(buffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(1920, 1920, { fit: "inside", withoutEnlargement: true })
|
||||
.flatten({ background: "#ffffff" })
|
||||
.jpeg({ quality: 92, chromaSubsampling: "4:4:4", mozjpeg: true })
|
||||
.toBuffer();
|
||||
return {
|
||||
buffer: normalized,
|
||||
extension: "jpg",
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("reference image normalization failed; preserving original bytes", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { buffer, extension: detectExtension(mimeType) };
|
||||
}
|
||||
}
|
||||
|
||||
async function stageReferenceImage(
|
||||
mediaUrl: string,
|
||||
stagingConfig: ReferenceStagingConfig,
|
||||
) {
|
||||
const downloaded =
|
||||
(await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
|
||||
(await downloadReferenceImage(mediaUrl));
|
||||
const normalized = await normalizeReferenceImage(
|
||||
downloaded.buffer,
|
||||
downloaded.mimeType,
|
||||
);
|
||||
const uploadedUrl = await uploadReferenceImage(normalized.buffer, stagingConfig);
|
||||
logger.info("reference image staged", {
|
||||
sourceHost: getUrlHost(mediaUrl),
|
||||
targetHost: getUrlHost(uploadedUrl),
|
||||
sourceBytes: downloaded.buffer.byteLength,
|
||||
stagedBytes: normalized.buffer.byteLength,
|
||||
});
|
||||
return uploadedUrl;
|
||||
}
|
||||
|
||||
async function ensurePublicMediaUrl(mediaUrl: string, mediaKind: "image" | "video") {
|
||||
const normalized = String(mediaUrl || "").trim();
|
||||
if (/^https?:\/\//i.test(normalized)) return normalized;
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
if (mediaKind !== "image") return normalized;
|
||||
const stagingConfig = await resolveReferenceStagingConfig();
|
||||
const sourceObjectKey = stagingConfig
|
||||
? getStorageObjectKeyFromPublicUrl(normalized, stagingConfig.sourcePublicUrl)
|
||||
: null;
|
||||
if (
|
||||
!stagingConfig ||
|
||||
sourceObjectKey?.startsWith(REFERENCE_STAGING_KEY_PREFIX) ||
|
||||
!shouldStageVip98kReferenceUrl({
|
||||
mediaUrl: normalized,
|
||||
sourcePublicUrl: stagingConfig.sourcePublicUrl,
|
||||
})
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const existingTask = referenceStagingTasks.get(normalized);
|
||||
if (existingTask) return existingTask;
|
||||
const task = stageReferenceImage(normalized, stagingConfig).finally(() => {
|
||||
referenceStagingTasks.delete(normalized);
|
||||
});
|
||||
referenceStagingTasks.set(normalized, task);
|
||||
return task;
|
||||
}
|
||||
if (!normalized.startsWith("data:")) return normalized;
|
||||
const parsed = parseDataUrl(normalized);
|
||||
const extension = detectExtension(parsed.mimeType);
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const stagingConfig = await resolveReferenceStagingConfig();
|
||||
if (stagingConfig && mediaKind === "image") {
|
||||
const normalizedImage = await normalizeReferenceImage(
|
||||
parsed.buffer,
|
||||
parsed.mimeType,
|
||||
);
|
||||
return uploadReferenceImage(normalizedImage.buffer, stagingConfig);
|
||||
}
|
||||
const uploaded = await uploadFile(
|
||||
`vip98k-ref-${suffix}.${extension}`,
|
||||
toArrayBuffer(parsed.buffer),
|
||||
@ -527,25 +801,69 @@ async function submitTask(params: {
|
||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||
const path = String(params.path || "/api/external/task/generate").trim();
|
||||
if (!path.startsWith("/")) throw new Error("VIP98K invocation path is invalid");
|
||||
const response = await fetch(`${baseURL}${path}`, {
|
||||
method: String(params.method || "POST").toUpperCase(),
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": params.contentType || "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
const stagedReferences = Array.isArray(params.body.file_urls)
|
||||
? params.body.file_urls.filter(
|
||||
(value): value is string =>
|
||||
typeof value === "string" && isStagedReferenceUrl(value),
|
||||
)
|
||||
: [];
|
||||
const maxAttempts = stagedReferences.length > 0 ? 3 : 1;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const requestBody =
|
||||
attempt === 1 || !Array.isArray(params.body.file_urls)
|
||||
? params.body
|
||||
: {
|
||||
...params.body,
|
||||
file_urls: params.body.file_urls.map((value) =>
|
||||
typeof value === "string" && isStagedReferenceUrl(value)
|
||||
? addVip98kReferenceRetryToken(
|
||||
value,
|
||||
`${Date.now()}-${attempt}`,
|
||||
)
|
||||
: value,
|
||||
),
|
||||
};
|
||||
const response = await fetch(`${baseURL}${path}`, {
|
||||
method: String(params.method || "POST").toUpperCase(),
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": params.contentType || "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (response.ok) {
|
||||
return {
|
||||
payload,
|
||||
taskId: extractTaskId(payload),
|
||||
resultPath: extractResultPath(payload),
|
||||
outputs: collectOutputUrls(payload),
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = extractErrorMessage(payload);
|
||||
const retryDelayMs = getVip98kStagedMediaRetryDelay({
|
||||
status: response.status,
|
||||
message: errorMessage,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
stagedReferenceCount: stagedReferences.length,
|
||||
});
|
||||
if (retryDelayMs !== null) {
|
||||
logger.warn("staged reference not ready for provider; retrying submission", {
|
||||
attempt,
|
||||
retryDelayMs,
|
||||
stagedReferenceCount: stagedReferences.length,
|
||||
});
|
||||
await delay(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
||||
}
|
||||
return {
|
||||
payload,
|
||||
taskId: extractTaskId(payload),
|
||||
resultPath: extractResultPath(payload),
|
||||
outputs: collectOutputUrls(payload),
|
||||
};
|
||||
|
||||
throw new Error("VIP98K request failed after media readiness retries");
|
||||
}
|
||||
|
||||
export type Vip98kExternalTaskSubmission = {
|
||||
@ -577,8 +895,12 @@ export async function submitVip98kExternalTask(params: {
|
||||
modelConfig?: Vip98kModelRuntimeConfig;
|
||||
}): Promise<Vip98kExternalTaskSubmission> {
|
||||
const [imageUrls, videoUrls] = await Promise.all([
|
||||
Promise.all((params.imageUrls || []).map(ensurePublicMediaUrl)),
|
||||
Promise.all((params.videoUrls || []).map(ensurePublicMediaUrl)),
|
||||
Promise.all(
|
||||
(params.imageUrls || []).map((url) => ensurePublicMediaUrl(url, "image")),
|
||||
),
|
||||
Promise.all(
|
||||
(params.videoUrls || []).map((url) => ensurePublicMediaUrl(url, "video")),
|
||||
),
|
||||
]);
|
||||
const taskParams: Vip98kTaskParams = {
|
||||
apiKey: params.apiKey,
|
||||
@ -992,8 +1314,12 @@ export function createVip98k({ apiKey, baseURL }: Vip98kConfig) {
|
||||
const activeKind = inferKind(activeModelId, runtimeConfig);
|
||||
const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(options.messages);
|
||||
const [publicImageUrls, publicVideoUrls] = await Promise.all([
|
||||
Promise.all(imageUrls.map(ensurePublicMediaUrl)),
|
||||
Promise.all(videoUrls.map(ensurePublicMediaUrl)),
|
||||
Promise.all(
|
||||
imageUrls.map((url) => ensurePublicMediaUrl(url, "image")),
|
||||
),
|
||||
Promise.all(
|
||||
videoUrls.map((url) => ensurePublicMediaUrl(url, "video")),
|
||||
),
|
||||
]);
|
||||
const text = await runTask({
|
||||
apiKey,
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
aiModelHealthEvent,
|
||||
type AiModel,
|
||||
} from "@/lib/db/schema";
|
||||
import {
|
||||
isMediaInputFailure,
|
||||
isNonProviderRequestStatus,
|
||||
} from "@/lib/ai/runtime/request-failure-classification";
|
||||
|
||||
export type AiModelHealthEventType =
|
||||
| "success"
|
||||
@ -173,6 +177,15 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
||||
};
|
||||
}
|
||||
|
||||
if (isMediaInputFailure(normalized)) {
|
||||
return {
|
||||
eventType: "error",
|
||||
severity: "warning",
|
||||
counted: false,
|
||||
reason: "request_media_rejected",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("fetch failed") ||
|
||||
normalized.includes("econnreset") ||
|
||||
@ -226,6 +239,15 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonProviderRequestStatus(httpStatus, normalized)) {
|
||||
return {
|
||||
eventType: "error",
|
||||
severity: "warning",
|
||||
counted: false,
|
||||
reason: "request_input_rejected",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isOperationalAbort(error, normalized) ||
|
||||
normalized.includes("timeout") ||
|
||||
|
||||
24
lib/ai/runtime/request-failure-classification.ts
Normal file
24
lib/ai/runtime/request-failure-classification.ts
Normal file
@ -0,0 +1,24 @@
|
||||
const NON_PROVIDER_REQUEST_STATUSES = new Set([400, 413, 415, 422]);
|
||||
|
||||
export function isMediaInputFailure(message: string) {
|
||||
const normalized = String(message || "").trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("media request is invalid") ||
|
||||
normalized.includes("invalid media request") ||
|
||||
normalized.includes("invalid input media") ||
|
||||
normalized.includes("unsupported media type") ||
|
||||
normalized.includes("vip98k reference staging failed")
|
||||
);
|
||||
}
|
||||
|
||||
export function isNonProviderRequestStatus(
|
||||
httpStatus: number | null,
|
||||
message: string,
|
||||
) {
|
||||
if (httpStatus !== null && NON_PROVIDER_REQUEST_STATUSES.has(httpStatus)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = String(message || "").trim();
|
||||
return /(?:^|\D)(?:400|413|415|422)(?:\D|$)/.test(normalized);
|
||||
}
|
||||
@ -181,6 +181,7 @@ const IMAGE_TOOL_OPERATION_BY_MODEL_ID: Record<string, string> = {
|
||||
// available to backend routing but remain hidden until their 2026+ release is verified.
|
||||
const VERIFIED_2026_IMAGE_MODEL_PATTERNS = [
|
||||
/^nano-banana-2(?:$|[-/])/,
|
||||
/^nano-banana-pro$/,
|
||||
/^gpt-image-2(?:$|[-/])/,
|
||||
/^alibaba\/wan-2\.(?:6|7)(?:$|[-/])/,
|
||||
/^black-forest-labs\/flux-2(?:$|[-/])/,
|
||||
|
||||
78
lib/ai/vip98k-reference-staging.ts
Normal file
78
lib/ai/vip98k-reference-staging.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { isMediaInputFailure } from "@/lib/ai/runtime/request-failure-classification";
|
||||
|
||||
function parseUrl(value: string) {
|
||||
try {
|
||||
return new URL(String(value || "").trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isUrlWithinPublicBase(mediaUrl: string, publicBaseUrl: string) {
|
||||
const media = parseUrl(mediaUrl);
|
||||
const base = parseUrl(publicBaseUrl);
|
||||
if (!media || !base || media.host.toLowerCase() !== base.host.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const basePath = base.pathname.replace(/\/+$/, "");
|
||||
if (!basePath) return true;
|
||||
return media.pathname === basePath || media.pathname.startsWith(`${basePath}/`);
|
||||
}
|
||||
|
||||
export function shouldStageVip98kReferenceUrl(params: {
|
||||
mediaUrl: string;
|
||||
sourcePublicUrl: string;
|
||||
targetPublicUrl?: string;
|
||||
}) {
|
||||
if (
|
||||
params.targetPublicUrl &&
|
||||
isUrlWithinPublicBase(params.mediaUrl, params.targetPublicUrl)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isUrlWithinPublicBase(params.mediaUrl, params.sourcePublicUrl);
|
||||
}
|
||||
|
||||
export function getStorageObjectKeyFromPublicUrl(
|
||||
mediaUrl: string,
|
||||
publicBaseUrl: string,
|
||||
) {
|
||||
if (!isUrlWithinPublicBase(mediaUrl, publicBaseUrl)) return null;
|
||||
const media = parseUrl(mediaUrl);
|
||||
const base = parseUrl(publicBaseUrl);
|
||||
if (!media || !base) return null;
|
||||
const basePath = base.pathname.replace(/\/+$/, "");
|
||||
const encodedKey = media.pathname.slice(basePath.length).replace(/^\/+/, "");
|
||||
if (!encodedKey) return null;
|
||||
try {
|
||||
return decodeURIComponent(encodedKey);
|
||||
} catch {
|
||||
return encodedKey;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVip98kStagedMediaRetryDelay(params: {
|
||||
status: number;
|
||||
message: string;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
stagedReferenceCount: number;
|
||||
}) {
|
||||
if (
|
||||
params.status !== 400 ||
|
||||
params.stagedReferenceCount < 1 ||
|
||||
params.attempt >= params.maxAttempts ||
|
||||
!isMediaInputFailure(params.message)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return params.attempt * 20_000;
|
||||
}
|
||||
|
||||
export function addVip98kReferenceRetryToken(mediaUrl: string, token: string) {
|
||||
const parsed = parseUrl(mediaUrl);
|
||||
if (!parsed) return mediaUrl;
|
||||
parsed.searchParams.set("sg_media_retry", token);
|
||||
return parsed.toString();
|
||||
}
|
||||
@ -60,6 +60,27 @@ async function main() {
|
||||
}
|
||||
const vip98kProvider = vip98kProviders[0];
|
||||
if (!vip98kProvider) throw new Error("VIP98K provider is missing");
|
||||
const existingVip98kModels = await sql<
|
||||
Array<{
|
||||
id: string;
|
||||
modelId: string;
|
||||
healthStatus: string | null;
|
||||
}>
|
||||
>`
|
||||
select id, "modelId", "healthStatus"
|
||||
from "AiModel"
|
||||
where "providerId" = ${vip98kProvider.id}::uuid
|
||||
`;
|
||||
const existingVip98kByModelId = new Map(
|
||||
existingVip98kModels.map((model) => [model.modelId, model]),
|
||||
);
|
||||
const autoDisabledQualifiedModelIds = qualifiedVip98kPresets
|
||||
.filter(
|
||||
(preset) =>
|
||||
existingVip98kByModelId.get(preset.modelId)?.healthStatus ===
|
||||
"auto_disabled",
|
||||
)
|
||||
.map((preset) => preset.modelId);
|
||||
|
||||
const disallowedImageModels = await sql<
|
||||
Array<{ id: string; providerType: string; modelId: string }>
|
||||
@ -102,6 +123,7 @@ async function main() {
|
||||
mode: apply ? "apply" : "dry-run",
|
||||
allowedImageProviderTypes: ALLOWED_IMAGE_PROVIDER_TYPES,
|
||||
qualifiedVip98kModels: qualifiedVip98kPresets.map((preset) => preset.modelId),
|
||||
autoDisabledVip98kModelsPreserved: autoDisabledQualifiedModelIds,
|
||||
disallowedImageModelsToDisable: disallowedImageModels.length,
|
||||
staleVip98kImageModelsToDisable: staleVip98kImageModels.length,
|
||||
imageOnlyProvidersToDisable: imageOnlyProviders.map((provider) => ({
|
||||
@ -128,26 +150,26 @@ async function main() {
|
||||
`;
|
||||
|
||||
for (const preset of qualifiedVip98kPresets) {
|
||||
const preserveAutoDisabled = autoDisabledQualifiedModelIds.includes(
|
||||
preset.modelId,
|
||||
);
|
||||
const capabilities = tx.json(toJsonValue(preset.capabilities));
|
||||
const defaultParams = tx.json(toJsonValue(preset.defaultParams));
|
||||
const imageGenConfig = preset.imageGenConfig
|
||||
? tx.json(toJsonValue(preset.imageGenConfig))
|
||||
: null;
|
||||
const existing = await tx<Array<{ id: string }>>`
|
||||
select id from "AiModel"
|
||||
where "providerId" = ${vip98kProvider.id}::uuid and "modelId" = ${preset.modelId}
|
||||
order by "createdAt" limit 1
|
||||
`;
|
||||
if (existing[0]) {
|
||||
const existing = existingVip98kByModelId.get(preset.modelId);
|
||||
if (existing) {
|
||||
await tx`
|
||||
update "AiModel" set
|
||||
"displayName" = ${preset.displayName}, description = ${preset.description},
|
||||
"supportsVision" = ${preset.supportsVision}, "supportsImageGen" = true,
|
||||
"isEnabled" = true, "isHidden" = false, "sortOrder" = ${preset.sortOrder},
|
||||
"isEnabled" = ${!preserveAutoDisabled}, "isHidden" = false,
|
||||
"sortOrder" = ${preset.sortOrder},
|
||||
capabilities = ${capabilities}, "defaultParams" = ${defaultParams},
|
||||
"imageGenConfig" = ${imageGenConfig}, "fallbackModelId1" = null,
|
||||
"fallbackModelId2" = null, "updatedAt" = now()
|
||||
where id = ${existing[0].id}::uuid
|
||||
where id = ${existing.id}::uuid
|
||||
`;
|
||||
} else {
|
||||
await tx`
|
||||
@ -205,7 +227,9 @@ async function main() {
|
||||
if (invalidModels.length > 0) {
|
||||
throw new Error("Disallowed image models remain enabled after apply");
|
||||
}
|
||||
if (activeVip98kModels.length !== qualifiedVip98kPresets.length) {
|
||||
const expectedActiveVip98kCount =
|
||||
qualifiedVip98kPresets.length - autoDisabledQualifiedModelIds.length;
|
||||
if (activeVip98kModels.length !== expectedActiveVip98kCount) {
|
||||
throw new Error("VIP98K active image model count does not match qualified catalog");
|
||||
}
|
||||
console.log(
|
||||
|
||||
@ -11,10 +11,35 @@ const BASE_URL = String(process.env.VIP98K_BASE_URL || "https://api.vip98k.com")
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/v1$/i, "");
|
||||
const REFERENCE_URLS = [
|
||||
const DEFAULT_REFERENCE_URLS = [
|
||||
"https://dummyimage.com/512x512/2563eb/ffffff.png&text=BLUE+A",
|
||||
"https://dummyimage.com/512x512/dc2626/ffffff.png&text=RED+B",
|
||||
];
|
||||
const REFERENCE_URLS = (() => {
|
||||
const configured = String(process.env.VIP98K_IMAGE_TEST_REFERENCES || "").trim();
|
||||
if (!configured) return DEFAULT_REFERENCE_URLS;
|
||||
const urls = configured
|
||||
.split(/\r?\n/)
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url));
|
||||
if (urls.length < 2) {
|
||||
throw new Error("VIP98K_IMAGE_TEST_REFERENCES must contain at least two URLs");
|
||||
}
|
||||
return urls;
|
||||
})();
|
||||
const USES_CUSTOM_REFERENCES = Boolean(
|
||||
String(process.env.VIP98K_IMAGE_TEST_REFERENCES || "").trim(),
|
||||
);
|
||||
const MODEL_FILTER = new Set(
|
||||
String(process.env.VIP98K_IMAGE_TEST_MODELS || "")
|
||||
.split(",")
|
||||
.map((modelId) => modelId.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const TEST_PROMPT =
|
||||
String(process.env.VIP98K_IMAGE_TEST_PROMPT || "").trim() ||
|
||||
"Use BOTH references. Place the blue A card on the left and the red B card on the right. " +
|
||||
"Preserve both labels and colors in a landscape 16:9 composition at 1024x576.";
|
||||
const TERMINAL_STATUSES = new Set(["succeeded", "success", "completed", "done"]);
|
||||
const FAILURE_STATUSES = new Set(["failed", "error", "timeout", "canceled", "cancelled"]);
|
||||
|
||||
@ -162,12 +187,20 @@ async function pollTask(apiKey: string, taskId: string, resultPath: string) {
|
||||
throw new Error("VIP98K task did not finish within 15 minutes");
|
||||
}
|
||||
|
||||
async function analyzeImage(url: string) {
|
||||
async function analyzeImage(url: string, modelId: string) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (!response.ok) throw new Error(`Generated image download failed (HTTP ${response.status})`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const image = sharp(buffer).removeAlpha();
|
||||
const metadata = await image.metadata();
|
||||
const outputPath = resolve(
|
||||
".local",
|
||||
`vip98k-${modelId.replace(/[^a-z0-9._-]+/gi, "-")}-latest.${
|
||||
metadata.format === "jpeg" ? "jpg" : metadata.format || "png"
|
||||
}`,
|
||||
);
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, buffer);
|
||||
const { data, info } = await image.resize(256, 256, { fit: "fill" }).raw().toBuffer({
|
||||
resolveWithObject: true,
|
||||
});
|
||||
@ -188,6 +221,7 @@ async function analyzeImage(url: string) {
|
||||
blueShare: Number((bluePixels / pixels).toFixed(4)),
|
||||
redShare: Number((redPixels / pixels).toFixed(4)),
|
||||
bothReferencesDetected: bluePixels / pixels > 0.03 && redPixels / pixels > 0.03,
|
||||
outputPath,
|
||||
sha256: createHash("sha256").update(buffer).digest("hex"),
|
||||
};
|
||||
}
|
||||
@ -216,9 +250,7 @@ async function testModel(apiKey: string, model: JsonRecord) {
|
||||
const body = { ...asRecord(invocation.request_example) };
|
||||
body.model = modelId;
|
||||
body.file_urls = REFERENCE_URLS;
|
||||
body.prompt =
|
||||
"Use BOTH references. Place the blue A card on the left and the red B card on the right. " +
|
||||
"Preserve both labels and colors in a landscape 16:9 composition at 1024x576.";
|
||||
body.prompt = TEST_PROMPT;
|
||||
const submitted = await readJson(
|
||||
await fetch(`${BASE_URL}${invocation.path}`, {
|
||||
method: String(invocation.method || "POST").toUpperCase(),
|
||||
@ -238,11 +270,13 @@ async function testModel(apiKey: string, model: JsonRecord) {
|
||||
);
|
||||
if (!resultPath) throw new Error(`${modelId} publishes no result path`);
|
||||
const completed = await pollTask(apiKey, taskId, resultPath);
|
||||
const image = await analyzeImage(completed.outputUrl);
|
||||
const image = await analyzeImage(completed.outputUrl, modelId);
|
||||
const passed = USES_CUSTOM_REFERENCES || image.bothReferencesDetected;
|
||||
return {
|
||||
modelId,
|
||||
status: image.bothReferencesDetected ? "passed" : "failed",
|
||||
eligibleForMultiImageEditing: image.bothReferencesDetected,
|
||||
status: passed ? "passed" : "failed",
|
||||
eligibleForMultiImageEditing: passed,
|
||||
referenceVerification: USES_CUSTOM_REFERENCES ? "manual" : "color-marker",
|
||||
publishedRequestKeys: keys.sort(),
|
||||
requested: { referenceImageCount: 2, aspectRatio: "16:9", size: "1024x576" },
|
||||
nativeParameterSupport: { aspectRatio: nativeAspectRatio, size: nativeSize },
|
||||
@ -254,7 +288,14 @@ async function testModel(apiKey: string, model: JsonRecord) {
|
||||
async function main() {
|
||||
const apiKey = String(process.env.VIP98K_API_KEY || "").trim();
|
||||
if (!apiKey) throw new Error("VIP98K_API_KEY is required");
|
||||
const models = await fetchCatalog(apiKey);
|
||||
const authorizedModels = await fetchCatalog(apiKey);
|
||||
const models =
|
||||
MODEL_FILTER.size > 0
|
||||
? authorizedModels.filter((model: JsonRecord) =>
|
||||
MODEL_FILTER.has(String(model.id || "")),
|
||||
)
|
||||
: authorizedModels;
|
||||
if (models.length === 0) throw new Error("No authorized VIP98K image models matched");
|
||||
const results: unknown[] = [];
|
||||
for (const model of models) {
|
||||
try {
|
||||
@ -271,7 +312,8 @@ async function main() {
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseUrl: BASE_URL,
|
||||
authorizedImageModelCount: models.length,
|
||||
authorizedImageModelCount: authorizedModels.length,
|
||||
testedImageModelCount: models.length,
|
||||
results,
|
||||
};
|
||||
const outputPath = resolve(
|
||||
|
||||
24
tests/routes/model-health-classification.test.ts
Normal file
24
tests/routes/model-health-classification.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
isMediaInputFailure,
|
||||
isNonProviderRequestStatus,
|
||||
} from "@/lib/ai/runtime/request-failure-classification";
|
||||
|
||||
test.describe("AI model health request classification", () => {
|
||||
test("does not treat rejected media as a provider outage", () => {
|
||||
expect(
|
||||
isMediaInputFailure("The media request is invalid. (HTTP 400)"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMediaInputFailure("VIP98K reference staging failed: fetch failed"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes request-specific HTTP failures", () => {
|
||||
expect(isNonProviderRequestStatus(413, "payload too large")).toBe(true);
|
||||
expect(isNonProviderRequestStatus(null, "request failed (HTTP 422)")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isNonProviderRequestStatus(503, "service unavailable")).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -5,7 +5,16 @@ import {
|
||||
selectVip98kTaskInvocation,
|
||||
type Vip98kTaskInvocation,
|
||||
} from "@/lib/ai/vip98k-task-invocation";
|
||||
import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models";
|
||||
import {
|
||||
buildVip98kModelPresets,
|
||||
isVip98kImageModelVisible,
|
||||
} from "@/lib/ai/vip98k-models";
|
||||
import {
|
||||
addVip98kReferenceRetryToken,
|
||||
getStorageObjectKeyFromPublicUrl,
|
||||
getVip98kStagedMediaRetryDelay,
|
||||
shouldStageVip98kReferenceUrl,
|
||||
} from "@/lib/ai/vip98k-reference-staging";
|
||||
import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery";
|
||||
|
||||
const textToImageInvocation: Vip98kTaskInvocation = {
|
||||
@ -20,6 +29,74 @@ const textToImageInvocation: Vip98kTaskInvocation = {
|
||||
};
|
||||
|
||||
test.describe("VIP98K reference-image routing", () => {
|
||||
test("identifies default-storage references that need provider staging", () => {
|
||||
expect(
|
||||
shouldStageVip98kReferenceUrl({
|
||||
mediaUrl: "https://default.example/uploads/reference.jpg",
|
||||
sourcePublicUrl: "https://default.example",
|
||||
targetPublicUrl: "https://backup.example/project",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldStageVip98kReferenceUrl({
|
||||
mediaUrl: "https://backup.example/project/uploads/reference.jpg",
|
||||
sourcePublicUrl: "https://default.example",
|
||||
targetPublicUrl: "https://backup.example/project",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldStageVip98kReferenceUrl({
|
||||
mediaUrl: "https://external.example/reference.jpg",
|
||||
sourcePublicUrl: "https://default.example",
|
||||
targetPublicUrl: "https://backup.example/project",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getStorageObjectKeyFromPublicUrl(
|
||||
"https://default.example/assets/folder%20name/reference.jpg?token=secret",
|
||||
"https://default.example/assets",
|
||||
),
|
||||
).toBe("folder name/reference.jpg");
|
||||
});
|
||||
|
||||
test("retries transient media rejection while a staged URL propagates", () => {
|
||||
expect(
|
||||
getVip98kStagedMediaRetryDelay({
|
||||
status: 400,
|
||||
message: "The media request is invalid.",
|
||||
attempt: 1,
|
||||
maxAttempts: 3,
|
||||
stagedReferenceCount: 2,
|
||||
}),
|
||||
).toBe(20_000);
|
||||
expect(
|
||||
getVip98kStagedMediaRetryDelay({
|
||||
status: 400,
|
||||
message: "The media request is invalid.",
|
||||
attempt: 3,
|
||||
maxAttempts: 3,
|
||||
stagedReferenceCount: 2,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
addVip98kReferenceRetryToken(
|
||||
"https://cdn.example/reference.jpg?existing=1",
|
||||
"retry-2",
|
||||
),
|
||||
).toBe(
|
||||
"https://cdn.example/reference.jpg?existing=1&sg_media_retry=retry-2",
|
||||
);
|
||||
});
|
||||
|
||||
test("shows the newly published Nano Banana Pro image editor", () => {
|
||||
expect(
|
||||
isVip98kImageModelVisible({
|
||||
modelId: "nano-banana-pro",
|
||||
type: "image",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("replaces a text-to-image alias invocation with the resolved reference tool", () => {
|
||||
const invocation = resolveVip98kTaskInvocation(
|
||||
"google/nano-banana-2/reference-to-image",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user