fix: poll qwen image generation asynchronously

This commit is contained in:
134 2026-09-07 19:46:18 +08:00
parent e49b0707e9
commit 6ce7c31a1e
3 changed files with 206 additions and 0 deletions

View File

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

View File

@ -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<string, unknown>;
requestURL: string;
}): Promise<AlibabaImageResponse> {
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 响应,提取并上传图片
*/

View File

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