Merge pull request '对齐98K异步等待并保留待核对任务编号' (#85) from codex/image-review-requirements-20261113 into main

This commit is contained in:
WuYongJie 2026-11-13 15:02:50 +08:00
commit 7464108d2f
10 changed files with 86 additions and 9 deletions

View File

@ -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) {

View File

@ -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" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";

View File

@ -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,
),

View File

@ -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;

View File

@ -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<string, unknown>;
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: {

View File

@ -24,7 +24,9 @@ export type VideoTaskLifecycle = {
assertActive: () => Promise<void>;
};
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,

View File

@ -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 };
}

View File

@ -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 } }];

View File

@ -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<Response>,
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 };

View File

@ -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;