From 6ce7c31a1ef0347da5db5cc4e89d5b1b019e0dbf Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 7 Sep 2026 19:46:18 +0800 Subject: [PATCH] fix: poll qwen image generation asynchronously --- lib/ai/providers/alibaba-image-api.ts | 29 ++++ lib/ai/providers/alibaba-image.ts | 144 ++++++++++++++++++ .../alibaba-image-async-routing.test.ts | 33 ++++ 3 files changed, 206 insertions(+) create mode 100644 lib/ai/providers/alibaba-image-api.ts create mode 100644 tests/routes/alibaba-image-async-routing.test.ts diff --git a/lib/ai/providers/alibaba-image-api.ts b/lib/ai/providers/alibaba-image-api.ts new file mode 100644 index 000000000..304c6ed85 --- /dev/null +++ b/lib/ai/providers/alibaba-image-api.ts @@ -0,0 +1,29 @@ +const ALIBABA_IMAGE_SYNC_PATH = + "/api/v1/services/aigc/multimodal-generation/generation"; +const ALIBABA_IMAGE_ASYNC_PATH = + "/api/v1/services/aigc/image-generation/generation"; + +export function isAlibabaQwenImage3ModelId(modelId: string) { + return /^qwen-image-3(?:\.0)?(?:$|[-/])/i.test(String(modelId || "").trim()); +} + +export function resolveAlibabaImageAsyncEndpoint(baseURL: string) { + const parsed = new URL(baseURL); + parsed.pathname = parsed.pathname.includes(ALIBABA_IMAGE_SYNC_PATH) + ? parsed.pathname.replace(ALIBABA_IMAGE_SYNC_PATH, ALIBABA_IMAGE_ASYNC_PATH) + : ALIBABA_IMAGE_ASYNC_PATH; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); +} + +export function resolveAlibabaImageTaskEndpoint( + requestEndpoint: string, + taskId: string, +) { + const parsed = new URL(requestEndpoint); + parsed.pathname = `/api/v1/tasks/${encodeURIComponent(taskId)}`; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); +} diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index ce1d0e4db..4f50256ca 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -14,6 +14,11 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification" import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { uploadFile } from "@/lib/storage/service"; +import { + isAlibabaQwenImage3ModelId, + resolveAlibabaImageAsyncEndpoint, + resolveAlibabaImageTaskEndpoint, +} from "./alibaba-image-api"; interface AlibabaImageConfig { apiKey: string; @@ -33,6 +38,8 @@ export interface AlibabaImageParams { // 阿里生图 API 响应 interface AlibabaImageResponse { output?: { + task_id?: string; + task_status?: string; choices?: Array<{ finish_reason: string; message: { @@ -53,6 +60,9 @@ interface AlibabaImageResponse { const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000; const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000; +const ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS = 60 * 1000; +const ALIBABA_IMAGE_POLL_TIMEOUT_MS = 30 * 1000; +const ALIBABA_IMAGE_POLL_INTERVAL_MS = 5 * 1000; const ALIBABA_MIN_SIZE = 512; const ALIBABA_MAX_SIZE = 2048; const ALIBABA_OUTPUT_SIZE_MULTIPLE = 2; @@ -60,6 +70,17 @@ const ALIBABA_MAX_INPUT_SIDE = 4096; const ALIBABA_DEFAULT_MAX_INPUT_BYTES = 18 * 1024 * 1024; const ALIBABA_QWEN_MAX_INPUT_BYTES = 9.5 * 1024 * 1024; +class AlibabaImageTerminalTaskError extends Error {} + +class AlibabaImageRequestError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" { const normalized = String(imageSize || "").trim().toUpperCase(); if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized; @@ -459,6 +480,14 @@ async function sendAlibabaImageRequest( console.log("[Alibaba Image] Sending request to:", baseURL); console.log("[Alibaba Image] Model:", modelId); console.log("[Alibaba Image] Parameters:", JSON.stringify(requestBody.parameters)); + + if (isAlibabaQwenImage3ModelId(modelId)) { + return sendAlibabaImageAsyncRequest({ + apiKey, + requestBody, + requestURL: resolveAlibabaImageAsyncEndpoint(baseURL), + }); + } const ctx = createAbortSignal(ALIBABA_IMAGE_GENERATION_TIMEOUT_MS); @@ -492,6 +521,121 @@ async function sendAlibabaImageRequest( } } +async function parseAlibabaImageResponse(response: Response) { + const responseText = await response.text(); + if (!responseText.trim()) { + throw new Error(`阿里图片服务返回空响应(HTTP ${response.status})`); + } + + try { + return JSON.parse(responseText) as AlibabaImageResponse; + } catch { + throw new Error(`阿里图片服务返回了无效响应(HTTP ${response.status})`); + } +} + +async function fetchAlibabaImageJson(params: { + apiKey: string; + body?: string; + method: "GET" | "POST"; + timeoutMs: number; + url: string; + asyncRequest?: boolean; +}) { + const ctx = createAbortSignal(params.timeoutMs); + try { + const response = await fetch(params.url, { + method: params.method, + headers: { + Authorization: `Bearer ${params.apiKey}`, + ...(params.body ? { "Content-Type": "application/json" } : {}), + ...(params.asyncRequest ? { "X-DashScope-Async": "enable" } : {}), + }, + ...(params.body ? { body: params.body } : {}), + signal: ctx.signal, + }); + const data = await parseAlibabaImageResponse(response); + if (!response.ok || data.code) { + throw new AlibabaImageRequestError( + data.message || `API request failed: ${response.status}`, + response.status, + ); + } + return data; + } finally { + ctx.clear(); + } +} + +async function sendAlibabaImageAsyncRequest(params: { + apiKey: string; + requestBody: Record; + requestURL: string; +}): Promise { + const submitted = await fetchAlibabaImageJson({ + apiKey: params.apiKey, + asyncRequest: true, + body: JSON.stringify(params.requestBody), + method: "POST", + timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS, + url: params.requestURL, + }); + const taskId = String(submitted.output?.task_id || "").trim(); + if (!taskId) throw new Error("阿里图片异步任务未返回 task_id"); + + const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId); + const startedAt = Date.now(); + let lastPollError: unknown = null; + console.log("[Alibaba Image] Async task submitted:", taskId); + + while (Date.now() - startedAt < ALIBABA_IMAGE_GENERATION_TIMEOUT_MS) { + await new Promise((resolve) => + setTimeout(resolve, ALIBABA_IMAGE_POLL_INTERVAL_MS), + ); + + try { + const task = await fetchAlibabaImageJson({ + apiKey: params.apiKey, + method: "GET", + timeoutMs: ALIBABA_IMAGE_POLL_TIMEOUT_MS, + url: taskURL, + }); + const taskStatus = String(task.output?.task_status || "").toUpperCase(); + if (taskStatus === "SUCCEEDED") { + console.log("[Alibaba Image] Async task completed:", taskId); + return task; + } + if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) { + throw new AlibabaImageTerminalTaskError( + task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`, + ); + } + lastPollError = null; + } catch (error) { + if (error instanceof AlibabaImageTerminalTaskError) throw error; + if ( + error instanceof AlibabaImageRequestError && + error.status >= 400 && + error.status < 500 && + ![408, 425, 429].includes(error.status) + ) { + throw error; + } + lastPollError = error; + console.warn("[Alibaba Image] Async task poll failed; retrying:", { + taskId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const lastErrorMessage = + lastPollError instanceof Error ? `,最后错误:${lastPollError.message}` : ""; + throw new Error( + `阿里图片生成超时(600秒,task_id: ${taskId})${lastErrorMessage}`, + ); +} + /** * 处理 API 响应,提取并上传图片 */ diff --git a/tests/routes/alibaba-image-async-routing.test.ts b/tests/routes/alibaba-image-async-routing.test.ts new file mode 100644 index 000000000..31a3b25a7 --- /dev/null +++ b/tests/routes/alibaba-image-async-routing.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import { + isAlibabaQwenImage3ModelId, + resolveAlibabaImageAsyncEndpoint, + resolveAlibabaImageTaskEndpoint, +} from "@/lib/ai/providers/alibaba-image-api"; + +test.describe("Alibaba image async routing", () => { + test("identifies Qwen Image 3 models", () => { + expect(isAlibabaQwenImage3ModelId("qwen-image-3.0")).toBe(true); + expect(isAlibabaQwenImage3ModelId("qwen-image-3.0-plus")).toBe(true); + expect(isAlibabaQwenImage3ModelId("qwen-image-edit-plus")).toBe(false); + }); + + test("routes Qwen Image 3 submissions to the async image endpoint", () => { + expect( + resolveAlibabaImageAsyncEndpoint( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + ), + ).toBe( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation", + ); + }); + + test("builds the task endpoint without retaining query parameters", () => { + expect( + resolveAlibabaImageTaskEndpoint( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation?workspace=x", + "task/id", + ), + ).toBe("https://dashscope.aliyuncs.com/api/v1/tasks/task%2Fid"); + }); +});