UNPKG

@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.

1,093 lines (946 loc) • 32.1 kB
// @vitest-environment node import { ModelProvider } from 'model-bank'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LobeOpenAICompatibleRuntime } from '../../core/BaseAI'; import { testProvider } from '../../providerTestUtils'; import { LobeCohereAI, params } from './index'; const provider = ModelProvider.Cohere; const defaultBaseURL = 'https://api.cohere.ai/compatibility/v1'; testProvider({ Runtime: LobeCohereAI, provider, defaultBaseURL, chatDebugEnv: 'DEBUG_COHERE_CHAT_COMPLETION', chatModel: 'command-r7b', test: { skipAPICall: true, }, }); // Mock the console.error to avoid polluting test output vi.spyOn(console, 'error').mockImplementation(() => {}); let instance: LobeOpenAICompatibleRuntime; beforeEach(() => { instance = new LobeCohereAI({ apiKey: 'test' }); // Mock chat.completions.create method vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue( new ReadableStream() as any, ); }); afterEach(() => { vi.clearAllMocks(); }); describe('LobeCohereAI - custom features', () => { describe('Debug Configuration', () => { it('should disable debug by default', () => { delete process.env.DEBUG_COHERE_CHAT_COMPLETION; const result = params.debug.chatCompletion(); expect(result).toBe(false); }); it('should enable debug when env is set', () => { process.env.DEBUG_COHERE_CHAT_COMPLETION = '1'; const result = params.debug.chatCompletion(); expect(result).toBe(true); delete process.env.DEBUG_COHERE_CHAT_COMPLETION; }); }); describe('handlePayload - parameter constraints', () => { it('should clamp frequency_penalty to [0, 1] range', async () => { // Test upper bound await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 1.5, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 1 }), expect.anything(), ); }); it('should clamp frequency_penalty negative values to 0', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: -0.5, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 0 }), expect.anything(), ); }); it('should clamp presence_penalty to [0, 1] range', async () => { // Test upper bound await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', presence_penalty: 1.5, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ presence_penalty: 1 }), expect.anything(), ); }); it('should clamp presence_penalty negative values to 0', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', presence_penalty: -0.3, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ presence_penalty: 0 }), expect.anything(), ); }); it('should clamp top_p to [0, 1] range', async () => { // Test upper bound await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', top_p: 1.2, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ top_p: 1 }), expect.anything(), ); }); it('should clamp top_p negative values to 0', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', top_p: -0.1, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ top_p: 0 }), expect.anything(), ); }); it('should accept valid frequency_penalty values', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 0.5, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 0.5 }), expect.anything(), ); }); it('should accept valid presence_penalty values', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', presence_penalty: 0.7, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ presence_penalty: 0.7 }), expect.anything(), ); }); it('should accept valid top_p values', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', top_p: 0.9, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ top_p: 0.9 }), expect.anything(), ); }); it('should handle all penalty parameters together', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 0.3, presence_penalty: 0.4, top_p: 0.8, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 0.3, presence_penalty: 0.4, top_p: 0.8, }), expect.anything(), ); }); it('should handle boundary values correctly', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 0, presence_penalty: 1, top_p: 0.5, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 0, presence_penalty: 1, top_p: 0.5, }), expect.anything(), ); }); it('should not normalize temperature (keep original value)', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', temperature: 1.0, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ temperature: 1.0 }), expect.anything(), ); }); it('should preserve other payload properties', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', temperature: 0.5, max_tokens: 100, stream: true, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', temperature: 0.5, max_tokens: 100, stream: true, }), expect.anything(), ); }); it('should omit undefined penalty parameters', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', temperature: 0.5, }); const callArgs = vi.mocked(instance['client'].chat.completions.create).mock.calls[0][0]; expect(callArgs).not.toHaveProperty('frequency_penalty'); expect(callArgs).not.toHaveProperty('presence_penalty'); expect(callArgs).not.toHaveProperty('top_p'); }); it('should handle edge case: all penalties at maximum', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 2.0, presence_penalty: 2.0, top_p: 2.0, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 1, presence_penalty: 1, top_p: 1, }), expect.anything(), ); }); it('should handle edge case: all penalties at minimum', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: -1.0, presence_penalty: -1.0, top_p: -1.0, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 0, presence_penalty: 0, top_p: 0, }), expect.anything(), ); }); }); describe('handlePayload - excludeUsage and noUserId', () => { it('should verify excludeUsage is set to true', () => { expect(params.chatCompletion.excludeUsage).toBe(true); }); it('should verify noUserId is set to true', () => { expect(params.chatCompletion.noUserId).toBe(true); }); }); describe('models', () => { const mockClient = { baseURL: 'https://api.cohere.ai/compatibility/v1', models: { list: vi.fn(), }, }; beforeEach(() => { vi.clearAllMocks(); }); it('should fetch and process models with tools support', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus', context_length: 128000, features: ['tools', 'chat'], supports_vision: false, }, { name: 'command-r', context_length: 128000, features: ['tools', 'chat'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(2); expect(models[0]).toMatchObject({ id: 'command-r-plus', contextWindowTokens: 128000, functionCall: true, // Has tools in features vision: false, }); expect(models[1]).toMatchObject({ id: 'command-r', contextWindowTokens: 128000, functionCall: true, // Has tools in features vision: false, }); }); it('should detect vision support from supports_vision flag', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus-08-2024', context_length: 128000, features: ['tools', 'chat'], supports_vision: true, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: 'command-r-plus-08-2024', vision: true, }); }); it('should handle models without features (null)', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command', context_length: 4096, features: null, supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: 'command', contextWindowTokens: 4096, functionCall: false, // No tools in features, fallback to known model vision: false, }); }); it('should handle models with features but no tools', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-light', context_length: 4096, features: ['chat'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: 'command-light', functionCall: false, // No tools in features vision: false, }); }); it('should merge with known model list for display name and enabled status', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus', context_length: 128000, features: ['tools', 'chat'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); // Should have displayName and enabled from LOBE_DEFAULT_MODEL_LIST expect(models[0].displayName).toBeDefined(); expect(models[0].enabled).toBeDefined(); }); it('should handle models not in known model list', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'unknown-cohere-model', context_length: 8192, features: ['chat'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: 'unknown-cohere-model', contextWindowTokens: 8192, displayName: undefined, enabled: false, functionCall: false, vision: false, }); }); it('should handle case-insensitive model matching', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'COMMAND-R-PLUS', context_length: 128000, features: ['tools'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0].id).toBe('COMMAND-R-PLUS'); // Should match with lowercase in LOBE_DEFAULT_MODEL_LIST expect(models[0].displayName).toBeDefined(); }); it('should combine capabilities from both API and known model list', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus', context_length: 128000, features: ['tools', 'chat'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); // Should combine API features with known model abilities expect(models[0].functionCall).toBe(true); // From features expect(models[0].vision).toBe(false); // From API }); it('should handle empty model list', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toEqual([]); }); it('should change client baseURL to v1 endpoint', async () => { const clientWithBaseURL = { baseURL: 'https://api.cohere.ai/compatibility/v1', models: { list: vi.fn().mockResolvedValue({ body: { models: [] }, }), }, }; await params.models({ client: clientWithBaseURL as any }); expect(clientWithBaseURL.baseURL).toBe('https://api.cohere.com/v1'); }); it('should handle models with all capabilities', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus-vision', context_length: 128000, features: ['tools', 'chat', 'vision'], supports_vision: true, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: 'command-r-plus-vision', contextWindowTokens: 128000, functionCall: true, vision: true, }); }); it('should preserve abilities from known model list when API has no features', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus', context_length: 128000, features: null, supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); // Should use known model abilities as fallback expect(models[0].functionCall).toBeDefined(); }); it('should handle models with various context lengths', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-light', context_length: 4096, features: ['chat'], supports_vision: false, }, { name: 'command-r', context_length: 128000, features: ['tools', 'chat'], supports_vision: false, }, { name: 'command-r-plus', context_length: 256000, features: ['tools', 'chat'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(3); expect(models[0].contextWindowTokens).toBe(4096); expect(models[1].contextWindowTokens).toBe(128000); expect(models[2].contextWindowTokens).toBe(256000); }); it('should handle complex features array', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus', context_length: 128000, features: ['tools', 'chat', 'embeddings', 'rerank'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0].functionCall).toBe(true); // Should detect tools }); it('should handle API errors gracefully', async () => { mockClient.models.list.mockRejectedValue(new Error('API Error')); await expect(params.models({ client: mockClient as any })).rejects.toThrow('API Error'); }); it('should handle network timeout errors', async () => { mockClient.models.list.mockRejectedValue(new Error('Network timeout')); await expect(params.models({ client: mockClient as any })).rejects.toThrow('Network timeout'); }); it('should handle invalid API response structure', async () => { mockClient.models.list.mockResolvedValue({ body: { // Missing models array }, }); // Should throw error when trying to map over undefined await expect(params.models({ client: mockClient as any })).rejects.toThrow(); }); it('should handle malformed model data', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { // Missing required fields name: 'incomplete-model', } as any, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0].id).toBe('incomplete-model'); // Should handle undefined values expect(models[0].contextWindowTokens).toBeUndefined(); }); it('should verify baseURL changes to v1 endpoint for models API', async () => { const customClient = { baseURL: 'https://api.cohere.ai/compatibility/v1', models: { list: vi.fn().mockResolvedValue({ body: { models: [ { name: 'test-model', context_length: 8000, features: ['chat'], supports_vision: false, }, ], }, }), }, }; await params.models({ client: customClient as any }); // Should mutate baseURL to v1 expect(customClient.baseURL).toBe('https://api.cohere.com/v1'); }); it('should handle very large context lengths', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus-extended', context_length: 1000000, features: ['tools'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0].contextWindowTokens).toBe(1000000); }); it('should handle models with zero context length', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'test-model', context_length: 0, features: null, supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); expect(models[0].contextWindowTokens).toBe(0); }); it('should merge vision capability from both API and known model list', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus-08-2024', context_length: 128000, features: ['tools', 'chat'], supports_vision: true, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(1); // Vision should be true from either API or known model list expect(models[0].vision).toBe(true); }); it('should correctly filter and map all model fields', async () => { mockClient.models.list.mockResolvedValue({ body: { models: [ { name: 'command-r-plus', context_length: 128000, features: ['tools', 'chat'], supports_vision: true, }, { name: 'command-r', context_length: 128000, features: ['tools'], supports_vision: false, }, ], }, }); const models = await params.models({ client: mockClient as any }); expect(models).toHaveLength(2); // Verify all required fields are present models.forEach((model) => { expect(model).toHaveProperty('id'); expect(model).toHaveProperty('contextWindowTokens'); expect(model).toHaveProperty('functionCall'); expect(model).toHaveProperty('vision'); expect(model).toHaveProperty('enabled'); }); }); }); describe('baseURL configuration', () => { it('should use correct default baseURL', () => { expect(params.baseURL).toBe('https://api.cohere.ai/compatibility/v1'); }); it('should initialize instance with custom baseURL', () => { const customInstance = new LobeCohereAI({ apiKey: 'test', baseURL: 'https://custom.cohere.ai/v1', }); expect(customInstance).toBeDefined(); }); }); describe('provider configuration', () => { it('should have correct provider ID', () => { expect(params.provider).toBe(ModelProvider.Cohere); }); it('should export params object', () => { expect(params).toBeDefined(); expect(params).toHaveProperty('baseURL'); expect(params).toHaveProperty('chatCompletion'); expect(params).toHaveProperty('debug'); expect(params).toHaveProperty('models'); expect(params).toHaveProperty('provider'); }); }); describe('chatCompletion configuration', () => { it('should have excludeUsage set to true', () => { expect(params.chatCompletion.excludeUsage).toBe(true); }); it('should have noUserId set to true', () => { expect(params.chatCompletion.noUserId).toBe(true); }); it('should have handlePayload function', () => { expect(params.chatCompletion.handlePayload).toBeDefined(); expect(typeof params.chatCompletion.handlePayload).toBe('function'); }); }); describe('edge cases for payload handling', () => { it('should handle missing optional parameters gracefully', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', // No temperature, frequency_penalty, presence_penalty, or top_p }); const callArgs = vi.mocked(instance['client'].chat.completions.create).mock.calls[0][0]; expect(callArgs).toHaveProperty('messages'); expect(callArgs).toHaveProperty('model'); expect(callArgs).not.toHaveProperty('temperature'); expect(callArgs).not.toHaveProperty('frequency_penalty'); expect(callArgs).not.toHaveProperty('presence_penalty'); expect(callArgs).not.toHaveProperty('top_p'); }); it('should handle very small positive penalty values', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 0.001, presence_penalty: 0.0001, top_p: 0.01, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 0.001, presence_penalty: 0.0001, top_p: 0.01, }), expect.anything(), ); }); it('should handle exact boundary values (0 and 1)', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 1.0, presence_penalty: 0.0, top_p: 1.0, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 1.0, presence_penalty: 0.0, top_p: 1.0, }), expect.anything(), ); }); it('should handle multiple messages with different roles', async () => { await instance.chat({ messages: [ { content: 'System prompt', role: 'system' }, { content: 'User message 1', role: 'user' }, { content: 'Assistant response', role: 'assistant' }, { content: 'User message 2', role: 'user' }, ], model: 'command-r7b', }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ messages: [ { content: 'System prompt', role: 'system' }, { content: 'User message 1', role: 'user' }, { content: 'Assistant response', role: 'assistant' }, { content: 'User message 2', role: 'user' }, ], }), expect.anything(), ); }); it('should handle max_tokens parameter', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', max_tokens: 4096, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ max_tokens: 4096, }), expect.anything(), ); }); it('should handle stream parameter', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', stream: true, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ stream: true, }), expect.anything(), ); }); it('should handle tools parameter', async () => { const tools = [ { type: 'function' as const, function: { name: 'get_weather', description: 'Get weather', parameters: { type: 'object', properties: {} }, }, }, ]; await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', tools, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ tools, }), expect.anything(), ); }); it('should handle combined complex payload', async () => { const tools = [ { type: 'function' as const, function: { name: 'calculate', description: 'Calculate', parameters: { type: 'object', properties: {} }, }, }, ]; await instance.chat({ messages: [ { content: 'System', role: 'system' }, { content: 'Hello', role: 'user' }, ], model: 'command-r-plus', temperature: 0.7, max_tokens: 2048, top_p: 0.95, frequency_penalty: 0.1, presence_penalty: 0.2, stream: true, tools, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ messages: [ { content: 'System', role: 'system' }, { content: 'Hello', role: 'user' }, ], model: 'command-r-plus', temperature: 0.7, max_tokens: 2048, top_p: 0.95, frequency_penalty: 0.1, presence_penalty: 0.2, stream: true, tools, }), expect.anything(), ); }); it('should handle extreme out-of-range values correctly', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', frequency_penalty: 10.0, presence_penalty: -5.0, top_p: 100.0, }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ frequency_penalty: 1, // Clamped to max presence_penalty: 0, // Clamped to min top_p: 1, // Clamped to max }), expect.anything(), ); }); it('should handle temperature parameter when present', async () => { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'command-r7b', temperature: 0.7, }); const callArgs = vi.mocked(instance['client'].chat.completions.create).mock.calls[0][0]; expect(callArgs.temperature).toBe(0.7); }); it('should not modify messages array', async () => { const messages = [ { content: 'User message', role: 'user' as const }, { content: 'Assistant reply', role: 'assistant' as const }, ]; await instance.chat({ messages, model: 'command-r7b', }); expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ messages, }), expect.anything(), ); }); }); });