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 { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
import {
|
||||||
|
isAlibabaQwenImage3ModelId,
|
||||||
|
resolveAlibabaImageAsyncEndpoint,
|
||||||
|
resolveAlibabaImageTaskEndpoint,
|
||||||
|
} from "./alibaba-image-api";
|
||||||
|
|
||||||
interface AlibabaImageConfig {
|
interface AlibabaImageConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
@ -33,6 +38,8 @@ export interface AlibabaImageParams {
|
|||||||
// 阿里生图 API 响应
|
// 阿里生图 API 响应
|
||||||
interface AlibabaImageResponse {
|
interface AlibabaImageResponse {
|
||||||
output?: {
|
output?: {
|
||||||
|
task_id?: string;
|
||||||
|
task_status?: string;
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
finish_reason: string;
|
finish_reason: string;
|
||||||
message: {
|
message: {
|
||||||
@ -53,6 +60,9 @@ interface AlibabaImageResponse {
|
|||||||
|
|
||||||
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
|
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
|
||||||
const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000;
|
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_MIN_SIZE = 512;
|
||||||
const ALIBABA_MAX_SIZE = 2048;
|
const ALIBABA_MAX_SIZE = 2048;
|
||||||
const ALIBABA_OUTPUT_SIZE_MULTIPLE = 2;
|
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_DEFAULT_MAX_INPUT_BYTES = 18 * 1024 * 1024;
|
||||||
const ALIBABA_QWEN_MAX_INPUT_BYTES = 9.5 * 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" {
|
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
|
||||||
const normalized = String(imageSize || "").trim().toUpperCase();
|
const normalized = String(imageSize || "").trim().toUpperCase();
|
||||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
|
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
|
||||||
@ -460,6 +481,14 @@ async function sendAlibabaImageRequest(
|
|||||||
console.log("[Alibaba Image] Model:", modelId);
|
console.log("[Alibaba Image] Model:", modelId);
|
||||||
console.log("[Alibaba Image] Parameters:", JSON.stringify(requestBody.parameters));
|
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);
|
const ctx = createAbortSignal(ALIBABA_IMAGE_GENERATION_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -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 响应,提取并上传图片
|
* 处理 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