Skip to content

Commit

Permalink
✅ test: add tests for runtime
Browse files Browse the repository at this point in the history
  • Loading branch information
arvinxx committed May 11, 2024
1 parent e7ae100 commit d4965b2
Show file tree
Hide file tree
Showing 6 changed files with 733 additions and 15 deletions.
195 changes: 195 additions & 0 deletions src/libs/agent-runtime/anthropic/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// @vitest-environment node
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ChatCompletionTool } from '@/libs/agent-runtime';

import * as anthropicHelpers from '../utils/anthropicHelpers';
import * as debugStreamModule from '../utils/debugStream';
import { LobeAnthropicAI } from './index';

Expand All @@ -16,6 +19,10 @@ beforeEach(() => {

// 使用 vi.spyOn 来模拟 chat.completions.create 方法
vi.spyOn(instance['client'].messages, 'create').mockReturnValue(new ReadableStream() as any);

vi.spyOn(instance['client'].beta.tools.messages, 'create').mockReturnValue({
content: [],
} as any);
});

afterEach(() => {
Expand Down Expand Up @@ -233,6 +240,54 @@ describe('LobeAnthropicAI', () => {
process.env.DEBUG_ANTHROPIC_CHAT_COMPLETION = originalDebugValue;
});

describe('chat with tools', () => {
it('should call client.beta.tools.messages.create when tools are provided', async () => {
// Arrange
const tools: ChatCompletionTool[] = [
{ function: { name: 'tool1', description: 'desc1' }, type: 'function' },
];
const spyOn = vi.spyOn(anthropicHelpers, 'buildAnthropicTools');

// Act
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 1,
tools,
});

// Assert
expect(instance['client'].beta.tools.messages.create).toHaveBeenCalled();
expect(spyOn).toHaveBeenCalledWith(tools);
});

it('should handle text and tool_use content correctly in transformResponseToStream', async () => {
// Arrange
const mockResponse = {
content: [
{ type: 'text', text: 'Hello' },
{ type: 'tool_use', id: 'tool1', name: 'tool1', input: 'input1' },
],
};
// @ts-ignore
vi.spyOn(instance, 'transformResponseToStream').mockReturnValue(new ReadableStream());
vi.spyOn(instance['client'].beta.tools.messages, 'create').mockResolvedValue(
mockResponse as any,
);

// Act
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 0,
tools: [{ function: { name: 'tool1', description: 'desc1' }, type: 'function' }],
});

// Assert
expect(instance['transformResponseToStream']).toHaveBeenCalledWith(mockResponse);
});
});

describe('Error', () => {
it('should throw InvalidAnthropicAPIKey error on API_KEY_INVALID error', async () => {
// Arrange
Expand Down Expand Up @@ -305,5 +360,145 @@ describe('LobeAnthropicAI', () => {
}
});
});

describe('Error handling', () => {
it('should throw LocationNotSupportError on 403 error', async () => {
// Arrange
const apiError = { status: 403 };
(instance['client'].messages.create as Mock).mockRejectedValue(apiError);

// Act & Assert
await expect(
instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 1,
}),
).rejects.toEqual({
endpoint: 'https://api.anthropic.com',
error: apiError,
errorType: 'LocationNotSupportError',
provider,
});
});

it('should throw AnthropicBizError on other error status codes', async () => {
// Arrange
const apiError = { status: 500 };
(instance['client'].messages.create as Mock).mockRejectedValue(apiError);

// Act & Assert
await expect(
instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 1,
}),
).rejects.toEqual({
endpoint: 'https://api.anthropic.com',
error: apiError,
errorType: 'AnthropicBizError',
provider,
});
});

it('should desensitize custom baseURL in error message', async () => {
// Arrange
const apiError = { status: 401 };
const customInstance = new LobeAnthropicAI({
apiKey: 'test',
baseURL: 'https://api.custom.com/v1',
});
vi.spyOn(customInstance['client'].messages, 'create').mockRejectedValue(apiError);

// Act & Assert
await expect(
customInstance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 0,
}),
).rejects.toEqual({
endpoint: 'https://api.cu****om.com/v1',
error: apiError,
errorType: 'InvalidAnthropicAPIKey',
provider,
});
});
});

describe('Options', () => {
it('should pass signal to API call', async () => {
// Arrange
const controller = new AbortController();

// Act
await instance.chat(
{
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 1,
},
{ signal: controller.signal },
);

// Assert
expect(instance['client'].messages.create).toHaveBeenCalledWith(
expect.objectContaining({}),
{ signal: controller.signal },
);
});

it('should apply callback to the returned stream', async () => {
// Arrange
const callback = vi.fn();

// Act
await instance.chat(
{
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 0,
},
{
callback: { onStart: callback },
},
);

// Assert
expect(callback).toHaveBeenCalled();
});

it('should set headers on the response', async () => {
// Arrange
const headers = { 'X-Test-Header': 'test' };

// Act
const result = await instance.chat(
{
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-3-haiku-20240307',
temperature: 1,
},
{ headers },
);

// Assert
expect(result.headers.get('X-Test-Header')).toBe('test');
});
});

describe('Edge cases', () => {
it('should handle empty messages array', async () => {
// Act & Assert
await expect(
instance.chat({
messages: [],
model: 'claude-3-haiku-20240307',
temperature: 1,
}),
).resolves.toBeInstanceOf(Response);
});
});
});
});
154 changes: 154 additions & 0 deletions src/libs/agent-runtime/google/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @vitest-environment edge-runtime
import { FunctionDeclarationSchemaType } from '@google/generative-ai';
import { JSONSchema7 } from 'json-schema';
import OpenAI from 'openai';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -426,5 +428,157 @@ describe('LobeGoogleAI', () => {
expect(model).toEqual('gemini-pro-vision');
});
});

describe('buildGoogleTools', () => {
it('should return undefined when tools is undefined or empty', () => {
expect(instance['buildGoogleTools'](undefined)).toBeUndefined();
expect(instance['buildGoogleTools']([])).toBeUndefined();
});

it('should correctly convert ChatCompletionTool to GoogleFunctionCallTool', () => {
const tools: OpenAI.ChatCompletionTool[] = [
{
function: {
name: 'testTool',
description: 'A test tool',
parameters: {
type: 'object',
properties: {
param1: { type: 'string' },
param2: { type: 'number' },
},
required: ['param1'],
},
},
type: 'function',
},
];

const googleTools = instance['buildGoogleTools'](tools);

expect(googleTools).toHaveLength(1);
expect(googleTools![0].functionDeclarations![0]).toEqual({
name: 'testTool',
description: 'A test tool',
parameters: {
type: FunctionDeclarationSchemaType.OBJECT,
properties: {
param1: { type: FunctionDeclarationSchemaType.STRING },
param2: { type: FunctionDeclarationSchemaType.NUMBER },
},
required: ['param1'],
},
});
});
});

describe('convertSchemaObject', () => {
it('should correctly convert object schema', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
prop1: { type: 'string' },
prop2: { type: 'number' },
},
};

const converted = instance['convertSchemaObject'](schema);

expect(converted).toEqual({
type: FunctionDeclarationSchemaType.OBJECT,
properties: {
prop1: { type: FunctionDeclarationSchemaType.STRING },
prop2: { type: FunctionDeclarationSchemaType.NUMBER },
},
});
});

// 类似地添加 array/string/number/boolean 类型schema的测试用例
// ...

it('should correctly convert nested schema', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: {
nested: {
type: 'array',
items: {
type: 'object',
properties: {
prop: { type: 'string' },
},
},
},
},
};

const converted = instance['convertSchemaObject'](schema);

expect(converted).toEqual({
type: FunctionDeclarationSchemaType.OBJECT,
properties: {
nested: {
type: FunctionDeclarationSchemaType.ARRAY,
items: {
type: FunctionDeclarationSchemaType.OBJECT,
properties: {
prop: { type: FunctionDeclarationSchemaType.STRING },
},
},
},
},
});
});
});

describe('convertOAIMessagesToGoogleMessage', () => {
it('should correctly convert assistant message', () => {
const message: OpenAIChatMessage = {
role: 'assistant',
content: 'Hello',
};

const converted = instance['convertOAIMessagesToGoogleMessage'](message);

expect(converted).toEqual({
role: 'model',
parts: [{ text: 'Hello' }],
});
});

it('should correctly convert user message', () => {
const message: OpenAIChatMessage = {
role: 'user',
content: 'Hi',
};

const converted = instance['convertOAIMessagesToGoogleMessage'](message);

expect(converted).toEqual({
role: 'user',
parts: [{ text: 'Hi' }],
});
});

it('should correctly convert message with content parts', () => {
const message: OpenAIChatMessage = {
role: 'user',
content: [
{ type: 'text', text: 'Check this image:' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,...' } },
],
};

const converted = instance['convertOAIMessagesToGoogleMessage'](message);

expect(converted).toEqual({
role: 'user',
parts: [
{ text: 'Check this image:' },
{ inlineData: { data: '...', mimeType: 'image/png' } },
],
});
});
});
});
});

0 comments on commit d4965b2

Please sign in to comment.