From 86fd3353f432e36921b2bb0e5646d5a81f8519cb Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 13 Nov 2026 15:02:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E9=BD=9098K=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E5=B9=B6=E4=BF=9D=E7=95=99=E5=BE=85=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E4=BB=BB=E5=8A=A1=E7=BC=96=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ai/providers/vip98k.ts | 11 +++++- lib/ai/runtime/image-generation-error.ts | 4 ++ lib/ai/vip98k-models.ts | 3 +- lib/ai/vip98k-runtime-config.ts | 3 ++ lib/canvas/generate-image.ts | 5 ++- lib/video-canvas/video-task-lifecycle.ts | 4 +- tests/helpers/agent-image-provider-harness.ts | 5 ++- ...anvas-agent-runtime-image-failover.test.ts | 10 +++++ ...mage-provider-error-classification.test.ts | 38 ++++++++++++++++++- tests/routes/vip98k-reference-routing.test.ts | 12 ++++++ 10 files changed, 86 insertions(+), 9 deletions(-) diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 04c87148c..00bca41d0 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -36,6 +36,7 @@ import { normalizeVip98kRuntimeConfig, resolveVip98kSubmissionTimeoutMs, VIP98K_RUNTIME_CONFIG_KEY, + VIP98K_TASK_POLL_SECONDS, type Vip98kRuntimeConfig, } from "@/lib/ai/vip98k-runtime-config"; import { @@ -97,7 +98,7 @@ type UploadedImage = { const DOWNLOAD_TIMEOUT_MS = 120_000; const DEFAULT_POLL_INTERVAL_SECONDS = 8; -const DEFAULT_MAX_POLL_SECONDS = 600; +const DEFAULT_MAX_POLL_SECONDS = VIP98K_TASK_POLL_SECONDS; const REFERENCE_DOWNLOAD_ATTEMPTS = 3; const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; const REFERENCE_CDN_TIMEOUT_MS = 10_000; @@ -419,6 +420,12 @@ async function fetchTaskResult(params: { signal: AbortSignal.timeout(60_000), }); const payload = await parseJsonResponse(response); + const resultRecord = toRecord(unwrapData(payload)); + const resultError = toRecord(resultRecord?.error); + const resultCode = String(resultError?.code || resultRecord?.code || ""); + if (extractStatus(payload) === "pending_review" || + ["submission_pending_reconciliation", "request_outcome_unknown"].includes(resultCode)) + throw new ProviderTaskUncertainError("VIP98K original task outcome remains unknown", params.taskId); if (!response.ok) { logger.error("task result request failed", { taskId: params.taskId, @@ -1544,7 +1551,7 @@ async function waitForTask(params: { durationMs: Date.now() - startedAt, timeoutMs, }); - throw new Error("VIP98K task timed out; it may still be running"); + throw new ProviderTaskUncertainError("VIP98K task timed out; it may still be running", params.taskId); } function normalizeRatio(value: unknown) { diff --git a/lib/ai/runtime/image-generation-error.ts b/lib/ai/runtime/image-generation-error.ts index f0b433d76..e65b385ed 100644 --- a/lib/ai/runtime/image-generation-error.ts +++ b/lib/ai/runtime/image-generation-error.ts @@ -1,3 +1,5 @@ +import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle"; + /** Only adapters with an explicit rejection of the submission may use this. * Polling, result downloads and storage failures are not submission refusals. */ export class ImageSubmissionRejectedError extends Error { @@ -17,6 +19,8 @@ export class ImageReferenceReadTimeoutError extends AgentImageGenerationError { /** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */ export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") { if (error instanceof AgentImageGenerationError) return error; + if (error instanceof ProviderTaskUncertainError) + return new AgentImageGenerationError(`图片任务已提交,结果仍待核对。${error.providerTaskId ? `上游任务编号:${error.providerTaskId}。` : ""}请先查询原任务结果,不要重复提交。`, false); const rejected = error instanceof ImageSubmissionRejectedError; const message = error instanceof Error ? error.message : ""; const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。"; diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index 886120235..26fb7bc5e 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -1,4 +1,5 @@ import { canonicalVip98kVideoRatio, vip98kVideoDurationBounds } from "./vip98k-video-parameters"; +import { VIP98K_TASK_POLL_SECONDS } from "./vip98k-runtime-config"; import { hasVerifiedVip98kGptImage25ResolutionTiers, hasVerifiedVip98kOpenAiImageArrayContract, @@ -702,7 +703,7 @@ function getVip98kMetadata(model: Vip98kCatalogModel, type: string) { pollIntervalSeconds: parseNumber( model.pollIntervalSeconds || model.poll_interval_seconds, ) || 8, - maxPollSeconds: parseNumber(model.maxPollSeconds || model.max_poll_seconds) || 600, + maxPollSeconds: parseNumber(model.maxPollSeconds || model.max_poll_seconds) || VIP98K_TASK_POLL_SECONDS, resultTTLSeconds: parseNumber( model.resultTTLSeconds || model.result_ttl_seconds, ), diff --git a/lib/ai/vip98k-runtime-config.ts b/lib/ai/vip98k-runtime-config.ts index 474c4d3c9..0a67a6e2e 100644 --- a/lib/ai/vip98k-runtime-config.ts +++ b/lib/ai/vip98k-runtime-config.ts @@ -1,4 +1,7 @@ export const VIP98K_RUNTIME_CONFIG_KEY = "vip98k_runtime_config"; +export const VIP98K_TASK_POLL_SECONDS = 15 * 60; +// Allow submission and result delivery in addition to the accepted task's polling budget. +export const VIP98K_IMAGE_BATCH_TIMEOUT_MS = (VIP98K_TASK_POLL_SECONDS + 5 * 60) * 1000; export type Vip98kRuntimeConfig = { submissionTimeoutMs: number; diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 04f021f98..353e0b348 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -19,6 +19,7 @@ import { createBananaImage } from "@/lib/ai/providers/bananaimage"; import { createVisionary } from "@/lib/ai/providers/visionary"; import { createAtlasCloud } from "@/lib/ai/providers/atlascloud"; import { createVip98k } from "@/lib/ai/providers/vip98k"; +import { VIP98K_IMAGE_BATCH_TIMEOUT_MS } from "@/lib/ai/vip98k-runtime-config"; import { createStepfunImage } from "@/lib/ai/providers/stepfun-image"; import { createMidjourney } from "@/lib/ai/providers/midjourney"; import { createRunway } from "@/lib/ai/providers/runway"; @@ -338,7 +339,7 @@ function isCanvasScaleAdjustDebugRequest( ); } -function getCanvasProviderBatchTimeoutMs(params: { +export function getCanvasProviderBatchTimeoutMs(params: { debugLabel: string; debugMeta?: Record; providerType: string; @@ -349,7 +350,7 @@ function getCanvasProviderBatchTimeoutMs(params: { : CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS; } - return CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS; + return params.providerType === "vip98k" ? VIP98K_IMAGE_BATCH_TIMEOUT_MS : CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS; } function getCanvasProviderRuntimeParams(params: { diff --git a/lib/video-canvas/video-task-lifecycle.ts b/lib/video-canvas/video-task-lifecycle.ts index 1ca7825b8..8f1d855b5 100644 --- a/lib/video-canvas/video-task-lifecycle.ts +++ b/lib/video-canvas/video-task-lifecycle.ts @@ -24,7 +24,9 @@ export type VideoTaskLifecycle = { assertActive: () => Promise; }; export class ProviderTaskFailedError extends Error {} -export class ProviderTaskUncertainError extends Error {} +export class ProviderTaskUncertainError extends Error { + constructor(message: string, readonly providerTaskId?: string) { super(message); } +} export function bindVideoTaskLifecycle( lifecycle: VideoTaskLifecycle | undefined, selected: any, diff --git a/tests/helpers/agent-image-provider-harness.ts b/tests/helpers/agent-image-provider-harness.ts index 95c0b8233..ab7347462 100644 --- a/tests/helpers/agent-image-provider-harness.ts +++ b/tests/helpers/agent-image-provider-harness.ts @@ -21,8 +21,9 @@ export function agentImageProviderHarness(options: { generate?: (id: string, cou const text = options.generate?.(id, params.imageParams.imageCount, calls.length) || `![image](https://fixture.invalid/${id}-${calls.length}.png)`; return { text }; } }) }) }; - const generate = serverLoader(mocks)("lib/canvas/generate-image.ts").generateCanvasImage; + const canvas = serverLoader(mocks)("lib/canvas/generate-image.ts"); + const generate = canvas.generateCanvasImage; const request = { prompt: "a cat", model: "primary", imageCount: 1, imageSize: "2K", aspectRatio: "3:4", agentImageMode: "auto", allowFailover: true, referenceImages: ["https://fixture.invalid/source.png"], negativePrompt: "no text", providerImageParams: { seed: 0, watermark: false } }; - return { generate, request, calls, paused }; + return { generate, request, calls, paused, providerTimeout: canvas.getCanvasProviderBatchTimeoutMs }; } diff --git a/tests/routes/canvas-agent-runtime-image-failover.test.ts b/tests/routes/canvas-agent-runtime-image-failover.test.ts index 01cb80d75..d625e6e8b 100644 --- a/tests/routes/canvas-agent-runtime-image-failover.test.ts +++ b/tests/routes/canvas-agent-runtime-image-failover.test.ts @@ -1,10 +1,20 @@ import { expect, test } from "@playwright/test"; +import { VIP98K_TASK_POLL_SECONDS } from "@/lib/ai/vip98k-runtime-config"; import { agentImageProviderHarness as harness } from "../helpers/agent-image-provider-harness"; import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; import { ImageSubmissionRejectedError, agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import * as generationErrors from "@/lib/ai/runtime/image-generation-error"; import { serverLoader } from "../helpers/canvas-download-server-loader"; +test("the canvas deadline leaves room for 98K polling, submission and result delivery", () => { + const h = harness(); + const timeout = h.providerTimeout({ debugLabel: "canvas-agent", providerType: "vip98k" }); + expect(timeout).toBeGreaterThan(VIP98K_TASK_POLL_SECONDS * 1000); + expect(timeout).toBe(20 * 60_000); + expect(h.providerTimeout({ debugLabel: "canvas-agent", providerType: "alibaba" })).toBe(10 * 60_000); + expect(h.providerTimeout({ debugLabel: "canvas-scale-adjust", providerType: "vip98k" })).toBe(3 * 60_000); +}); + test("an unsupported pinned resolution is rejected before submission and can be corrected", async () => { const models = [{ model: { id: "primary", modelId: "primary", isEnabled: true, supportsImageGen: true, imageGenConfig: { supportedImageSizes: ["1K", "2K"] } }, provider: { id: "provider", type: "openai-image", isEnabled: true } }]; diff --git a/tests/routes/image-provider-error-classification.test.ts b/tests/routes/image-provider-error-classification.test.ts index 59bc7dba5..c7a539416 100644 --- a/tests/routes/image-provider-error-classification.test.ts +++ b/tests/routes/image-provider-error-classification.test.ts @@ -105,7 +105,7 @@ test("the provider submits once when an HTTP 524 request cannot be located", asy function imageRecoveryHarness( handler: (path: string, method: string, call: number) => Response | Promise, - maxPollSeconds = 3, + maxPollSeconds: number | undefined = 3, ) { let now = 1_000; const calls: { path: string; method: string; headers: Headers }[] = []; @@ -141,6 +141,42 @@ function imageRecoveryHarness( }; } +test("accepted async tasks can finish after ten minutes using only the original task", async () => { + let polls = 0; + const h = imageRecoveryHarness((_path, method) => { + if (method === "POST") return Response.json({ task_id: "slow-original", result_path: "/extensions/v1/tasks/slow-original/result" }, { status: 202 }); + return Response.json(++polls <= 610 ? { status: "running" } : { status: "succeeded", output: { url: "https://image.example/original.png" } }); + }, undefined); + // Omit the configured short fixture deadline to exercise the production default. + const result = await h.load("lib/ai/providers/vip98k.ts").executeVip98kExternalTask({ + apiKey: "test-only-key", baseURL: "https://provider.test", modelId: "nano-banana-2", + kind: "image", prompt: "A desk", imageUrls: [], modelConfig: { capabilities: { vip98k: { pollIntervalSeconds: 1 } } }, + }); + expect(result.outputUrls).toEqual(["https://image.example/original.png"]); + expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1); + expect(h.calls.filter(c => c.method === "GET").every(c => c.path.endsWith("/slow-original/result"))).toBe(true); +}); + +test("an accepted task's deadline and reconciliation status preserve its ID without another submission", async () => { + for (const reply of [ + { status: 202, body: { status: "running" } }, + { status: 202, body: { status: "pending_review", code: "submission_pending_reconciliation" } }, + { status: 409, body: { error: { code: "request_outcome_unknown" } } }, + ]) { + const h = imageRecoveryHarness((_path, method) => method === "POST" + ? Response.json({ task_id: "original-task", result_path: "/extensions/v1/tasks/original-task/result" }, { status: 202 }) + : Response.json(reply.body, { status: reply.status }), 2); + const error = await h.run().catch((error: unknown) => error); + expect(error.providerTaskId).toBe("original-task"); + expect(h.load("lib/ai/runtime/model-health.ts").shouldFailoverAiModelError(error)).toBe(false); + const visible = h.load("lib/ai/runtime/image-generation-error.ts").agentImageGenerationError(error, "auto"); + expect(visible.confirmedNoResult).toBe(false); + expect(visible.message).toContain("original-task"); + expect(visible.message).toContain("不要重复提交"); + expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1); + } +}); + const recoveredImage = { created: 1, data: [{ url: "https://image.example/original.png" }] }; const originalCompleted = { request_id: "original-request", status: "succeeded", result_available: true }; diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 887c1e532..a1594dd18 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -72,6 +72,18 @@ for (const status of ["failed", "unresolved", "unknown", "missing"]) { }); } +test("catalog defaults allow async failover time while retaining an explicit polling limit", () => { + const models = [{ id: "nano-banana-2", type: "image", invocation: { + kind: "task", method: "POST", path: "/extensions/v1/tasks", content_type: "application/json", + request_example: { file_urls: ["https://image.example/source.png"], model: "nano-banana-2", prompt: "Edit" }, + } }]; + const [normal] = buildVip98kModelPresets(models); + expect((normal.capabilities.vip98k as any).maxPollSeconds).toBe(900); + expect((normal.imageGenConfig!.vip98k as any).maxPollSeconds).toBe(900); + const [configured] = buildVip98kModelPresets([{ ...models[0], max_poll_seconds: 1200 }]); + expect((configured.capabilities.vip98k as any).maxPollSeconds).toBe(1200); +}); + test("a synchronous 502 recovers the original completed image through GET only", async () => { const calls: string[] = []; let lookups = 0;