@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
513 lines (410 loc) • 17.2 kB
text/typescript
// @vitest-environment node
import { ModelProvider } from 'model-bank';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeOpenAICompatibleRuntime } from '../../core/BaseAI';
import { testProvider } from '../../providerTestUtils';
import { LobeHunyuanAI, params } from './index';
testProvider({
Runtime: LobeHunyuanAI,
provider: ModelProvider.Hunyuan,
defaultBaseURL: 'https://api.hunyuan.cloud.tencent.com/v1',
chatDebugEnv: 'DEBUG_HUNYUAN_CHAT_COMPLETION',
chatModel: 'hunyuan-lite',
});
// Mock the console.error to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
let instance: LobeOpenAICompatibleRuntime;
beforeEach(() => {
instance = new LobeHunyuanAI({ apiKey: 'test' });
// 使用 vi.spyOn 来模拟 chat.completions.create 方法
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
new ReadableStream() as any,
);
});
describe('LobeHunyuanAI', () => {
describe('chat', () => {
it('should with search citations', async () => {
const data = [
{
id: '939fbdb8dbb9b4c5944cbbe687c977c2',
object: 'chat.completion.chunk',
created: 1741000456,
model: 'hunyuan-turbo',
system_fingerprint: '',
choices: [
{
index: 0,
delta: { role: 'assistant', content: '为您' },
finish_reason: null,
},
],
note: '以上内容为AI生成,不代表开发者立场,请勿删除或修改本标记',
search_info: {
search_results: [
{
index: 1,
title: '公务员考试时政热点【2025年3月3日】_公务员考试网_华图教育',
url: 'http://www.huatu.com/2025/0303/2803685.html',
icon: 'https://hunyuan-img-1251316161.cos.ap-guangzhou.myqcloud.com/%2Fpublic/img/63ce96deffe0119827f12deaa5ffe7ef.jpg',
text: '华图教育官网',
},
{
index: 2,
title: '外交部新闻(2025年3月3日)',
url: 'https://view.inews.qq.com/a/20250303A02NLC00?scene=qqsearch',
icon: 'https://hunyuan-img-1251316161.cos.ap-guangzhou.myqcloud.com/%2Fpublic/img/00ce40298870d1accb7920d641152722.jpg',
text: '腾讯网',
},
],
},
},
{
id: '939fbdb8dbb9b4c5944cbbe687c977c2',
object: 'chat.completion.chunk',
created: 1741000456,
model: 'hunyuan-turbo',
system_fingerprint: '',
choices: [
{
index: 0,
delta: { role: 'assistant', content: '找到' },
finish_reason: null,
},
],
note: '以上内容为AI生成,不代表开发者立场,请勿删除或修改本标记',
search_info: {
search_results: [
{
index: 1,
title: '公务员考试时政热点【2025年3月3日】_公务员考试网_华图教育',
url: 'http://www.huatu.com/2025/0303/2803685.html',
icon: 'https://hunyuan-img-1251316161.cos.ap-guangzhou.myqcloud.com/%2Fpublic/img/63ce96deffe0119827f12deaa5ffe7ef.jpg',
text: '华图教育官网',
},
{
index: 2,
title: '外交部新闻(2025年3月3日)',
url: 'https://view.inews.qq.com/a/20250303A02NLC00?scene=qqsearch',
icon: 'https://hunyuan-img-1251316161.cos.ap-guangzhou.myqcloud.com/%2Fpublic/img/00ce40298870d1accb7920d641152722.jpg',
text: '腾讯网',
},
],
},
},
];
const mockStream = new ReadableStream({
start(controller) {
data.forEach((chunk) => {
controller.enqueue(chunk);
});
controller.close();
},
});
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(mockStream as any);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
temperature: 0,
});
const decoder = new TextDecoder();
const reader = result.body!.getReader();
const stream: string[] = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
stream.push(decoder.decode(value));
}
expect(stream).toEqual(
[
'id: 939fbdb8dbb9b4c5944cbbe687c977c2',
'event: grounding',
'data: {"citations":[{"title":"公务员考试时政热点【2025年3月3日】_公务员考试网_华图教育","url":"http://www.huatu.com/2025/0303/2803685.html"},{"title":"外交部新闻(2025年3月3日)","url":"https://view.inews.qq.com/a/20250303A02NLC00?scene=qqsearch"}]}\n',
'id: 939fbdb8dbb9b4c5944cbbe687c977c2',
'event: text',
'data: "为您"\n',
'id: 939fbdb8dbb9b4c5944cbbe687c977c2',
'event: text',
'data: "找到"\n',
].map((line) => `${line}\n`),
);
expect((await reader.read()).done).toBe(true);
});
});
});
describe('LobeHunyuanAI - custom features', () => {
describe('Debug Configuration', () => {
it('should disable debug by default', () => {
delete process.env.DEBUG_HUNYUAN_CHAT_COMPLETION;
const result = params.debug.chatCompletion();
expect(result).toBe(false);
});
it('should enable debug when env is set', () => {
process.env.DEBUG_HUNYUAN_CHAT_COMPLETION = '1';
const result = params.debug.chatCompletion();
expect(result).toBe(true);
delete process.env.DEBUG_HUNYUAN_CHAT_COMPLETION;
});
});
describe('handlePayload', () => {
const handlePayload = params.chatCompletion.handlePayload!;
it('should remove frequency_penalty and presence_penalty from payload', () => {
const payload = {
model: 'hunyuan-lite',
messages: [{ role: 'user', content: 'test' }],
frequency_penalty: 0.5,
presence_penalty: 0.3,
temperature: 0.7,
} as any;
const result = handlePayload(payload);
expect(result.frequency_penalty).toBeUndefined();
expect(result.presence_penalty).toBeUndefined();
expect(result.model).toBe('hunyuan-lite');
expect(result.temperature).toBe(0.7);
expect(result.stream).toBe(true);
});
it('should add search fields when enabledSearch is true', () => {
const payload = {
model: 'hunyuan-turbo',
messages: [{ role: 'user', content: 'test' }],
enabledSearch: true,
} as any;
const result = handlePayload(payload);
expect(result.citation).toBe(true);
expect(result.enable_enhancement).toBe(true);
expect(result.search_info).toBe(true);
expect(result.enabledSearch).toBeUndefined();
});
it('should respect HUNYUAN_ENABLE_SPEED_SEARCH env when search is enabled', () => {
process.env.HUNYUAN_ENABLE_SPEED_SEARCH = '1';
const payload = {
model: 'hunyuan-turbo',
messages: [{ role: 'user', content: 'test' }],
enabledSearch: true,
} as any;
const result = handlePayload(payload);
expect(result.enable_speed_search).toBe(true);
delete process.env.HUNYUAN_ENABLE_SPEED_SEARCH;
});
it('should not enable speed search by default', () => {
delete process.env.HUNYUAN_ENABLE_SPEED_SEARCH;
const payload = {
model: 'hunyuan-turbo',
messages: [{ role: 'user', content: 'test' }],
enabledSearch: true,
} as any;
const result = handlePayload(payload);
expect(result.enable_speed_search).toBe(false);
});
it('should not add search fields when enabledSearch is false', () => {
const payload = {
model: 'hunyuan-lite',
messages: [{ role: 'user', content: 'test' }],
enabledSearch: false,
} as any;
const result = handlePayload(payload);
expect(result.citation).toBeUndefined();
expect(result.enable_enhancement).toBeUndefined();
expect(result.search_info).toBeUndefined();
expect(result.enable_speed_search).toBeUndefined();
});
it('should enable thinking for hunyuan-a13b when thinking.type is enabled', () => {
const payload = {
model: 'hunyuan-a13b',
messages: [{ role: 'user', content: 'test' }],
thinking: { type: 'enabled' },
} as any;
const result = handlePayload(payload);
expect(result.enable_thinking).toBe(true);
expect(result.thinking).toBeUndefined();
});
it('should disable thinking for hunyuan-a13b when thinking.type is disabled', () => {
const payload = {
model: 'hunyuan-a13b',
messages: [{ role: 'user', content: 'test' }],
thinking: { type: 'disabled' },
} as any;
const result = handlePayload(payload);
expect(result.enable_thinking).toBe(false);
});
it('should set thinking to undefined for hunyuan-a13b when thinking.type is undefined', () => {
const payload = {
model: 'hunyuan-a13b',
messages: [{ role: 'user', content: 'test' }],
thinking: { type: undefined },
} as any;
const result = handlePayload(payload);
expect(result.enable_thinking).toBeUndefined();
});
it('should not add enable_thinking for non-hunyuan-a13b models', () => {
const payload = {
model: 'hunyuan-lite',
messages: [{ role: 'user', content: 'test' }],
thinking: { type: 'enabled' },
} as any;
const result = handlePayload(payload);
expect(result.enable_thinking).toBeUndefined();
});
it('should handle combined enabledSearch and thinking for hunyuan-a13b', () => {
process.env.HUNYUAN_ENABLE_SPEED_SEARCH = '1';
const payload = {
model: 'hunyuan-a13b',
messages: [{ role: 'user', content: 'test' }],
enabledSearch: true,
thinking: { type: 'enabled' },
temperature: 0.5,
} as any;
const result = handlePayload(payload);
expect(result.citation).toBe(true);
expect(result.enable_enhancement).toBe(true);
expect(result.search_info).toBe(true);
expect(result.enable_speed_search).toBe(true);
expect(result.enable_thinking).toBe(true);
expect(result.temperature).toBe(0.5);
expect(result.stream).toBe(true);
delete process.env.HUNYUAN_ENABLE_SPEED_SEARCH;
});
});
describe('models', () => {
const mockClient = {
models: {
list: vi.fn(),
},
} as any;
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch and process models with function call detection', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{ id: 'hunyuan-functioncall' },
{ id: 'hunyuan-turbo' },
{ id: 'hunyuan-pro' },
{ id: 'hunyuan-lite' },
],
});
const models = await params.models({ client: mockClient });
expect(mockClient.models.list).toHaveBeenCalledTimes(1);
expect(models).toHaveLength(4);
const functionCallModel = models.find((m) => m.id === 'hunyuan-functioncall');
expect(functionCallModel?.functionCall).toBe(true);
const turboModel = models.find((m) => m.id === 'hunyuan-turbo');
expect(turboModel?.functionCall).toBe(true);
const proModel = models.find((m) => m.id === 'hunyuan-pro');
expect(proModel?.functionCall).toBe(true);
const liteModel = models.find((m) => m.id === 'hunyuan-lite');
expect(liteModel?.functionCall).toBe(false);
});
it('should not enable function call for vision models even with keywords', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'hunyuan-turbo-vision' }, { id: 'hunyuan-functioncall-vision' }],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(2);
const visionModel1 = models.find((m) => m.id === 'hunyuan-turbo-vision');
expect(visionModel1?.functionCall).toBe(false);
expect(visionModel1?.vision).toBe(true);
const visionModel2 = models.find((m) => m.id === 'hunyuan-functioncall-vision');
expect(visionModel2?.functionCall).toBe(false);
expect(visionModel2?.vision).toBe(true);
});
it('should detect reasoning models', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'hunyuan-t1' }, { id: 'hunyuan-lite' }],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(2);
const reasoningModel = models.find((m) => m.id === 'hunyuan-t1');
expect(reasoningModel?.reasoning).toBe(true);
const normalModel = models.find((m) => m.id === 'hunyuan-lite');
expect(normalModel?.reasoning).toBe(false);
});
it('should detect vision models', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'hunyuan-vision' }, { id: 'hunyuan-lite-vision' }, { id: 'hunyuan-lite' }],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(3);
const visionModel1 = models.find((m) => m.id === 'hunyuan-vision');
expect(visionModel1?.vision).toBe(true);
const visionModel2 = models.find((m) => m.id === 'hunyuan-lite-vision');
expect(visionModel2?.vision).toBe(true);
const normalModel = models.find((m) => m.id === 'hunyuan-lite');
expect(normalModel?.vision).toBe(false);
});
it('should merge with LOBE_DEFAULT_MODEL_LIST for known models', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'hunyuan-lite' }],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(1);
const model = models[0];
// LOBE_DEFAULT_MODEL_LIST should provide these values
expect(model.id).toBe('hunyuan-lite');
expect(model.enabled).toBeDefined();
});
it('should handle unknown models with defaults', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'hunyuan-unknown-model' }],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(1);
const model = models[0];
expect(model.id).toBe('hunyuan-unknown-model');
expect(model.enabled).toBe(false);
expect(model.contextWindowTokens).toBeUndefined();
expect(model.displayName).toBeUndefined();
});
it('should handle case-insensitive model matching', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'HUNYUAN-LITE' }],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(1);
expect(models[0].id).toBe('HUNYUAN-LITE');
});
it('should combine multiple abilities correctly', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{ id: 'hunyuan-turbo' },
{ id: 'hunyuan-t1' },
{ id: 'hunyuan-vision' },
{ id: 'hunyuan-turbo-vision' },
],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(4);
const turboModel = models.find((m) => m.id === 'hunyuan-turbo');
expect(turboModel?.functionCall).toBe(true);
expect(turboModel?.reasoning).toBe(false);
expect(turboModel?.vision).toBe(false);
const reasoningModel = models.find((m) => m.id === 'hunyuan-t1');
expect(reasoningModel?.functionCall).toBe(false);
expect(reasoningModel?.reasoning).toBe(true);
expect(reasoningModel?.vision).toBe(false);
const visionModel = models.find((m) => m.id === 'hunyuan-vision');
expect(visionModel?.functionCall).toBe(false);
expect(visionModel?.reasoning).toBe(false);
expect(visionModel?.vision).toBe(true);
const turboVisionModel = models.find((m) => m.id === 'hunyuan-turbo-vision');
expect(turboVisionModel?.functionCall).toBe(false);
expect(turboVisionModel?.reasoning).toBe(false);
expect(turboVisionModel?.vision).toBe(true);
});
it('should handle empty model list', async () => {
mockClient.models.list.mockResolvedValue({
data: [],
});
const models = await params.models({ client: mockClient });
expect(models).toHaveLength(0);
});
it('should filter out falsy values', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'hunyuan-lite' }, null, undefined],
});
const models = await params.models({ client: mockClient });
// Only valid models should be included
expect(models.length).toBeGreaterThan(0);
expect(models.every((m) => m !== null && m !== undefined)).toBe(true);
});
});
});