fix: poll qwen image generation asynchronously
This commit is contained in:
parent
e49b0707e9
commit
6ce7c31a1e
29
lib/ai/providers/alibaba-image-api.ts
Normal file
29
lib/ai/providers/alibaba-image-api.ts
Normal 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();
|
||||
}
|
||||
@ -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 响应,提取并上传图片
|
||||
*/
|
||||
|
||||
33
tests/routes/alibaba-image-async-routing.test.ts
Normal file
33
tests/routes/alibaba-image-async-routing.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user