💄 style: add Tencent Hunyuan 3.0 ImageGen support (#13166)

This commit is contained in:
Zhijie He
2026-03-22 12:54:27 +08:00
committed by GitHub
parent f9166133a7
commit 7af4562a60
4 changed files with 802 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import type { AIChatModelCard } from '../types/aiModel';
import type { AIChatModelCard, AIImageModelCard } from '../types/aiModel';
// https://cloud.tencent.com/document/product/1729/104753
const hunyuanChatModels: AIChatModelCard[] = [
@@ -513,6 +513,31 @@ const hunyuanChatModels: AIChatModelCard[] = [
},
];
export const allModels = [...hunyuanChatModels];
const hunyuanImageModels: AIImageModelCard[] = [
{
description:
'Powerful original-image feature extraction and detail preservation capabilities, delivering richer visual texture and producing high-accuracy, well-composed, production-grade visuals.',
displayName: 'HY-Image-V3.0',
enabled: true,
id: 'HY-Image-V3.0',
parameters: {
height: { default: 1024, max: 2048, min: 512, step: 1 },
imageUrls: { default: [], maxCount: 3 },
prompt: {
default: '',
},
seed: { default: null },
width: { default: 1024, max: 2048, min: 512, step: 1 },
},
pricing: {
currency: 'CNY',
units: [{ name: 'imageGeneration', rate: 0.2, strategy: 'fixed', unit: 'image' }],
},
releasedAt: '2026-01-26',
type: 'image',
},
];
export const allModels = [...hunyuanChatModels, ...hunyuanImageModels];
export default allModels;

View File

@@ -0,0 +1,543 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CreateImageOptions } from '../../core/openaiCompatibleFactory';
import type { CreateImagePayload } from '../../types/image';
import { createHunyuanImage } from './createImage';
vi.spyOn(console, 'error').mockImplementation(() => {});
const mockOptions: CreateImageOptions = {
apiKey: 'sk-test-api-key',
baseURL: 'https://api.cloudai.tencent.com/v1',
provider: 'hunyuan',
};
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe('createHunyuanImage', () => {
describe('Success scenarios', () => {
it('should successfully generate image with basic prompt', async () => {
const mockJobId = '1301052320-1774048771-3ff52e2c-24b3-11f1-aca3-525400cc0b9a-0';
const mockImageUrl = 'https://aiart-1258344699.cos.ap-guangzhou.myqcloud.com/test/image.png';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
request_id: 'req-123',
job_id: mockJobId,
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
request_id: 'req-456',
status: '5',
data: [{ url: mockImageUrl }],
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: {
prompt: '生成一个可爱猫猫',
},
};
const resultPromise = createHunyuanImage(payload, mockOptions);
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
const submitCall = (fetch as any).mock.calls[0];
expect(submitCall[0]).toBe('https://api.cloudai.tencent.com/v1/aiart/submit');
const submitBody = JSON.parse(submitCall[1].body);
expect(submitBody).toEqual({
model: 'HY-Image-V3.0',
prompt: '生成一个可爱猫猫',
size: '1024:1024',
extra_body: {
logo_add: 0,
},
});
const queryCall = (fetch as any).mock.calls[1];
expect(queryCall[0]).toBe('https://api.cloudai.tencent.com/v1/aiart/query');
const queryBody = JSON.parse(queryCall[1].body);
expect(queryBody).toEqual({ job_id: mockJobId });
expect(result).toEqual({
imageUrl: mockImageUrl,
});
});
it('should handle custom size parameter', async () => {
const mockJobId = 'job-custom-size';
const mockImageUrl = 'https://aiart.tencent.com/test/custom.png';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '5',
data: [{ url: mockImageUrl }],
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: {
prompt: 'Custom size test',
size: '1024x1024',
},
};
const resultPromise = createHunyuanImage(payload, mockOptions);
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
const submitBody = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(submitBody.size).toBe('1024:1024');
expect(result.imageUrl).toBe(mockImageUrl);
});
it('should handle width and height parameters', async () => {
const mockJobId = 'job-dims';
const mockImageUrl = 'https://aiart.tencent.com/test/dims.png';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '5',
data: [{ url: mockImageUrl }],
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: {
prompt: 'Custom dimensions',
width: 1024,
height: 768,
},
};
const resultPromise = createHunyuanImage(payload, mockOptions);
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
const submitBody = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(submitBody.size).toBe('1024:768');
expect(result.imageUrl).toBe(mockImageUrl);
});
it('should handle seed parameter', async () => {
const mockJobId = 'job-seed';
const mockImageUrl = 'https://aiart.tencent.com/test/seed.png';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '5',
data: [{ url: mockImageUrl }],
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: {
prompt: 'With seed',
seed: 84445,
},
};
const resultPromise = createHunyuanImage(payload, mockOptions);
await vi.advanceTimersByTimeAsync(1000);
await resultPromise;
const submitBody = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(submitBody.extra_body.seed).toBe(84445);
});
it('should handle imageUrls for image-to-image', async () => {
const mockJobId = 'job-img2img';
const mockImageUrl = 'https://aiart.tencent.com/test/edited.png';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '5',
data: [{ url: mockImageUrl }],
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: {
prompt: 'Add a cat',
imageUrls: ['https://example.com/source.png'],
},
};
const resultPromise = createHunyuanImage(payload, mockOptions);
await vi.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
const submitBody = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(submitBody.images).toEqual(['https://example.com/source.png']);
expect(result.imageUrl).toBe(mockImageUrl);
});
it('should poll multiple times until completion', async () => {
const mockJobId = 'job-polling';
const mockImageUrl = 'https://aiart.tencent.com/test/final.png';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: '1' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: '2' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '5',
data: [{ url: mockImageUrl }],
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: {
prompt: 'Polling test',
},
};
const resultPromise = createHunyuanImage(payload, mockOptions);
await vi.advanceTimersByTimeAsync(2000);
const result = await resultPromise;
expect(result.imageUrl).toBe(mockImageUrl);
expect((fetch as any).mock.calls.length).toBe(4);
});
});
describe('Error scenarios - Submit endpoint', () => {
it('should handle 401 unauthorized error', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({
error: {
message: 'Incorrect API key provided',
type: 'invalid_request_error',
},
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
await expect(createHunyuanImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
});
it('should handle image download error', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
request_id: 'req-123',
job_id: '',
error: {
message: '图片下载错误。',
type: 'api_error',
code: 'FailedOperation.ImageDownloadError',
},
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
await expect(createHunyuanImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
});
it('should handle missing job_id', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ request_id: 'req-123' }),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
await expect(createHunyuanImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
});
it('should handle HTTP error with error.message format', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({
message: 'Invalid prompt',
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Invalid' },
};
await expect(createHunyuanImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
});
});
describe('Error scenarios - Query endpoint', () => {
it('should handle API error in query response', async () => {
const mockJobId = 'job-query-error';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '',
error: {
message: 'Unknown job status: ',
type: 'api_error',
},
}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
try {
await createHunyuanImage(payload, mockOptions);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
}
});
it('should handle missing status in query response', async () => {
const mockJobId = 'job-no-status';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
try {
await createHunyuanImage(payload, mockOptions);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
}
});
it('should handle failed status', async () => {
const mockJobId = 'job-failed';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: '4' }),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
try {
await createHunyuanImage(payload, mockOptions);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
}
});
it('should handle completed status with empty data', async () => {
const mockJobId = 'job-empty-data';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: '5', data: null }),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
try {
await createHunyuanImage(payload, mockOptions);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
}
});
it('should handle completed status with empty images array', async () => {
const mockJobId = 'job-empty-images';
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ job_id: mockJobId }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: '5', data: [] }),
});
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
try {
await createHunyuanImage(payload, mockOptions);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
}
});
it('should handle network error', async () => {
global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'));
const payload: CreateImagePayload = {
model: 'HY-Image-V3.0',
params: { prompt: 'Test' },
};
await expect(createHunyuanImage(payload, mockOptions)).rejects.toEqual(
expect.objectContaining({
errorType: 'ProviderBizError',
provider: 'hunyuan',
}),
);
});
});
});

View File

@@ -0,0 +1,230 @@
import createDebug from 'debug';
import type { CreateImageOptions } from '../../core/openaiCompatibleFactory';
import type { CreateImagePayload, CreateImageResponse } from '../../types/image';
import { asyncifyPolling } from '../../utils/asyncifyPolling';
import { AgentRuntimeError } from '../../utils/createError';
const log = createDebug('lobe-image:hunyuan');
interface HunyuanImageSubmitResponse {
error?: {
code?: string;
message?: string;
type?: string;
};
job_id?: string;
request_id?: string;
}
interface HunyuanImageQueryResponse {
data?: Array<{
url: string;
}> | null;
error?: {
code?: string;
message?: string;
type?: string;
};
request_id?: string;
status?: string;
}
// Hunyuan3.0 ImageGen Status Code
// https://cloud.tencent.com/document/product/1668/124633
const getStatusName = (status: string): string => {
const statusMap: Record<string, string> = {
'1': 'PENDING',
'2': 'PROCESSING',
'4': 'FAILED',
'5': 'COMPLETED',
};
return statusMap[status] || `UNKNOWN(${status})`;
};
export async function createHunyuanImage(
payload: CreateImagePayload,
options: CreateImageOptions,
): Promise<CreateImageResponse> {
const { apiKey, provider } = options;
const { model, params } = payload;
// Hunyuan3.0 ImageGen BaseURL
// https://cloud.tencent.com/document/product/1668/129429
const baseURL = options.baseURL || 'https://api.cloudai.tencent.com/v1';
try {
log('Starting Hunyuan image generation with model: %s and params: %O', model, params);
const submitUrl = `${baseURL}/aiart/submit`;
const submitBody: Record<string, any> = {
model,
prompt: params.prompt,
...(params.width && params.height
? { size: `${params.width}:${params.height}` }
: params.size
? { size: params.size.replace('x', ':') }
: { size: '1024:1024' }),
...(params.imageUrls && params.imageUrls.length > 0
? { images: params.imageUrls }
: params.imageUrl
? { images: [params.imageUrl] }
: {}),
extra_body: {
logo_add: 0, // Add Watermark: 0 disabled, 1 enabled
...(typeof params.seed === 'number' ? { seed: params.seed } : {}),
},
};
log('Submitting task to: %s', submitUrl);
log('Submit body: %O', submitBody);
const submitResponse = await fetch(submitUrl, {
body: JSON.stringify(submitBody),
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!submitResponse.ok) {
let errorData;
try {
errorData = await submitResponse.json();
} catch {}
const errorMessage =
typeof errorData?.error?.message === 'string'
? errorData.error.message
: typeof errorData?.message === 'string'
? errorData.message
: JSON.stringify(errorData || submitResponse.statusText);
throw new Error(`Hunyuan API submit error (${submitResponse.status}): ${errorMessage}`);
}
const submitData: HunyuanImageSubmitResponse = await submitResponse.json();
log('Submit response: %O', submitData);
if (submitData.error?.message) {
throw new Error(`Hunyuan API error: ${submitData.error.message}`);
}
if (!submitData.job_id) {
throw new Error(
`No job_id returned from submit endpoint. Response: ${JSON.stringify(submitData)}`,
);
}
const jobId = submitData.job_id;
log('Task submitted successfully, job_id: %s', jobId);
const queryUrl = `${baseURL}/aiart/query`;
const result = await asyncifyPolling<HunyuanImageQueryResponse, CreateImageResponse>({
checkStatus: (taskStatus: HunyuanImageQueryResponse): any => {
log('Checking task status: %O', taskStatus);
if (taskStatus.error?.message) {
log('API error response: %s', taskStatus.error.message);
return {
error: new Error(`Hunyuan API error: ${taskStatus.error.message}`),
status: 'failed',
};
}
const status = taskStatus.status;
// Status return an empty string if query got an error
if (!status) {
return {
error: new Error('Invalid query response: missing status'),
status: 'failed',
};
}
log('Task status: %s', getStatusName(status));
// Task completed
if (status === '5') {
if (!taskStatus.data || !Array.isArray(taskStatus.data) || taskStatus.data.length === 0) {
return {
error: new Error('Task completed but no images generated'),
status: 'failed',
};
}
const imageUrl = taskStatus.data[0].url;
if (!imageUrl) {
return {
error: new Error('No valid image URL in response'),
status: 'failed',
};
}
log('Image generation completed successfully: %s', imageUrl);
return {
data: { imageUrl },
status: 'success',
};
}
// Task failed
if (status === '4') {
return {
error: new Error('Task failed'),
status: 'failed',
};
}
return { status: 'pending' };
},
logger: {
debug: (message: any, ...args: any[]) => log(message, ...args),
error: (message: any, ...args: any[]) => log(message, ...args),
},
maxConsecutiveFailures: 5,
maxRetries: 60,
pollingQuery: async () => {
log('Polling task status for job_id: %s', jobId);
const queryResponse = await fetch(queryUrl, {
body: JSON.stringify({ job_id: jobId }),
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!queryResponse.ok) {
let errorData;
try {
errorData = await queryResponse.json();
} catch {}
const errorMessage =
typeof errorData?.message === 'string'
? errorData.message
: JSON.stringify(errorData || queryResponse.statusText);
throw new Error(`Hunyuan API query error (${queryResponse.status}): ${errorMessage}`);
}
return await queryResponse.json();
},
});
log('Image generation completed: %O', result);
return result;
} catch (error) {
log('Error in createHunyuanImage: %O', error);
throw AgentRuntimeError.createImage({
error: error as any,
errorType: 'ProviderBizError',
provider,
});
}
}

View File

@@ -3,6 +3,7 @@ import { ModelProvider } from 'model-bank';
import type { OpenAICompatibleFactoryOptions } from '../../core/openaiCompatibleFactory';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { createHunyuanImage } from './createImage';
export interface HunyuanModelCard {
id: string;
@@ -38,6 +39,7 @@ export const params = {
} as any;
},
},
createImage: createHunyuanImage,
debug: {
chatCompletion: () => process.env.DEBUG_HUNYUAN_CHAT_COMPLETION === '1',
},