@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.
986 lines (860 loc) • 30 kB
text/typescript
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LobeOpenAICompatibleRuntime } from '../../core/BaseAI';
import { testProvider } from '../../providerTestUtils';
import { LobeZhipuAI, params } from './index';
testProvider({
provider: 'zhipu',
defaultBaseURL: 'https://open.bigmodel.cn/api/paas/v4',
chatModel: 'glm-4',
Runtime: LobeZhipuAI,
chatDebugEnv: 'DEBUG_ZHIPU_CHAT_COMPLETION',
test: {
skipAPICall: true, // Skip because Zhipu has custom handlePayload that normalizes temperature
},
});
// Mock the console.error to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
let instance: LobeOpenAICompatibleRuntime;
beforeEach(() => {
instance = new LobeZhipuAI({ apiKey: 'test' });
// Mock chat.completions.create
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
new ReadableStream() as any,
);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('LobeZhipuAI - custom features', () => {
describe('Debug Configuration', () => {
it('should disable debug by default', () => {
delete process.env.DEBUG_ZHIPU_CHAT_COMPLETION;
const result = params.debug.chatCompletion();
expect(result).toBe(false);
});
it('should enable debug when env is set', () => {
process.env.DEBUG_ZHIPU_CHAT_COMPLETION = '1';
const result = params.debug.chatCompletion();
expect(result).toBe(true);
delete process.env.DEBUG_ZHIPU_CHAT_COMPLETION;
});
});
describe('handlePayload', () => {
describe('Web Search Feature', () => {
it('should add web_search tool when enabledSearch is true', async () => {
await instance.chat({
enabledSearch: true,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
expect.objectContaining({
type: 'web_search',
web_search: expect.objectContaining({
enable: true,
result_sequence: 'before',
search_engine: 'search_std',
search_result: true,
}),
}),
]),
}),
expect.anything(),
);
});
it('should use custom search engine from env', async () => {
process.env.ZHIPU_SEARCH_ENGINE = 'search_pro';
const payload = params.chatCompletion.handlePayload({
enabledSearch: true,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
});
expect(payload.tools).toContainEqual(
expect.objectContaining({
type: 'web_search',
web_search: expect.objectContaining({
search_engine: 'search_pro',
}),
}),
);
delete process.env.ZHIPU_SEARCH_ENGINE;
});
it('should merge web_search with existing tools', async () => {
const existingTool = {
type: 'function' as const,
function: { name: 'test_tool', parameters: {} },
};
await instance.chat({
enabledSearch: true,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0,
tools: [existingTool],
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
existingTool,
expect.objectContaining({ type: 'web_search' }),
]),
}),
expect.anything(),
);
});
it('should not add web_search tool when enabledSearch is false', async () => {
await instance.chat({
enabledSearch: false,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0,
});
const callArgs = (instance['client'].chat.completions.create as any).mock.calls[0][0];
expect(callArgs.tools).toBeUndefined();
});
it('should use existing tools without web_search when enabledSearch is not set', async () => {
const existingTool = {
type: 'function' as const,
function: { name: 'test_tool', parameters: {} },
};
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0,
tools: [existingTool],
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: [existingTool],
}),
expect.anything(),
);
});
});
describe('Model-specific max_tokens constraints', () => {
it('should limit max_tokens to 1024 for glm-4v models', async () => {
await instance.chat({
max_tokens: 2000,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4v',
temperature: 0.5,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
max_tokens: 1024,
}),
expect.anything(),
);
});
it('should limit max_tokens to 15300 for glm-zero-preview model', async () => {
await instance.chat({
max_tokens: 20000,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-zero-preview',
temperature: 0.5,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
max_tokens: 15_300,
}),
expect.anything(),
);
});
it('should allow max_tokens lower than constraint for glm-4v', async () => {
await instance.chat({
max_tokens: 512,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4v',
temperature: 0.5,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
max_tokens: 512,
}),
expect.anything(),
);
});
it('should not limit max_tokens for other models', async () => {
await instance.chat({
max_tokens: 4000,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0.5,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
max_tokens: 4000,
}),
expect.anything(),
);
});
});
describe('glm-4-alltools temperature and top_p constraints', () => {
it('should clamp temperature to [0.01, 0.99] for glm-4-alltools', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4-alltools',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.01,
}),
expect.anything(),
);
});
it('should clamp high temperature to 0.99 for glm-4-alltools', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4-alltools',
temperature: 2.0, // Will be normalized to 1.0, then clamped to 0.99
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.99,
}),
expect.anything(),
);
});
it('should clamp top_p to [0.01, 0.99] for glm-4-alltools', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4-alltools',
temperature: 0.5,
top_p: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
top_p: 0.01,
}),
expect.anything(),
);
});
it('should clamp high top_p to 0.99 for glm-4-alltools', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4-alltools',
temperature: 0.5,
top_p: 1,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
top_p: 0.99,
}),
expect.anything(),
);
});
it('should normalize and preserve temperature in range for glm-4-alltools', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4-alltools',
temperature: 1.0, // Will be normalized to 0.5
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
expect.anything(),
);
});
});
describe('Temperature normalization', () => {
it('should normalize temperature by dividing by 2', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 1.0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.5,
}),
expect.anything(),
);
});
it('should normalize high temperature', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 1.6,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.8,
}),
expect.anything(),
);
});
it('should handle temperature 0 correctly', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0,
}),
expect.anything(),
);
});
});
describe('Thinking mode for GLM-4.5 models', () => {
it('should include thinking type for glm-4.5 models', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4.5',
temperature: 0.5,
thinking: { type: 'enabled', budget_tokens: 1000 },
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: 'enabled' },
}),
expect.anything(),
);
});
it('should include thinking for glm-4.5-turbo', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4.5-turbo',
temperature: 0.5,
thinking: { type: 'disabled', budget_tokens: 0 },
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: 'disabled' },
}),
expect.anything(),
);
});
it('should not include thinking for non-4.5 models', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0.5,
thinking: { type: 'enabled', budget_tokens: 1000 },
});
const callArgs = (instance['client'].chat.completions.create as any).mock.calls[0][0];
expect(callArgs.thinking).toBeUndefined();
});
it('should handle undefined thinking gracefully for 4.5 models', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4.5',
temperature: 0.5,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
thinking: { type: undefined },
}),
expect.anything(),
);
});
});
describe('Stream parameter', () => {
it('should always set stream to true', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0.5,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
stream: true,
}),
expect.anything(),
);
});
it('should override stream parameter to true', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
stream: false,
temperature: 0.5,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
stream: true,
}),
expect.anything(),
);
});
});
describe('Preserve other payload properties', () => {
it('should preserve all other properties', async () => {
await instance.chat({
frequency_penalty: 0.5,
max_tokens: 100,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
presence_penalty: 0.3,
temperature: 0.5,
top_p: 0.9,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
frequency_penalty: 0.5,
max_tokens: 100,
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
presence_penalty: 0.3,
temperature: 0.25, // Normalized from 0.5
top_p: 0.9,
}),
expect.anything(),
);
});
});
});
describe('handleStream', () => {
describe('Tool calls index fixing for GLM-4.5', () => {
it('should fix negative tool_calls index to positive', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue({
choices: [
{
delta: {
tool_calls: [
{ index: -1, id: 'call_1', function: { name: 'tool1', arguments: '{}' } },
{ index: -1, id: 'call_2', function: { name: 'tool2', arguments: '{}' } },
],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4.5',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4.5',
temperature: 0.5,
});
// Read the stream to trigger the transform
const reader = result.body?.getReader();
const chunks = [];
if (reader) {
let done = false;
while (!done) {
const { value, done: isDone } = await reader.read();
done = isDone;
if (value) {
chunks.push(new TextDecoder().decode(value));
}
}
}
// The transform should have fixed the negative indices
expect(chunks).toBeDefined();
});
it('should preserve positive tool_calls index', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue({
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: 'call_1', function: { name: 'tool1', arguments: '{}' } },
{ index: 1, id: 'call_2', function: { name: 'tool2', arguments: '{}' } },
],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0.5,
});
// Read the stream
const reader = result.body?.getReader();
if (reader) {
let done = false;
while (!done) {
const { value, done: isDone } = await reader.read();
done = isDone;
}
}
expect(result).toBeDefined();
});
it('should handle chunks without tool_calls', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue({
choices: [
{
delta: {
content: 'Hello',
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0.5,
});
expect(result).toBeInstanceOf(Response);
});
it('should handle chunks without choices', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue({
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0.5,
});
expect(result).toBeInstanceOf(Response);
});
it('should handle empty tool_calls array', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue({
choices: [
{
delta: {
tool_calls: [],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4',
temperature: 0.5,
});
expect(result).toBeInstanceOf(Response);
});
it('should handle mixed tool_calls indices', async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue({
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: 'call_1', function: { name: 'tool1', arguments: '{}' } },
{ index: -1, id: 'call_2', function: { name: 'tool2', arguments: '{}' } },
{ index: 2, id: 'call_3', function: { name: 'tool3', arguments: '{}' } },
],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4.5',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4.5',
temperature: 0.5,
});
// Read the stream to trigger the transform
const reader = result.body?.getReader();
if (reader) {
let done = false;
while (!done) {
const { value, done: isDone } = await reader.read();
done = isDone;
}
}
expect(result).toBeDefined();
});
it('should handle multiple chunks with tool_calls', async () => {
const mockStream = new ReadableStream({
start(controller) {
// First chunk with tool_call
controller.enqueue({
choices: [
{
delta: {
tool_calls: [{ index: -1, id: 'call_1', function: { name: 'tool1' } }],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4.5',
object: 'chat.completion.chunk',
});
// Second chunk with arguments
controller.enqueue({
choices: [
{
delta: {
tool_calls: [{ index: -1, function: { arguments: '{"a":1}' } }],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-4.5',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-4.5',
temperature: 0.5,
});
// Read the stream
const reader = result.body?.getReader();
if (reader) {
let done = false;
while (!done) {
const { value, done: isDone } = await reader.read();
done = isDone;
}
}
expect(result).toBeDefined();
});
});
});
describe('exports', () => {
it('should export params object', () => {
expect(params).toBeDefined();
expect(params.provider).toBe('zhipu');
expect(params.baseURL).toBe('https://open.bigmodel.cn/api/paas/v4');
expect(params.chatCompletion).toBeDefined();
expect(params.debug).toBeDefined();
expect(params.models).toBeDefined();
});
it('should export chatCompletion configuration with handlePayload', () => {
expect(params.chatCompletion.handlePayload).toBeDefined();
expect(typeof params.chatCompletion.handlePayload).toBe('function');
});
it('should export chatCompletion configuration with handleStream', () => {
expect(params.chatCompletion.handleStream).toBeDefined();
expect(typeof params.chatCompletion.handleStream).toBe('function');
});
it('should export debug configuration', () => {
expect(params.debug.chatCompletion).toBeDefined();
expect(typeof params.debug.chatCompletion).toBe('function');
});
it('should export models function', () => {
expect(params.models).toBeDefined();
expect(typeof params.models).toBe('function');
});
});
describe('models', () => {
const mockFetch = vi.fn();
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = mockFetch;
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
it('should fetch and process models with correct headers', async () => {
mockFetch.mockResolvedValue({
json: async () => ({
rows: [
{
description: 'GLM-4 model',
modelCode: 'glm-4',
modelName: 'GLM-4',
},
{
description: 'GLM-4V model',
modelCode: 'glm-4v',
modelName: 'GLM-4V',
},
],
}),
});
const mockClient = { apiKey: 'test_api_key' };
await params.models({ client: mockClient as any });
expect(mockFetch).toHaveBeenCalledWith(
'https://open.bigmodel.cn/api/fine-tuning/model_center/list?pageSize=100&pageNum=1',
{
headers: {
'Authorization': 'Bearer test_api_key',
'Bigmodel-Organization': 'lobehub',
'Bigmodel-Project': 'lobechat',
},
method: 'GET',
},
);
});
it('should process model list correctly', async () => {
mockFetch.mockResolvedValue({
json: async () => ({
rows: [
{
description: 'GLM-4 model',
modelCode: 'glm-4',
modelName: 'GLM-4',
},
],
}),
});
const mockClient = { apiKey: 'test_api_key' };
const models = await params.models({ client: mockClient as any });
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
});
it('should transform modelCode to id and modelName to displayName', async () => {
mockFetch.mockResolvedValue({
json: async () => ({
rows: [
{
description: 'Test model',
modelCode: 'test-model-code',
modelName: 'Test Model Name',
},
],
}),
});
const mockClient = { apiKey: 'test_api_key' };
const models = await params.models({ client: mockClient as any });
// processModelList will merge with LOBE_DEFAULT_MODEL_LIST
// Check that fetch was called and data was processed
expect(mockFetch).toHaveBeenCalled();
expect(models).toBeDefined();
});
it('should handle empty model list', async () => {
mockFetch.mockResolvedValue({
json: async () => ({
rows: [],
}),
});
const mockClient = { apiKey: 'test_api_key' };
const models = await params.models({ client: mockClient as any });
expect(models).toEqual([]);
});
it('should handle multiple models', async () => {
mockFetch.mockResolvedValue({
json: async () => ({
rows: [
{
description: 'GLM-4 model',
modelCode: 'glm-4',
modelName: 'GLM-4',
},
{
description: 'GLM-4V model',
modelCode: 'glm-4v',
modelName: 'GLM-4V',
},
{
description: 'GLM-4-Air model',
modelCode: 'glm-4-air',
modelName: 'GLM-4-Air',
},
],
}),
});
const mockClient = { apiKey: 'test_api_key' };
const models = await params.models({ client: mockClient as any });
expect(models).toBeDefined();
expect(Array.isArray(models)).toBe(true);
});
it('should include description in model data', async () => {
mockFetch.mockResolvedValue({
json: async () => ({
rows: [
{
description: 'This is a test description',
modelCode: 'glm-4',
modelName: 'GLM-4',
},
],
}),
});
const mockClient = { apiKey: 'test_api_key' };
await params.models({ client: mockClient as any });
// Verify the API was called correctly
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test_api_key',
}),
}),
);
});
it('should handle API errors gracefully', async () => {
mockFetch.mockRejectedValue(new Error('API Error'));
const mockClient = { apiKey: 'test_api_key' };
await expect(params.models({ client: mockClient as any })).rejects.toThrow('API Error');
});
it('should use correct API endpoint', async () => {
mockFetch.mockResolvedValue({
json: async () => ({ rows: [] }),
});
const mockClient = { apiKey: 'test_api_key' };
await params.models({ client: mockClient as any });
expect(mockFetch).toHaveBeenCalledWith(
'https://open.bigmodel.cn/api/fine-tuning/model_center/list?pageSize=100&pageNum=1',
expect.anything(),
);
});
});
});