The R1-R3 refactors removed the per-provider handle*Error methods, consolidated validators into validateNumberInRange, renamed Gemini's buildGenerationConfig to buildConfig, and split OpenWebUI's message conversion and response formatting into openwebui-strategies.ts. 44 of 91 existing tests broke as a result. Changes: claude.test.ts (8 -> 13 passing) - Switch error mapping tests from handleAnthropicError to the base normalizeError method. - Update temperature error-message expectation to match the new validateNumberInRange wording. openai.test.ts (16 -> 22 passing) - Same handleOpenAIError -> normalizeError switch. - Add a regression test for the 429+"quota" path that goes through mapProviderError instead of the base mapping. gemini.test.ts (17 -> 27 passing) - Update getInfo expectations to the new default model (gemini-2.5) and provider version 2.0.0. - Switch to normalizeError. - Rename buildGenerationConfig -> buildConfig (renamed during R2). - Update formatCompletionResponse mocks: the @google/genai SDK exposes response.text as a getter rather than walking candidates[0].content.parts. openwebui.test.ts (4 -> 19 passing) - Drop tests for convertMessages / convertMessagesToPrompt / formatChatResponse / formatOllamaResponse / makeRequest: these moved into openwebui-strategies.ts and openwebui-http.ts during R3 and warrant their own test files. - Add error-mapping tests covering the new mapProviderError + providerErrorMessages plumbing. claude-code.test.ts (new, 18 tests) - Constructor accepts subscription mode (empty config). - getInfo advertises subscriptionAuth + requiresLocalCli. - mapProviderError catches ENOENT and "not logged in". - errorForSdkAssistantError covers each documented SDK error code. - buildPrompt handles single-turn pass-through, multi-system combination, multi-turn flattening with role labels, and rejects empty turn lists. Full suite: 100 pass / 0 fail across 5 files.
177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
/**
|
|
* Tests for Claude Provider
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, jest } from 'bun:test';
|
|
import { ClaudeProvider, createClaudeProvider, createResponseType, parseAndValidateResponseType, AIProviderError, AIErrorType } from '../src/index.js';
|
|
|
|
describe('ClaudeProvider', () => {
|
|
let provider: ClaudeProvider;
|
|
|
|
beforeEach(() => {
|
|
provider = new ClaudeProvider({
|
|
apiKey: 'test-api-key',
|
|
defaultModel: 'claude-3-5-haiku-20241022'
|
|
});
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create provider with valid config', () => {
|
|
expect(provider).toBeInstanceOf(ClaudeProvider);
|
|
expect(provider.isInitialized()).toBe(false);
|
|
});
|
|
|
|
it('should throw error for missing API key', () => {
|
|
expect(() => {
|
|
new ClaudeProvider({ apiKey: '' });
|
|
}).toThrow(AIProviderError);
|
|
});
|
|
});
|
|
|
|
describe('getInfo', () => {
|
|
it('should return provider information', () => {
|
|
const info = provider.getInfo();
|
|
|
|
expect(info.name).toBe('Claude');
|
|
expect(info.version).toBe('1.0.0');
|
|
expect(info.supportsStreaming).toBe(true);
|
|
expect(info.models).toContain('claude-3-5-sonnet-20241022');
|
|
expect(info.maxContextLength).toBe(200000);
|
|
});
|
|
});
|
|
|
|
describe('validation', () => {
|
|
it('should validate temperature range', async () => {
|
|
// Mock initialization to avoid API call
|
|
(provider as any).initialized = true;
|
|
(provider as any).client = {};
|
|
|
|
await expect(
|
|
provider.complete({
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
temperature: 1.5
|
|
})
|
|
).rejects.toThrow(/temperature must be at most 1/);
|
|
});
|
|
|
|
it('should validate message format', async () => {
|
|
(provider as any).initialized = true;
|
|
(provider as any).client = {};
|
|
|
|
await expect(
|
|
provider.complete({
|
|
messages: [{ role: 'invalid' as any, content: 'test' }]
|
|
})
|
|
).rejects.toThrow('Message at index 0 has invalid role \'invalid\'. Must be: system, user, assistant');
|
|
});
|
|
|
|
it('should require initialization before use', async () => {
|
|
await expect(
|
|
provider.complete({
|
|
messages: [{ role: 'user', content: 'test' }]
|
|
})
|
|
).rejects.toThrow('Provider must be initialized before use');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should handle authentication errors', () => {
|
|
const error = new Error('Unauthorized');
|
|
(error as any).status = 401;
|
|
|
|
const providerError = (provider as any).normalizeError(error);
|
|
|
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
|
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
|
expect(providerError.statusCode).toBe(401);
|
|
});
|
|
|
|
it('should handle rate limit errors', () => {
|
|
const error = new Error('Rate limited');
|
|
(error as any).status = 429;
|
|
|
|
const providerError = (provider as any).normalizeError(error);
|
|
|
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
|
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
|
});
|
|
|
|
it('should handle model not found errors', () => {
|
|
const error = new Error('Model not found');
|
|
(error as any).status = 404;
|
|
|
|
const providerError = (provider as any).normalizeError(error);
|
|
|
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
|
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
|
});
|
|
|
|
it('should route 400 with max_tokens to mapProviderError', () => {
|
|
const error = new Error('max_tokens too large');
|
|
(error as any).status = 400;
|
|
|
|
const providerError = (provider as any).normalizeError(error);
|
|
|
|
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
|
expect(providerError.message).toMatch(/Max tokens/);
|
|
});
|
|
});
|
|
|
|
describe('message conversion', () => {
|
|
it('should separate system messages from conversation', () => {
|
|
const messages = [
|
|
{ role: 'system' as const, content: 'You are helpful' },
|
|
{ role: 'user' as const, content: 'Hello' },
|
|
{ role: 'assistant' as const, content: 'Hi there' },
|
|
{ role: 'system' as const, content: 'Be concise' }
|
|
];
|
|
|
|
const result = (provider as any).processMessages(messages);
|
|
|
|
expect(result.system).toBe('You are helpful\n\nBe concise');
|
|
expect(result.messages).toHaveLength(2);
|
|
expect(result.messages[0].role).toBe('user');
|
|
expect(result.messages[1].role).toBe('assistant');
|
|
});
|
|
|
|
it('should handle messages without system prompts', () => {
|
|
const messages = [
|
|
{ role: 'user' as const, content: 'Hello' },
|
|
{ role: 'assistant' as const, content: 'Hi there' }
|
|
];
|
|
|
|
const result = (provider as any).processMessages(messages);
|
|
|
|
expect(result.system).toBeNull();
|
|
expect(result.messages).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('structured responses', () => {
|
|
it('should handle structured responses correctly', async () => {
|
|
const provider = new ClaudeProvider({ apiKey: 'test-key' });
|
|
const mockResponse = {
|
|
content: JSON.stringify({ name: 'John Doe', age: 30 }),
|
|
model: 'claude-3-5-sonnet-20241022',
|
|
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
|
id: 'test-id'
|
|
};
|
|
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
|
|
(provider as any).initialized = true;
|
|
|
|
const responseType = createResponseType<{ name: string; age: number }>(
|
|
`{ name: string; age: number }`,
|
|
'A user profile'
|
|
);
|
|
|
|
const response = await provider.complete<{ name: string; age: number }>({
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
responseType
|
|
});
|
|
|
|
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
|
|
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
|
|
expect((provider as any).doComplete).toHaveBeenCalled();
|
|
});
|
|
});
|
|
}); |