fix(image): route text-to-image and image-edit by model family

Fix wanx/wan2.x-t2i, wan2.5-i2i, z-image, and qwen-image-plus hitting the wrong endpoint, and add routing unit tests plus dry-run coverage.
This commit is contained in:
qcq01083097
2026-07-27 10:16:25 +08:00
parent fac2b2d18b
commit 9a13700390
10 changed files with 600 additions and 202 deletions
+87 -63
View File
@@ -1,7 +1,5 @@
import {
defineCommand,
imagePath,
imageSyncPath,
taskPath,
detectOutputFormat,
resolveOutputDir,
@@ -20,6 +18,7 @@ import {
BailianError,
resolveBooleanFlag,
resolveWatermark,
resolveImageEditApi,
ASYNC_FLAG,
CONCURRENT_FLAG,
redactDataUri,
@@ -32,13 +31,8 @@ import { resolveImageSize } from "bailian-cli-runtime";
import { join } from "path";
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
function isSyncModel(model: string): boolean {
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
}
function enablesPromptExtendByDefault(model: string): boolean {
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
}
@@ -114,6 +108,7 @@ export default defineCommand({
'--image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
'--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
'--image ./photo.png --prompt "Change the style" --model wan2.7-image',
'--image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview',
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
],
async run(ctx) {
@@ -128,7 +123,7 @@ export default defineCommand({
const prompt = flags.prompt;
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
const useSync = isSyncModel(model);
const route = resolveImageEditApi(model);
// Auto-upload local files (resolve all images in parallel)
const resolvedImages = await Promise.all(
@@ -142,67 +137,94 @@ export default defineCommand({
"prompt-extend",
);
// Build content: all images first, then text prompt
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
(u: string) => ({ image: u }),
);
contentItems.push({ text: prompt });
const watermark = resolveWatermark(flags.watermark);
const body: DashScopeImageRequest = {
model,
input: {
messages: [
{
role: "user",
content: contentItems,
},
],
},
parameters: {
size: resolveImageSize(flags.size, useSync),
n,
seed: flags.seed,
prompt_extend: promptExtend,
watermark,
negative_prompt: flags.negativePrompt || undefined,
},
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size: resolveImageSize(flags.size, route.useSync),
n,
seed: flags.seed,
prompt_extend: promptExtend,
watermark,
};
let body: DashScopeImageRequest;
if (route.inputStyle === "prompt-images") {
body = {
model,
input: {
prompt,
images: resolvedImages,
negative_prompt: flags.negativePrompt || undefined,
},
parameters,
};
} else {
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
(imageUrl: string) => ({ image: imageUrl }),
);
contentItems.push({ text: prompt });
body = {
model,
input: {
messages: [
{
role: "user",
content: contentItems,
},
],
},
parameters: {
...parameters,
negative_prompt: flags.negativePrompt || undefined,
},
};
}
// Remove undefined parameters
stripUndefined(body.parameters as Record<string, unknown>);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const previewBody = {
...body,
input: {
messages: body.input.messages.map((message) => ({
...message,
content: message.content.map((item) =>
item.image ? { ...item, image: redactDataUri(item.image) } : item,
),
})),
},
};
emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format);
const previewBody =
"messages" in body.input
? {
...body,
input: {
messages: body.input.messages.map((message) => ({
...message,
content: message.content.map((item) =>
item.image ? { ...item, image: redactDataUri(item.image) } : item,
),
})),
},
}
: {
...body,
input: {
...body.input,
images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)),
},
};
emitResult(
{ request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path },
format,
);
return;
}
if (!settings.quiet) {
process.stderr.write(
`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
);
}
const concurrent = getConcurrency(flags);
if (useSync) {
await handleSyncMode(ctx.client, settings, body, flags, format, concurrent);
if (route.useSync) {
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
} else {
await handleAsyncMode(ctx.client, settings, body, flags, format, concurrent);
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
}
},
});
@@ -210,6 +232,7 @@ export default defineCommand({
async function handleSyncMode(
client: Client,
settings: Settings,
path: string,
body: DashScopeImageRequest,
flags: EditFlags,
format: OutputFormat,
@@ -217,15 +240,15 @@ async function handleSyncMode(
): Promise<void> {
const results = await runConcurrent(concurrent, settings, () =>
client.requestJson<DashScopeImageSyncResponse>({
path: imageSyncPath(),
path,
method: "POST",
body,
}),
);
const imageUrls = results
.flatMap((r) => r.output.choices || [])
.flatMap((c) => c.message?.content || [])
.flatMap((result) => result.output.choices || [])
.flatMap((choice) => choice.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
@@ -239,6 +262,7 @@ async function handleSyncMode(
async function handleAsyncMode(
client: Client,
settings: Settings,
path: string,
body: DashScopeImageRequest,
flags: EditFlags,
format: OutputFormat,
@@ -249,14 +273,14 @@ async function handleAsyncMode(
settings,
() =>
client.requestJson<DashScopeAsyncResponse>({
path: imagePath(),
path,
method: "POST",
body,
async: true,
}),
"tasks",
);
const taskIds = responses.map((r) => r.output.task_id);
const taskIds = responses.map((response) => response.output.task_id);
if (flags.async) {
emitResult({ task_ids: taskIds }, format);
@@ -269,12 +293,12 @@ async function handleAsyncMode(
url: client.url(taskPath(taskId)),
intervalSec: pollInterval,
timeoutSec: settings.timeout,
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
getErrorMessage: (d) => {
const o = (d as DashScopeTaskResponse).output;
return o.message || o.code || undefined;
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
getErrorMessage: (data) => {
const output = (data as DashScopeTaskResponse).output;
return output.message || output.code || undefined;
},
}),
);
@@ -285,13 +309,13 @@ async function handleAsyncMode(
for (const result of results) {
if (result.output.choices) {
const urls = result.output.choices
.flatMap((c) => c.message?.content || [])
.flatMap((choice) => choice.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
imageUrls.push(...urls);
}
if (result.output.results) {
const urls = result.output.results.map((r) => r.url).filter(Boolean);
const urls = result.output.results.map((item) => item.url).filter(Boolean);
if (urls.length > 0 && imageUrls.length === 0) {
imageUrls.push(...urls);
}
@@ -321,8 +345,8 @@ async function saveImages(
// Parallel download all images
const items =
imageUrls.length > 1
? imageUrls.map((url, i) => {
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
? imageUrls.map((url, index) => {
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
return { url, destPath: join(outDir, filename) };
})
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
@@ -1,7 +1,5 @@
import {
defineCommand,
imagePath,
imageSyncPath,
taskPath,
detectOutputFormat,
type Client,
@@ -19,6 +17,7 @@ import {
generateFilename,
resolveBooleanFlag,
resolveWatermark,
resolveImageGenerateApi,
ASYNC_FLAG,
CONCURRENT_FLAG,
} from "bailian-cli-core";
@@ -31,14 +30,8 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
import { join } from "path";
// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint.
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
function isSyncModel(model: string): boolean {
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
}
function enablesPromptExtendByDefault(model: string): boolean {
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
}
@@ -109,6 +102,8 @@ export default defineCommand({
'--prompt "Logo" --watermark false',
'--prompt "An alien in the space" --watermark false',
'--prompt "sunset" --model wan2.6-t2i --async --quiet',
'--prompt "plush doll" --model z-image-turbo --size 1024*1024',
'--prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024',
'--prompt "Pro quality" --model qwen-image-2.0-pro',
'--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
],
@@ -117,10 +112,10 @@ export default defineCommand({
const prompt = flags.prompt;
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
const useSync = isSyncModel(model);
const defaultSize = useSync ? "1:1" : "1:1";
const route = resolveImageGenerateApi(model);
const defaultSize = "1:1";
const sizeInput = flags.size || defaultSize;
const size = resolveImageSize(sizeInput, useSync);
const size = resolveImageSize(sizeInput, route.useSync);
const n = flags.n ?? 1;
const concurrent = getConcurrency(flags);
@@ -132,58 +127,75 @@ export default defineCommand({
const watermark = resolveWatermark(flags.watermark);
const body: DashScopeImageRequest = {
model,
input: {
messages: [{ role: "user", content: [{ text: prompt }] }],
},
parameters: {
size,
n,
seed: flags.seed,
prompt_extend: promptExtend,
watermark,
negative_prompt: flags.negativePrompt || undefined,
},
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size,
n,
seed: flags.seed,
prompt_extend: promptExtend,
watermark,
};
const body: DashScopeImageRequest =
route.inputStyle === "prompt"
? {
model,
input: {
prompt,
negative_prompt: flags.negativePrompt || undefined,
},
parameters,
}
: {
model,
input: {
messages: [{ role: "user", content: [{ text: prompt }] }],
},
parameters: {
...parameters,
negative_prompt: flags.negativePrompt || undefined,
},
};
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
emitResult(
{ request: body, mode: route.useSync ? "sync" : "async", path: route.path },
format,
);
return;
}
if (!settings.quiet) {
process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`);
process.stderr.write(`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]\n`);
}
if (useSync) {
await handleSyncMode(ctx.client, settings, model, body, flags, format, concurrent);
if (route.useSync) {
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
} else {
await handleAsyncMode(ctx.client, settings, model, body, flags, format, concurrent);
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
}
},
});
// ---- Sync mode: qwen-image-2.0 series ----
// ---- Sync mode: qwen-image / wan2.7-image / z-image ----
async function handleSyncMode(
client: Client,
settings: Settings,
_model: string,
path: string,
body: DashScopeImageRequest,
flags: GenerateFlags,
format: string,
concurrent: number,
): Promise<void> {
const results = await runConcurrent(concurrent, settings, () =>
client.requestJson<DashScopeImageSyncResponse>({ path: imageSyncPath(), method: "POST", body }),
client.requestJson<DashScopeImageSyncResponse>({ path, method: "POST", body }),
);
const imageUrls = results
.flatMap((r) => r.output.choices || [])
.flatMap((c) => c.message?.content || [])
.flatMap((result) => result.output.choices || [])
.flatMap((choice) => choice.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
@@ -194,12 +206,12 @@ async function handleSyncMode(
await saveImages(imageUrls, flags, settings, format);
}
// ---- Async mode: wan2.x / qwen-image-plus ----
// ---- Async mode: wan2.6-t2i / wan2.6-image / legacy text2image ----
async function handleAsyncMode(
client: Client,
settings: Settings,
_model: string,
path: string,
body: DashScopeImageRequest,
flags: GenerateFlags,
format: string,
@@ -210,14 +222,14 @@ async function handleAsyncMode(
settings,
() =>
client.requestJson<DashScopeAsyncResponse>({
path: imagePath(),
path,
method: "POST",
body,
async: true,
}),
"tasks",
);
const taskIds = responses.map((r) => r.output.task_id);
const taskIds = responses.map((response) => response.output.task_id);
// --async: return all task IDs immediately
if (flags.async) {
@@ -234,12 +246,12 @@ async function handleAsyncMode(
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: settings.timeout,
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
getErrorMessage: (d) => {
const o = (d as DashScopeTaskResponse).output;
return o.message || o.code || undefined;
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
getErrorMessage: (data) => {
const output = (data as DashScopeTaskResponse).output;
return output.message || output.code || undefined;
},
});
});
@@ -250,13 +262,13 @@ async function handleAsyncMode(
for (const result of results) {
if (result.output.choices) {
const urls = result.output.choices
.flatMap((c) => c.message?.content || [])
.flatMap((choice) => choice.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
imageUrls.push(...urls);
}
if (result.output.results) {
const urls = result.output.results.map((r) => r.url).filter(Boolean);
const urls = result.output.results.map((item) => item.url).filter(Boolean);
if (urls.length > 0 && imageUrls.length === 0) {
imageUrls.push(...urls);
}
@@ -298,8 +310,8 @@ async function saveImages(
// Parallel download all images
const items =
imageUrls.length > 1
? imageUrls.map((url, i) => {
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
? imageUrls.map((url, index) => {
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
return { url, destPath: join(outDir, filename) };
})
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
@@ -61,6 +61,78 @@ describe("e2e: image generate", () => {
expect(data.mode).toBe("sync");
expect(data.request?.model).toBe("wan2.7-image");
});
test("z-image-turbo dry-run 走 sync multimodal", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"z-image-turbo",
"--prompt",
"一只猫",
"--size",
"1024*1024",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ mode?: string; request?: { model?: string } }>(stdout);
expect(data.mode).toBe("sync");
expect(data.request?.model).toBe("z-image-turbo");
});
test("qwen-image-plus dry-run 走 sync multimodal", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"qwen-image-plus",
"--prompt",
"一只猫",
"--size",
"1328*1328",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
mode?: string;
path?: string;
request?: { model?: string };
}>(stdout);
expect(data.mode).toBe("sync");
expect(data.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(data.request?.model).toBe("qwen-image-plus");
});
test("wanx2.0-t2i-turbo dry-run 走 text2image prompt 路径", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"wanx2.0-t2i-turbo",
"--prompt",
"一只猫",
"--size",
"1024*1024",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
mode?: string;
path?: string;
request?: { model?: string; input?: { prompt?: string; messages?: unknown } };
}>(stdout);
expect(data.mode).toBe("async");
expect(data.path).toBe("/api/v1/services/aigc/text2image/image-synthesis");
expect(data.request?.model).toBe("wanx2.0-t2i-turbo");
expect(data.request?.input?.prompt).toBe("一只猫");
expect(data.request?.input?.messages).toBeUndefined();
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
+12 -1
View File
@@ -7,15 +7,26 @@ export function chatPath(): string {
}
// ---- Image Generation (DashScope) ----
/** Async image API used by wan2.6-t2i / wan2.6-image (T2I) and similar message-format models. */
export function imagePath(): string {
return "/api/v1/services/aigc/image-generation/generation";
}
// Synchronous image generation (qwen-image-2.0 / qwen-image-max series)
/** Sync multimodal API (qwen-image / wan2.7-image / z-image generate; also wan2.6-image edit). */
export function imageSyncPath(): string {
return "/api/v1/services/aigc/multimodal-generation/generation";
}
/** Legacy async text-to-image API (wan2.5/2.2/2.1-t2i, wanx-*-t2i). */
export function imageText2ImagePath(): string {
return "/api/v1/services/aigc/text2image/image-synthesis";
}
/** Legacy async image-to-image / edit API (wan2.5-i2i, *imageedit*). */
export function image2ImagePath(): string {
return "/api/v1/services/aigc/image2image/image-synthesis";
}
// ---- Video Generation (DashScope) ----
export function videoGeneratePath(): string {
return "/api/v1/services/aigc/video-generation/video-synthesis";
+125
View File
@@ -0,0 +1,125 @@
import { image2ImagePath, imagePath, imageSyncPath, imageText2ImagePath } from "./endpoints.ts";
/**
* DashScope image APIs differ by model family:
*
* Generate (T2I):
* - sync multimodal + messages: qwen-image*, wan2.7-image*, z-image*
* - async image-generation + messages: wan2.6-t2i*, wan2.6-image*
* (wan2.6-image sync multimodal requires 1–4 images, so pure T2I must be async)
* - async text2image + prompt: wan2.5/2.2/2.1-t2i*, wanx*-t2i*
*
* Edit (I2I):
* - sync multimodal + messages(+images): qwen-image-*, wan2.6-image*, wan2.7-image*, z-image*
* - async image2image + prompt/images: wan2.5-i2i*, *imageedit*
* - async image-generation + messages(+images): other async fallbacks
*/
export type ImageApiKind =
| "sync-multimodal"
| "async-image-generation"
| "async-text2image"
| "async-image2image";
export interface ImageApiRoute {
kind: ImageApiKind;
path: string;
/** True when the call is synchronous (no X-DashScope-Async / task poll). */
useSync: boolean;
/** How to shape `input` in the request body. */
inputStyle: "messages" | "prompt" | "prompt-images";
}
/** Models that accept text-only sync multimodal for generate. */
const SYNC_GENERATE_PREFIXES = ["qwen-image", "wan2.7-image", "z-image"] as const;
/**
* Extra models that use sync multimodal only for edit (messages must include images).
* wan2.6-image generate is async image-generation instead.
*/
const SYNC_EDIT_ONLY_PREFIXES = ["wan2.6-image"] as const;
function startsWithAny(model: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => model.startsWith(prefix));
}
/** True when the model family can use sync multimodal (generate and/or edit). */
export function isSyncMultimodalImageModel(model: string): boolean {
return (
startsWithAny(model, SYNC_GENERATE_PREFIXES) || startsWithAny(model, SYNC_EDIT_ONLY_PREFIXES)
);
}
function isSyncGenerateModel(model: string): boolean {
return startsWithAny(model, SYNC_GENERATE_PREFIXES);
}
function isSyncEditModel(model: string): boolean {
return isSyncMultimodalImageModel(model);
}
/** wan2.5 / wan2.2 / wan2.1 / wanx text-to-image models use the legacy prompt API. */
export function isLegacyText2ImageModel(model: string): boolean {
if (model.startsWith("wan2.6-t2i") || model.startsWith("wan2.6-image")) return false;
if (isSyncGenerateModel(model)) return false;
if (/^wan2\.[0-5][^-]*-t2i/i.test(model)) return true;
if (/^wanx-v1$/i.test(model)) return true;
if (/^wanx/i.test(model) && /t2i|text2image/i.test(model)) return true;
return false;
}
/** wan2.5-i2i / *imageedit* use the legacy image2image prompt+images API. */
export function isLegacyImage2ImageModel(model: string): boolean {
return /wan2\.5-i2i/i.test(model) || /imageedit/i.test(model);
}
export function resolveImageGenerateApi(model: string): ImageApiRoute {
if (isSyncGenerateModel(model)) {
return {
kind: "sync-multimodal",
path: imageSyncPath(),
useSync: true,
inputStyle: "messages",
};
}
if (isLegacyText2ImageModel(model)) {
return {
kind: "async-text2image",
path: imageText2ImagePath(),
useSync: false,
inputStyle: "prompt",
};
}
// Includes wan2.6-t2i* and wan2.6-image* (text-only generate).
return {
kind: "async-image-generation",
path: imagePath(),
useSync: false,
inputStyle: "messages",
};
}
export function resolveImageEditApi(model: string): ImageApiRoute {
if (isSyncEditModel(model)) {
return {
kind: "sync-multimodal",
path: imageSyncPath(),
useSync: true,
inputStyle: "messages",
};
}
if (isLegacyImage2ImageModel(model)) {
return {
kind: "async-image2image",
path: image2ImagePath(),
useSync: false,
inputStyle: "prompt-images",
};
}
return {
kind: "async-image-generation",
path: imagePath(),
useSync: false,
inputStyle: "messages",
};
}
+11
View File
@@ -3,6 +3,8 @@ export {
chatPath,
imagePath,
imageSyncPath,
imageText2ImagePath,
image2ImagePath,
knowledgeChatEndpoint,
knowledgeRetrievePath,
knowledgeSearchEndpoint,
@@ -18,6 +20,15 @@ export {
userProfilePath,
videoGeneratePath,
} from "./endpoints.ts";
export {
isLegacyImage2ImageModel,
isLegacyText2ImageModel,
isSyncMultimodalImageModel,
resolveImageEditApi,
resolveImageGenerateApi,
type ImageApiKind,
type ImageApiRoute,
} from "./image-routes.ts";
export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts";
export type { RequestOpts } from "./http.ts";
export { request, requestJson } from "./http.ts";
+13 -6
View File
@@ -112,12 +112,19 @@ export interface StreamChunk {
export interface DashScopeImageRequest {
model: string;
input: {
messages: Array<{
role: "user";
content: Array<{ text?: string; image?: string }>;
}>;
};
input:
| {
messages: Array<{
role: "user";
content: Array<{ text?: string; image?: string }>;
}>;
}
| {
prompt: string;
/** Required by image2image models such as wan2.5-i2i-preview. */
images?: string[];
negative_prompt?: string;
};
parameters?: {
size?: string;
n?: number;
+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from "vite-plus/test";
import {
isLegacyImage2ImageModel,
isLegacyText2ImageModel,
isSyncMultimodalImageModel,
resolveImageEditApi,
resolveImageGenerateApi,
} from "../src/client/image-routes.ts";
test("sync multimodal family covers qwen-image, wan2.6/2.7 image, and z-image", () => {
expect(isSyncMultimodalImageModel("qwen-image-2.0")).toBe(true);
expect(isSyncMultimodalImageModel("qwen-image-2.0-pro")).toBe(true);
expect(isSyncMultimodalImageModel("qwen-image-plus")).toBe(true);
expect(isSyncMultimodalImageModel("qwen-image-max")).toBe(true);
expect(isSyncMultimodalImageModel("wan2.7-image")).toBe(true);
expect(isSyncMultimodalImageModel("wan2.6-image")).toBe(true);
expect(isSyncMultimodalImageModel("z-image-turbo")).toBe(true);
expect(isSyncMultimodalImageModel("wan2.6-t2i")).toBe(false);
});
test("legacy text2image covers wan2.5/2.2/2.1 t2i and wanx but not wan2.6-t2i/image", () => {
expect(isLegacyText2ImageModel("wan2.2-t2i-plus")).toBe(true);
expect(isLegacyText2ImageModel("wan2.5-t2i-preview")).toBe(true);
expect(isLegacyText2ImageModel("wan2.1-t2i-turbo")).toBe(true);
expect(isLegacyText2ImageModel("wanx2.0-t2i-turbo")).toBe(true);
expect(isLegacyText2ImageModel("wan2.6-t2i")).toBe(false);
expect(isLegacyText2ImageModel("wan2.6-image")).toBe(false);
expect(isLegacyText2ImageModel("wan2.7-image")).toBe(false);
});
test("legacy image2image covers wan2.5-i2i and imageedit models", () => {
expect(isLegacyImage2ImageModel("wan2.5-i2i-preview")).toBe(true);
expect(isLegacyImage2ImageModel("wanx2.1-imageedit")).toBe(true);
expect(isLegacyImage2ImageModel("wan2.6-image")).toBe(false);
});
test("resolveImageGenerateApi picks path and input style by model family", () => {
expect(resolveImageGenerateApi("wanx2.0-t2i-turbo")).toMatchObject({
kind: "async-text2image",
path: "/api/v1/services/aigc/text2image/image-synthesis",
inputStyle: "prompt",
useSync: false,
});
expect(resolveImageGenerateApi("wan2.2-t2i-plus")).toMatchObject({
kind: "async-text2image",
path: "/api/v1/services/aigc/text2image/image-synthesis",
inputStyle: "prompt",
useSync: false,
});
expect(resolveImageGenerateApi("wan2.6-t2i")).toMatchObject({
kind: "async-image-generation",
path: "/api/v1/services/aigc/image-generation/generation",
inputStyle: "messages",
useSync: false,
});
expect(resolveImageGenerateApi("wan2.6-image")).toMatchObject({
kind: "async-image-generation",
path: "/api/v1/services/aigc/image-generation/generation",
inputStyle: "messages",
useSync: false,
});
expect(resolveImageGenerateApi("qwen-image-2.0")).toMatchObject({
kind: "sync-multimodal",
path: "/api/v1/services/aigc/multimodal-generation/generation",
inputStyle: "messages",
useSync: true,
});
expect(resolveImageGenerateApi("z-image-turbo")).toMatchObject({
kind: "sync-multimodal",
useSync: true,
});
expect(resolveImageGenerateApi("qwen-image-plus")).toMatchObject({
kind: "sync-multimodal",
path: "/api/v1/services/aigc/multimodal-generation/generation",
inputStyle: "messages",
useSync: true,
});
expect(resolveImageGenerateApi("wan2.7-image")).toMatchObject({
kind: "sync-multimodal",
useSync: true,
});
});
test("resolveImageEditApi picks path and input style by model family", () => {
expect(resolveImageEditApi("wan2.5-i2i-preview")).toMatchObject({
kind: "async-image2image",
path: "/api/v1/services/aigc/image2image/image-synthesis",
inputStyle: "prompt-images",
useSync: false,
});
expect(resolveImageEditApi("wan2.6-image")).toMatchObject({
kind: "sync-multimodal",
path: "/api/v1/services/aigc/multimodal-generation/generation",
inputStyle: "messages",
useSync: true,
});
expect(resolveImageEditApi("wan2.7-image")).toMatchObject({
kind: "sync-multimodal",
useSync: true,
});
});
+107 -84
View File
@@ -4,8 +4,8 @@
*/
import {
chatPath,
imagePath,
imageSyncPath,
resolveImageEditApi,
resolveImageGenerateApi,
videoGeneratePath,
taskPath,
speechSynthesizePath,
@@ -147,12 +147,6 @@ export async function visionDescribe(
// --- image/generate ---
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
function isSyncImageModel(model: string): boolean {
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
}
export interface ImageGenerateInput {
prompt?: string;
model?: string;
@@ -178,61 +172,72 @@ export async function imageGenerate(
}
const model = input.model || "qwen-image-2.0";
const useSync = isSyncImageModel(model);
const route = resolveImageGenerateApi(model);
const n = input.n ?? 1;
const promptExtend = resolveBooleanFlag(
input["prompt-extend"],
useSync ? true : undefined,
route.useSync ? true : undefined,
"prompt-extend",
);
const body: DashScopeImageRequest = {
model,
input: {
messages: [{ role: "user", content: [{ text: input.prompt }] }],
},
parameters: {
size: resolveImageSize(input.size, useSync),
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
negative_prompt: input["negative-prompt"] || undefined,
},
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size: resolveImageSize(input.size, route.useSync),
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
};
if (useSync) {
const url = imageSyncPath();
const body: DashScopeImageRequest =
route.inputStyle === "prompt"
? {
model,
input: {
prompt: input.prompt,
negative_prompt: input["negative-prompt"] || undefined,
},
parameters,
}
: {
model,
input: {
messages: [{ role: "user", content: [{ text: input.prompt }] }],
},
parameters: {
...parameters,
negative_prompt: input["negative-prompt"] || undefined,
},
};
if (route.useSync) {
const response = await env.client.requestJson<DashScopeImageSyncResponse>({
path: url,
path: route.path,
method: "POST",
body,
signal: ctx.signal,
});
const urls = response.output.choices
.flatMap((c) => c.message?.content || [])
.flatMap((choice) => choice.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) };
} else {
// Async mode: submit then poll
const url = imagePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
path: url,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const result = await pollTask(env, taskId, ctx);
const urls = Array.isArray(result.urls) ? (result.urls as string[]) : [];
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
if (saved) result.saved = saved;
return result;
}
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
path: route.path,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const result = await pollTask(env, taskId, ctx);
const urls = Array.isArray(result.urls) ? (result.urls as string[]) : [];
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
if (saved) result.saved = saved;
return result;
}
// --- image/edit ---
@@ -264,70 +269,88 @@ export async function imageEdit(
const images = Array.isArray(input.image) ? input.image : input.image ? [input.image] : [];
const model = input.model || "qwen-image-2.0";
const useSync = isSyncImageModel(model);
const route = resolveImageEditApi(model);
const n = input.n ?? 1;
const promptExtend = resolveBooleanFlag(
input["prompt-extend"],
useSync ? true : undefined,
route.useSync ? true : undefined,
"prompt-extend",
);
const content: Array<{ text?: string; image?: string }> = [];
for (const img of images) {
let imageUrl = img;
if (isLocalFile(img)) {
imageUrl = await env.client.uploadFile(img, model, { signal: ctx.signal });
const resolvedImages: string[] = [];
for (const image of images) {
let imageUrl = image;
if (isLocalFile(image)) {
imageUrl = await env.client.uploadFile(image, model, { signal: ctx.signal });
}
content.push({ image: imageUrl });
resolvedImages.push(imageUrl);
}
content.push({ text: input.prompt });
const body: DashScopeImageRequest = {
model,
input: {
messages: [{ role: "user", content }],
},
parameters: {
size: resolveImageSize(input.size, useSync),
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
negative_prompt: input["negative-prompt"] || undefined,
},
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size: resolveImageSize(input.size, route.useSync),
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
};
if (useSync) {
const url = imageSyncPath();
let body: DashScopeImageRequest;
if (route.inputStyle === "prompt-images") {
body = {
model,
input: {
prompt: input.prompt,
images: resolvedImages,
negative_prompt: input["negative-prompt"] || undefined,
},
parameters,
};
} else {
const content: Array<{ text?: string; image?: string }> = resolvedImages.map((imageUrl) => ({
image: imageUrl,
}));
content.push({ text: input.prompt });
body = {
model,
input: {
messages: [{ role: "user", content }],
},
parameters: {
...parameters,
negative_prompt: input["negative-prompt"] || undefined,
},
};
}
if (route.useSync) {
const response = await env.client.requestJson<DashScopeImageSyncResponse>({
path: url,
path: route.path,
method: "POST",
body,
signal: ctx.signal,
});
const urls = response.output.choices
.flatMap((c) => c.message?.content || [])
.flatMap((choice) => choice.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) };
} else {
const url = imagePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
path: url,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const result = await pollTask(env, taskId, ctx);
const urls = Array.isArray(result.urls) ? (result.urls as string[]) : [];
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
if (saved) result.saved = saved;
return result;
}
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
path: route.path,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const result = await pollTask(env, taskId, ctx);
const urls = Array.isArray(result.urls) ? (result.urls as string[]) : [];
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
if (saved) result.saved = saved;
return result;
}
/**
+12
View File
@@ -65,6 +65,10 @@ bl image edit --image https://example.com/photo.png --prompt "Remove the person"
bl image edit --image ./photo.png --prompt "Change the style" --model wan2.7-image
```
```bash
bl image edit --image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview
```
```bash
bl image edit --image ./photo.png --prompt "Replace the background with a beach" --watermark false
```
@@ -127,6 +131,14 @@ bl image generate --prompt "An alien in the space" --watermark false
bl image generate --prompt "sunset" --model wan2.6-t2i --async --quiet
```
```bash
bl image generate --prompt "plush doll" --model z-image-turbo --size 1024*1024
```
```bash
bl image generate --prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024
```
```bash
bl image generate --prompt "Pro quality" --model qwen-image-2.0-pro
```