🐛 fix: add qwen provider support for image-edit model (#9277)

* 🐛 fix: add qwen provider support for image-edit model

- Register qwen provider in baseRuntimeMap
- Add qwen routing support in NewAPI provider
- Implement qwen-image-edit model with correct multimodal API
- Fix API endpoint and request format for image-to-image generation

Fixes #9184

* Update packages/model-runtime/src/providers/qwen/createImage.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Maple Gao 2025-09-17 04:24:21 +01:00 committed by GitHub
parent 47c6e7fc17
commit e137b33c8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 229 additions and 5 deletions

View file

@ -4,6 +4,7 @@ import { LobeCloudflareAI } from '../../providers/cloudflare';
import { LobeFalAI } from '../../providers/fal';
import { LobeGoogleAI } from '../../providers/google';
import { LobeOpenAI } from '../../providers/openai';
import { LobeQwenAI } from '../../providers/qwen';
import { LobeXAI } from '../../providers/xai';
export const baseRuntimeMap = {
@ -13,5 +14,6 @@ export const baseRuntimeMap = {
fal: LobeFalAI,
google: LobeGoogleAI,
openai: LobeOpenAI,
qwen: LobeQwenAI,
xai: LobeXAI,
};

View file

@ -37,11 +37,11 @@ const handlePayload = (payload: ChatStreamPayload) => {
return payload as any;
};
// 根据 owned_by 字段判断提供商
// 根据 owned_by 字段判断提供商(基于 NewAPI 的 channel name
const getProviderFromOwnedBy = (ownedBy: string): string => {
const normalizedOwnedBy = ownedBy.toLowerCase();
if (normalizedOwnedBy.includes('anthropic') || normalizedOwnedBy.includes('claude')) {
if (normalizedOwnedBy.includes('claude') || normalizedOwnedBy.includes('anthropic')) {
return 'anthropic';
}
if (normalizedOwnedBy.includes('google') || normalizedOwnedBy.includes('gemini')) {
@ -50,6 +50,9 @@ const getProviderFromOwnedBy = (ownedBy: string): string => {
if (normalizedOwnedBy.includes('xai') || normalizedOwnedBy.includes('grok')) {
return 'xai';
}
if (normalizedOwnedBy.includes('ali') || normalizedOwnedBy.includes('qwen')) {
return 'qwen';
}
// 默认为 openai
return 'openai';
@ -149,6 +152,8 @@ export const LobeNewAPIAI = createRouterRuntime({
detectedProvider = 'google';
} else if (model.supported_endpoint_types.includes('xai')) {
detectedProvider = 'xai';
} else if (model.supported_endpoint_types.includes('qwen')) {
detectedProvider = 'qwen';
}
}
// 优先级2使用 owned_by 字段
@ -211,6 +216,16 @@ export const LobeNewAPIAI = createRouterRuntime({
baseURL: urlJoin(userBaseURL, '/v1'),
},
},
{
apiType: 'qwen',
models: LOBE_DEFAULT_MODEL_LIST.map((m) => m.id).filter(
(id) => detectModelProvider(id) === 'qwen',
),
options: {
...options,
baseURL: urlJoin(userBaseURL, '/v1'),
},
},
{
apiType: 'openai',
options: {

View file

@ -591,4 +591,114 @@ describe('createQwenImage', () => {
expect(fetch).toHaveBeenCalledTimes(4);
});
});
describe('qwen-image-edit model', () => {
it('should successfully generate image with qwen-image-edit model', async () => {
const mockImageUrl =
'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/test-generated-image.jpg';
// Mock fetch for multimodal-generation API
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
output: {
choices: [
{
message: {
content: [{ image: mockImageUrl }],
},
},
],
},
request_id: 'req-edit-123',
}),
});
const payload: CreateImagePayload = {
model: 'qwen-image-edit',
params: {
prompt: 'Edit this image to add a cat',
imageUrl: 'https://example.com/source-image.jpg',
},
};
const result = await createQwenImage(payload, mockOptions);
expect(result).toEqual({
imageUrl: mockImageUrl,
});
expect(fetch).toHaveBeenCalled();
const [url, options] = (fetch as any).mock.calls[0];
expect(url).toBe(
'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation',
);
expect(options.method).toBe('POST');
expect(options.headers).toEqual({
'Authorization': 'Bearer test-api-key',
'Content-Type': 'application/json',
});
const body = JSON.parse(options.body);
expect(body).toEqual({
input: {
messages: [
{
content: [
{ image: 'https://example.com/source-image.jpg' },
{ text: 'Edit this image to add a cat' },
],
role: 'user',
},
],
},
model: 'qwen-image-edit',
parameters: {},
});
});
it('should throw error when imageUrl is missing for qwen-image-edit', async () => {
const payload: CreateImagePayload = {
model: 'qwen-image-edit',
params: {
prompt: 'Edit this image',
// imageUrl is missing
},
};
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'qwen',
}),
);
});
it('should handle qwen-image-edit API errors', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({
message: 'Invalid image format',
}),
});
const payload: CreateImagePayload = {
model: 'qwen-image-edit',
params: {
prompt: 'Edit this image',
imageUrl: 'https://example.com/invalid-image.jpg',
},
};
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'qwen',
}),
);
});
});
});

View file

@ -19,8 +19,22 @@ interface QwenImageTaskResponse {
request_id: string;
}
// Interface for qwen-image-edit multimodal-generation response
interface QwenImageEditResponse {
output: {
choices: Array<{
message: {
content: Array<{
image: string;
}>;
};
}>;
};
request_id: string;
}
/**
* Create an image generation task with Qwen API
* Create an image generation task with Qwen API for text-to-image models
*/
async function createImageTask(payload: CreateImagePayload, apiKey: string): Promise<string> {
const { model, params } = payload;
@ -72,6 +86,78 @@ async function createImageTask(payload: CreateImagePayload, apiKey: string): Pro
return data.output.task_id;
}
/**
* Create image with Qwen image-edit API for image-to-image models
* This is a synchronous API that returns the result directly
*/
async function createImageEdit(
payload: CreateImagePayload,
apiKey: string,
): Promise<CreateImageResponse> {
const { model, params } = payload;
const endpoint = `https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`;
log('Creating image edit with model: %s, endpoint: %s', model, endpoint);
if (!params.imageUrl) {
throw new Error('imageUrl is required for qwen-image-edit model');
}
const response = await fetch(endpoint, {
body: JSON.stringify({
input: {
messages: [
{
content: [{ image: params.imageUrl }, { text: params.prompt }],
role: 'user',
},
],
},
model,
parameters: {
// watermark defaults to false (no watermark) unless explicitly requested
},
}),
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
// Failed to parse JSON error response
}
throw new Error(
`Failed to create image edit (${response.status}): ${errorData?.message || response.statusText}`,
);
}
const data: QwenImageEditResponse = await response.json();
if (!data.output.choices || data.output.choices.length === 0) {
throw new Error('No image choices returned from qwen-image-edit API');
}
const choice = data.output.choices[0];
if (!choice.message.content || choice.message.content.length === 0) {
throw new Error('No image content returned from qwen-image-edit API');
}
const imageContent = choice.message.content.find((content) => 'image' in content);
if (!imageContent) {
throw new Error('No image found in response content');
}
const imageUrl = imageContent.image;
log('Image edit generated successfully: %s', imageUrl);
return { imageUrl };
}
/**
* Query the status of an image generation task
*/
@ -102,15 +188,26 @@ async function queryTaskStatus(taskId: string, apiKey: string): Promise<QwenImag
}
/**
* Create image using Qwen Wanxiang API
* This implementation uses async task creation and polling
* Create image using Qwen API
* Supports both text-to-image (async with polling) and image-to-image (sync) workflows
*/
export async function createQwenImage(
payload: CreateImagePayload,
options: CreateImageOptions,
): Promise<CreateImageResponse> {
const { apiKey, provider } = options;
const { model } = payload;
try {
// Check if this is qwen-image-edit model for image-to-image
if (model === 'qwen-image-edit') {
log('Using multimodal-generation API for qwen-image-edit model');
return await createImageEdit(payload, apiKey);
}
// Default to text-to-image workflow for other qwen models
log('Using text2image API for model: %s', model);
// 1. Create image generation task
const taskId = await createImageTask(payload, apiKey);