对齐98K异步等待并保留待核对任务编号
This commit is contained in:
parent
af54ec2611
commit
86fd3353f4
@ -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) {
|
||||
|
||||
@ -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" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -21,8 +21,9 @@ export function agentImageProviderHarness(options: { generate?: (id: string, cou
|
||||
const text = options.generate?.(id, params.imageParams.imageCount, calls.length) || ``;
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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 } }];
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user