@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.
465 lines (389 loc) • 16.3 kB
text/typescript
// @vitest-environment node
import OpenAI from 'openai';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as debugStreamModule from '../../utils/debugStream';
import officalOpenAIModels from './fixtures/openai-models.json';
import { LobeOpenAI, params } from './index';
// Mock the console.error to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock fetch for most tests, but will be restored for real network tests
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('LobeOpenAI', () => {
let instance: InstanceType<typeof LobeOpenAI>;
beforeEach(() => {
instance = new LobeOpenAI({ apiKey: 'test' });
// 使用 vi.spyOn 来模拟 chat.completions.create 方法
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
new ReadableStream() as any,
);
vi.spyOn(instance['client'].models, 'list').mockResolvedValue({ data: [] } as any);
// Mock responses.create for responses API tests
vi.spyOn(instance['client'].responses, 'create').mockResolvedValue(new ReadableStream() as any);
});
afterEach(() => {
vi.clearAllMocks();
mockFetch.mockClear();
});
describe('chat', () => {
it('should return a StreamingTextResponse on successful API call', async () => {
// Arrange
const mockStream = new ReadableStream();
const mockResponse = Promise.resolve(mockStream);
(instance['client'].chat.completions.create as Mock).mockResolvedValue(mockResponse);
// Act
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'text-davinci-003',
temperature: 0,
});
// Assert
expect(result).toBeInstanceOf(Response);
});
describe('Error', () => {
it('should return ProviderBizError with an openai error response when OpenAI.APIError is thrown', async () => {
// Arrange
const apiError = new OpenAI.APIError(
400,
{
error: {
message: 'Bad Request',
},
status: 400,
},
'Error message',
{},
);
vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError);
// Act
try {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'text-davinci-003',
temperature: 0,
});
} catch (e) {
expect(e).toEqual({
endpoint: 'https://api.openai.com/v1',
error: {
error: { message: 'Bad Request' },
status: 400,
},
errorType: 'ProviderBizError',
provider: 'openai',
});
}
});
it('should throw AgentRuntimeError with NoOpenAIAPIKey if no apiKey is provided', async () => {
try {
new LobeOpenAI({});
} catch (e) {
expect(e).toEqual({ errorType: 'InvalidProviderAPIKey' });
}
});
it('should return ProviderBizError with the cause when OpenAI.APIError is thrown with cause', async () => {
// Arrange
const errorInfo = {
cause: {
message: 'api is undefined',
},
};
const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {});
vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError);
// Act
try {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'text-davinci-003',
temperature: 0,
});
} catch (e) {
expect(e).toEqual({
endpoint: 'https://api.openai.com/v1',
error: {
cause: { message: 'api is undefined' },
},
errorType: 'ProviderBizError',
provider: 'openai',
});
}
});
it('should return ProviderBizError with an cause response with desensitize Url', async () => {
// Arrange
const errorInfo = {
cause: { message: 'api is undefined' },
};
const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {});
instance = new LobeOpenAI({
apiKey: 'test',
baseURL: 'https://api.abc.com/v1',
});
vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError);
// Act
try {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-3.5-turbo',
temperature: 0,
});
} catch (e) {
expect(e).toEqual({
endpoint: 'https://api.***.com/v1',
error: {
cause: { message: 'api is undefined' },
},
errorType: 'ProviderBizError',
provider: 'openai',
});
}
});
it('should return AgentRuntimeError for non-OpenAI errors', async () => {
// Arrange
const genericError = new Error('Generic Error');
vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(genericError);
// Act
try {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'text-davinci-003',
temperature: 0,
});
} catch (e) {
expect(e).toEqual({
endpoint: 'https://api.openai.com/v1',
error: {
cause: genericError.cause,
message: genericError.message,
name: genericError.name,
},
errorType: 'AgentRuntimeError',
provider: 'openai',
});
}
});
});
describe('DEBUG', () => {
it('should call debugStream and return StreamingTextResponse when DEBUG_OPENAI_CHAT_COMPLETION is 1', async () => {
// Arrange
const mockProdStream = new ReadableStream() as any; // 模拟的 prod 流
const mockDebugStream = new ReadableStream({
start(controller) {
controller.enqueue('Debug stream content');
controller.close();
},
}) as any;
mockDebugStream.toReadableStream = () => mockDebugStream; // 添加 toReadableStream 方法
// 模拟 chat.completions.create 返回值,包括模拟的 tee 方法
(instance['client'].chat.completions.create as Mock).mockResolvedValue({
tee: () => [mockProdStream, { toReadableStream: () => mockDebugStream }],
});
// 保存原始环境变量值
const originalDebugValue = process.env.DEBUG_OPENAI_CHAT_COMPLETION;
// 模拟环境变量
process.env.DEBUG_OPENAI_CHAT_COMPLETION = '1';
vi.spyOn(debugStreamModule, 'debugStream').mockImplementation(() => Promise.resolve());
// 执行测试
// 运行你的测试函数,确保它会在条件满足时调用 debugStream
// 假设的测试函数调用,你可能需要根据实际情况调整
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'text-davinci-003',
temperature: 0,
});
// 验证 debugStream 被调用
expect(debugStreamModule.debugStream).toHaveBeenCalled();
// 恢复原始环境变量值
process.env.DEBUG_OPENAI_CHAT_COMPLETION = originalDebugValue;
});
});
});
describe('models', () => {
it('should get models', async () => {
// mock the models.list method
(instance['client'].models.list as Mock).mockResolvedValue({ data: officalOpenAIModels });
const list = await instance.models();
expect(list).toMatchSnapshot();
});
});
describe('chatCompletion.handlePayload', () => {
it('should use responses API for responsesAPIModels without enabledSearch', async () => {
const payload = {
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'o1-pro', // 这个模型在 responsesAPIModels 中
temperature: 0.7,
};
await instance.chat(payload);
// 应该调用 responses.create 而不是 chat.completions.create
expect(instance['client'].responses.create).toHaveBeenCalled();
const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
expect(createCall.model).toBe('o1-pro');
});
it('should use responses API when enabledSearch is true', async () => {
const payload = {
enabledSearch: true,
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'gpt-4o',
temperature: 0.7,
};
await instance.chat(payload);
// 应该调用 responses.create
expect(instance['client'].responses.create).toHaveBeenCalled();
});
it('should handle -search- models with stripped parameters', async () => {
const payload = {
frequency_penalty: 0.5,
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'gpt-4o-search-2024',
presence_penalty: 0.3,
temperature: 0.7,
top_p: 0.9,
};
await instance.chat(payload);
const createCall = (instance['client'].chat.completions.create as Mock).mock.calls[0][0];
expect(createCall.model).toBe('gpt-4o-search-2024');
expect(createCall.temperature).toBeUndefined();
expect(createCall.top_p).toBeUndefined();
expect(createCall.frequency_penalty).toBeUndefined();
expect(createCall.presence_penalty).toBeUndefined();
expect(createCall.stream).toBe(true);
});
it('should handle regular models with all parameters', async () => {
const payload = {
frequency_penalty: 0.5,
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'gpt-4o',
presence_penalty: 0.3,
temperature: 0.7,
top_p: 0.9,
};
await instance.chat(payload);
const createCall = (instance['client'].chat.completions.create as Mock).mock.calls[0][0];
expect(createCall.model).toBe('gpt-4o');
expect(createCall.temperature).toBe(0.7);
expect(createCall.top_p).toBe(0.9);
expect(createCall.frequency_penalty).toBe(0.5);
expect(createCall.presence_penalty).toBe(0.3);
expect(createCall.stream).toBe(true);
});
});
describe('responses.handlePayload', () => {
it('should add web_search tool when enabledSearch is true', async () => {
const payload = {
enabledSearch: true,
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'gpt-4o',
// 使用常规模型,通过 enabledSearch 触发 responses API
temperature: 0.7,
tools: [{ function: { description: 'test', name: 'test' }, type: 'function' as const }],
};
await instance.chat(payload);
const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
expect(createCall.tools).toEqual([
{ description: 'test', name: 'test', type: 'function' },
{ type: 'web_search' },
]);
});
it('should add search_context_size to web_search tool when OPENAI_SEARCH_CONTEXT_SIZE is set', async () => {
// Note: oaiSearchContextSize is read at module load time, not runtime
// This test verifies the tool structure is correct when the env var would be set
const payload = {
enabledSearch: true,
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'gpt-4o',
temperature: 0.7,
};
await instance.chat(payload);
const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
// Verify web_search tool is added, search_context_size depends on env var at module load time
expect(createCall.tools).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'web_search' })]),
);
});
it('should handle computer-use models with truncation and reasoning', async () => {
const payload = {
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'computer-use-preview',
reasoning: { effort: 'medium' },
temperature: 0.7,
};
await instance.chat(payload);
const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
expect(createCall.truncation).toBe('auto');
expect(createCall.reasoning).toEqual({ effort: 'medium', summary: 'auto' });
});
it('should handle prunePrefixes models without computer-use truncation', async () => {
const payload = {
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'o3-pro', // prunePrefixes 模型但非 computer-use,且在 responsesAPIModels 中
temperature: 0.7,
};
await instance.chat(payload);
const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
expect(createCall.reasoning).toEqual({ summary: 'auto' });
expect(createCall.truncation).toBeUndefined();
});
it('should set reasoning.effort to high for gpt-5-pro models', async () => {
const payload = {
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'gpt-5-pro',
temperature: 0.7,
};
await instance.chat(payload);
const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
expect(createCall.reasoning).toEqual({ effort: 'high', summary: 'auto' });
});
it('should set reasoning.effort to high for gpt-5-pro-2025-10-06 models', async () => {
const payload = {
messages: [{ content: 'Hello', role: 'user' as const }],
model: 'gpt-5-pro-2025-10-06',
temperature: 0.7,
};
await instance.chat(payload);
const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
expect(createCall.reasoning).toEqual({ effort: 'high', summary: 'auto' });
});
});
describe('supportsFlexTier', () => {
// Note: enableServiceTierFlex is read at module load time
// These tests verify the logic would work if env var was set at module load
it('should verify flex tier logic for supported models', () => {
// Since enableServiceTierFlex is read at module load time,
// we can't dynamically test it without reloading the module.
// Instead, we verify that the supportsFlexTier function logic is correct
// by checking the model patterns it should support.
const flexSupportedModels = ['gpt-5', 'o3', 'o4-mini'];
// Should support these models
expect(flexSupportedModels.some((m) => 'gpt-5-turbo'.startsWith(m))).toBe(true);
expect(flexSupportedModels.some((m) => 'o3-pro'.startsWith(m))).toBe(true);
expect(flexSupportedModels.some((m) => 'o4-mini'.startsWith(m))).toBe(true);
// Should NOT support o3-mini (explicitly excluded)
expect('o3-mini'.startsWith('o3-mini')).toBe(true);
});
});
describe('debug configuration', () => {
it('should return false when DEBUG_OPENAI_CHAT_COMPLETION is not set', () => {
delete process.env.DEBUG_OPENAI_CHAT_COMPLETION;
const result = params.debug.chatCompletion();
expect(result).toBe(false);
});
it('should return true when DEBUG_OPENAI_CHAT_COMPLETION is set to 1', () => {
const originalEnv = process.env.DEBUG_OPENAI_CHAT_COMPLETION;
process.env.DEBUG_OPENAI_CHAT_COMPLETION = '1';
const result = params.debug.chatCompletion();
expect(result).toBe(true);
process.env.DEBUG_OPENAI_CHAT_COMPLETION = originalEnv;
});
it('should return false when DEBUG_OPENAI_RESPONSES is not set', () => {
delete process.env.DEBUG_OPENAI_RESPONSES;
const result = params.debug.responses();
expect(result).toBe(false);
});
it('should return true when DEBUG_OPENAI_RESPONSES is set to 1', () => {
const originalEnv = process.env.DEBUG_OPENAI_RESPONSES;
process.env.DEBUG_OPENAI_RESPONSES = '1';
const result = params.debug.responses();
expect(result).toBe(true);
process.env.DEBUG_OPENAI_RESPONSES = originalEnv;
});
});
});