mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🐛 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:
parent
47c6e7fc17
commit
e137b33c8d
4 changed files with 229 additions and 5 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue