@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.
857 lines (720 loc) • 23.4 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 { AgentRuntimeErrorType } from '../../types/error';
import { LobeGroq, params } from './index';
testProvider({
provider: 'groq',
defaultBaseURL: 'https://api.groq.com/openai/v1',
chatModel: 'mistralai/mistral-7b-instruct:free',
Runtime: LobeGroq,
chatDebugEnv: 'DEBUG_GROQ_CHAT_COMPLETION',
});
// Mock the console.error to avoid polluting test output
vi.spyOn(console, 'error').mockImplementation(() => {});
let instance: LobeOpenAICompatibleRuntime;
beforeEach(() => {
instance = new LobeGroq({ apiKey: 'test' });
// 使用 vi.spyOn 来模拟 chat.completions.create 方法
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
new ReadableStream() as any,
);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('LobeGroq - custom features', () => {
describe('filterAdvancedFields', () => {
const filterAdvancedFields = params.generateObject!.handleSchema!;
it('should filter out maxItems from schema', () => {
const schema = {
items: { type: 'string' },
maxItems: 5,
type: 'array',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
items: { type: 'string' },
type: 'array',
});
expect(result.maxItems).toBeUndefined();
});
it('should filter out minItems from schema', () => {
const schema = {
items: { type: 'string' },
minItems: 2,
type: 'array',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
items: { type: 'string' },
type: 'array',
});
expect(result.minItems).toBeUndefined();
});
it('should filter out maxLength from schema', () => {
const schema = {
maxLength: 100,
type: 'string',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'string',
});
expect(result.maxLength).toBeUndefined();
});
it('should filter out minLength from schema', () => {
const schema = {
minLength: 5,
type: 'string',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'string',
});
expect(result.minLength).toBeUndefined();
});
it('should filter out pattern from schema', () => {
const schema = {
pattern: '^[a-z]+$',
type: 'string',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'string',
});
expect(result.pattern).toBeUndefined();
});
it('should filter out format from schema', () => {
const schema = {
format: 'email',
type: 'string',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'string',
});
expect(result.format).toBeUndefined();
});
it('should filter out uniqueItems from schema', () => {
const schema = {
items: { type: 'number' },
type: 'array',
uniqueItems: true,
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
items: { type: 'number' },
type: 'array',
});
expect(result.uniqueItems).toBeUndefined();
});
it('should filter out maxProperties from schema', () => {
const schema = {
maxProperties: 10,
type: 'object',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'object',
});
expect(result.maxProperties).toBeUndefined();
});
it('should filter out minProperties from schema', () => {
const schema = {
minProperties: 2,
type: 'object',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'object',
});
expect(result.minProperties).toBeUndefined();
});
it('should filter out multipleOf from schema', () => {
const schema = {
multipleOf: 5,
type: 'number',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'number',
});
expect(result.multipleOf).toBeUndefined();
});
it('should filter out maximum from schema', () => {
const schema = {
maximum: 100,
type: 'number',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'number',
});
expect(result.maximum).toBeUndefined();
});
it('should filter out minimum from schema', () => {
const schema = {
minimum: 0,
type: 'number',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'number',
});
expect(result.minimum).toBeUndefined();
});
it('should filter out exclusiveMaximum from schema', () => {
const schema = {
exclusiveMaximum: 100,
type: 'number',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'number',
});
expect(result.exclusiveMaximum).toBeUndefined();
});
it('should filter out exclusiveMinimum from schema', () => {
const schema = {
exclusiveMinimum: 0,
type: 'number',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'number',
});
expect(result.exclusiveMinimum).toBeUndefined();
});
it('should filter out multiple unsupported properties at once', () => {
const schema = {
format: 'email',
maxLength: 100,
minLength: 5,
pattern: '^[a-z]+$',
type: 'string',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
type: 'string',
});
expect(result.maxLength).toBeUndefined();
expect(result.minLength).toBeUndefined();
expect(result.pattern).toBeUndefined();
expect(result.format).toBeUndefined();
});
it('should preserve supported properties', () => {
const schema = {
description: 'A test field',
enum: ['a', 'b', 'c'],
maxLength: 10,
type: 'string',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
description: 'A test field',
enum: ['a', 'b', 'c'],
type: 'string',
});
});
it('should handle nested objects recursively', () => {
const schema = {
properties: {
email: {
format: 'email',
maxLength: 100,
type: 'string',
},
name: {
minLength: 2,
type: 'string',
},
},
type: 'object',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
properties: {
email: {
type: 'string',
},
name: {
type: 'string',
},
},
type: 'object',
});
});
it('should handle deeply nested objects', () => {
const schema = {
properties: {
user: {
properties: {
address: {
maxProperties: 5,
properties: {
city: {
maxLength: 50,
type: 'string',
},
zip: {
pattern: '^\\d{5}$',
type: 'string',
},
},
type: 'object',
},
name: {
minLength: 1,
type: 'string',
},
},
type: 'object',
},
},
type: 'object',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
properties: {
user: {
properties: {
address: {
properties: {
city: {
type: 'string',
},
zip: {
type: 'string',
},
},
type: 'object',
},
name: {
type: 'string',
},
},
type: 'object',
},
},
type: 'object',
});
});
it('should handle arrays in schema', () => {
const schema = {
items: {
maxLength: 50,
type: 'string',
},
maxItems: 10,
minItems: 1,
type: 'array',
uniqueItems: true,
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
items: {
type: 'string',
},
type: 'array',
});
});
it('should handle arrays of objects', () => {
const schema = {
items: {
properties: {
age: {
maximum: 120,
minimum: 0,
type: 'number',
},
name: {
maxLength: 100,
type: 'string',
},
},
type: 'object',
},
type: 'array',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
items: {
properties: {
age: {
type: 'number',
},
name: {
type: 'string',
},
},
type: 'object',
},
type: 'array',
});
});
it('should handle null values', () => {
const result = filterAdvancedFields(null);
expect(result).toBeNull();
});
it('should handle primitive values', () => {
expect(filterAdvancedFields('string')).toBe('string');
expect(filterAdvancedFields(123)).toBe(123);
expect(filterAdvancedFields(true)).toBe(true);
});
it('should handle empty objects', () => {
const schema = {};
const result = filterAdvancedFields(schema);
expect(result).toEqual({});
});
it('should preserve required and other common fields', () => {
const schema = {
additionalProperties: false,
description: 'A person object',
maxProperties: 10,
properties: {
age: {
description: 'Person age',
maximum: 150,
type: 'number',
},
name: {
description: 'Person name',
maxLength: 100,
type: 'string',
},
},
required: ['name', 'age'],
type: 'object',
};
const result = filterAdvancedFields(schema);
expect(result).toEqual({
additionalProperties: false,
description: 'A person object',
properties: {
age: {
description: 'Person age',
type: 'number',
},
name: {
description: 'Person name',
type: 'string',
},
},
required: ['name', 'age'],
type: 'object',
});
});
});
describe('Debug Configuration', () => {
it('should disable debug by default', () => {
delete process.env.DEBUG_GROQ_CHAT_COMPLETION;
const result = params.debug.chatCompletion();
expect(result).toBe(false);
});
it('should enable debug when env is set', () => {
process.env.DEBUG_GROQ_CHAT_COMPLETION = '1';
const result = params.debug.chatCompletion();
expect(result).toBe(true);
delete process.env.DEBUG_GROQ_CHAT_COMPLETION;
});
});
describe('handlePayload', () => {
it('should not set stream to false when payload contains tools', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
temperature: 0,
tools: [
{
type: 'function',
function: { name: 'tool1', description: '', parameters: {} },
},
],
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({ stream: true }),
expect.anything(),
);
});
it('should set temperature to 0.7', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
temperature: 0.7,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({ temperature: 0.7 }),
expect.anything(),
);
});
it('should set temperature to undefined when temperature is 0', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
temperature: 0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({ temperature: undefined }),
expect.anything(),
);
});
it('should set temperature to undefined when temperature is negative', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
temperature: -1.0,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({ temperature: undefined }),
expect.anything(),
);
});
it('should default stream to true when not specified', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({ stream: true }),
expect.anything(),
);
});
it('should preserve stream value when explicitly set to false', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
stream: false,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({ stream: false }),
expect.anything(),
);
});
it('should preserve other payload properties', async () => {
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
temperature: 0.5,
max_tokens: 100,
top_p: 0.9,
});
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: [{ content: 'Hello', role: 'user' }],
model: 'mistralai/mistral-7b-instruct:free',
temperature: 0.5,
max_tokens: 100,
top_p: 0.9,
}),
expect.anything(),
);
});
});
describe('handleError', () => {
it('should handle 403 location not supported error', () => {
const error = { status: 403, message: 'Location not supported' };
const result = params.chatCompletion.handleError?.(error as any);
expect(result).toEqual({
error,
errorType: AgentRuntimeErrorType.LocationNotSupportError,
});
});
it('should not handle other error statuses', () => {
const error = { status: 401, message: 'Unauthorized' };
const result = params.chatCompletion.handleError?.(error as any);
expect(result).toBeUndefined();
});
it('should not handle 404 errors', () => {
const error = { status: 404, message: 'Not found' };
const result = params.chatCompletion.handleError?.(error as any);
expect(result).toBeUndefined();
});
it('should not handle 500 errors', () => {
const error = { status: 500, message: 'Internal server error' };
const result = params.chatCompletion.handleError?.(error as any);
expect(result).toBeUndefined();
});
});
describe('models', () => {
const mockClient = {
models: {
list: vi.fn(),
},
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch and process models with function call detection', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{ id: 'llama-3.1-70b-versatile', context_window: 8192 },
{ id: 'mixtral-8x7b-32768', context_window: 32768 },
{ id: 'gemma2-9b-it', context_window: 8192 },
],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(3);
expect(models[0]).toMatchObject({
id: 'llama-3.1-70b-versatile',
contextWindowTokens: 8192,
functionCall: true, // Should detect function call from llama-3.1
});
expect(models[1]).toMatchObject({
id: 'mixtral-8x7b-32768',
contextWindowTokens: 32768,
functionCall: true, // Should detect function call from mixtral-8x7b-32768
});
expect(models[2]).toMatchObject({
id: 'gemma2-9b-it',
contextWindowTokens: 8192,
functionCall: true, // Should detect function call from gemma2-9b-it
});
});
it('should detect vision capability from model id', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{ id: 'llama-3.2-11b-vision-preview', context_window: 8192 },
{ id: 'llama-3.2-90b-vision-preview', context_window: 8192 },
],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(2);
expect(models[0]).toMatchObject({
id: 'llama-3.2-11b-vision-preview',
vision: true,
});
expect(models[1]).toMatchObject({
id: 'llama-3.2-90b-vision-preview',
vision: true,
});
});
it('should detect reasoning capability from model id', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{ id: 'deepseek-r1-distill-llama-70b', context_window: 8192 },
{ id: 'deepseek-r1-distill-qwen-32b', context_window: 32768 },
],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(2);
expect(models[0]).toMatchObject({
id: 'deepseek-r1-distill-llama-70b',
reasoning: true,
});
expect(models[1]).toMatchObject({
id: 'deepseek-r1-distill-qwen-32b',
reasoning: true,
});
});
it('should detect llama-3.3 function call capability', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'llama-3.3-70b-versatile', context_window: 8192 }],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: 'llama-3.3-70b-versatile',
functionCall: true,
});
});
it('should detect llama3- prefix function call capability', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'llama3-70b-8192', context_window: 8192 }],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: 'llama3-70b-8192',
functionCall: true,
});
});
it('should detect tool keyword function call capability', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'llama3-groq-70b-8192-tool-use-preview', context_window: 8192 }],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: 'llama3-groq-70b-8192-tool-use-preview',
functionCall: true,
});
});
it('should merge with known model list for display name and enabled status', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'llama-3.1-70b-versatile', context_window: 8192 }],
});
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();
});
it('should handle models not in known model list', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'unknown-model-id', context_window: 4096 }],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: 'unknown-model-id',
contextWindowTokens: 4096,
displayName: undefined,
enabled: false,
functionCall: false,
vision: false,
reasoning: false,
});
});
it('should handle case-insensitive model matching', async () => {
mockClient.models.list.mockResolvedValue({
data: [{ id: 'LLAMA-3.1-70B-VERSATILE', context_window: 8192 }],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(1);
expect(models[0].id).toBe('LLAMA-3.1-70B-VERSATILE');
// Should match with lowercase in LOBE_DEFAULT_MODEL_LIST
expect(models[0].displayName).toBeDefined();
});
it('should combine multiple capabilities', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{
id: 'llama-3.2-11b-vision-preview',
context_window: 8192,
},
],
});
const models = await params.models({ client: mockClient as any });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: 'llama-3.2-11b-vision-preview',
vision: true,
// Vision models may not have function call by default
});
});
it('should handle empty model list', async () => {
mockClient.models.list.mockResolvedValue({
data: [],
});
const models = await params.models({ client: mockClient as any });
expect(models).toEqual([]);
});
it('should preserve abilities from known model list', async () => {
mockClient.models.list.mockResolvedValue({
data: [
{ id: 'llama-3.2-11b-vision-preview', context_window: 8192 },
{ id: 'llama-3.1-70b-versatile', context_window: 131072 },
],
});
const models = await params.models({ client: mockClient as any });
expect(models.length).toBeGreaterThan(0);
// Models should inherit abilities from LOBE_DEFAULT_MODEL_LIST when available
models.forEach((model) => {
expect(model).toHaveProperty('functionCall');
expect(model).toHaveProperty('vision');
expect(model).toHaveProperty('reasoning');
});
});
});
});