diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 1e5ba33cb..4e250175b 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -13,6 +13,7 @@ import { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models" import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; import { getStorageObjectKeyFromPublicUrl, + getVip98kStagedMediaRetryDelay, shouldStageVip98kReferenceUrl, } from "@/lib/ai/vip98k-reference-staging"; import { createLogger } from "@/lib/observability/logger"; @@ -499,6 +500,14 @@ function getUrlHost(value: string) { } } +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) { @@ -791,25 +800,55 @@ 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 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) { + 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 = { diff --git a/lib/ai/vip98k-reference-staging.ts b/lib/ai/vip98k-reference-staging.ts index ee7d5e5a5..1c49e111e 100644 --- a/lib/ai/vip98k-reference-staging.ts +++ b/lib/ai/vip98k-reference-staging.ts @@ -1,3 +1,5 @@ +import { isMediaInputFailure } from "@/lib/ai/runtime/request-failure-classification"; + function parseUrl(value: string) { try { return new URL(String(value || "").trim()); @@ -49,3 +51,21 @@ export function getStorageObjectKeyFromPublicUrl( 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; +} diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 289cc289b..3776e08fb 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -9,8 +9,11 @@ import { buildVip98kModelPresets, isVip98kImageModelVisible, } from "@/lib/ai/vip98k-models"; -import { shouldStageVip98kReferenceUrl } from "@/lib/ai/vip98k-reference-staging"; -import { getStorageObjectKeyFromPublicUrl } from "@/lib/ai/vip98k-reference-staging"; +import { + getStorageObjectKeyFromPublicUrl, + getVip98kStagedMediaRetryDelay, + shouldStageVip98kReferenceUrl, +} from "@/lib/ai/vip98k-reference-staging"; import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; const textToImageInvocation: Vip98kTaskInvocation = { @@ -25,7 +28,7 @@ const textToImageInvocation: Vip98kTaskInvocation = { }; test.describe("VIP98K reference-image routing", () => { - test("stages references from the configured default storage into backup storage", () => { + test("identifies default-storage references that need provider staging", () => { expect( shouldStageVip98kReferenceUrl({ mediaUrl: "https://default.example/uploads/reference.jpg", @@ -55,6 +58,27 @@ test.describe("VIP98K reference-image routing", () => { ).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(); + }); + test("shows the newly published Nano Banana Pro image editor", () => { expect( isVip98kImageModelVisible({