diff --git a/tests/integration/claude-code-optional.test.js b/tests/integration/claude-code-optional.test.js new file mode 100644 index 00000000..6dc791e3 --- /dev/null +++ b/tests/integration/claude-code-optional.test.js @@ -0,0 +1,85 @@ +import { jest } from '@jest/globals'; + +// Mock the base provider to avoid circular dependencies +jest.unstable_mockModule('../../src/ai-providers/base-provider.js', () => ({ + BaseAIProvider: class { + constructor() { + this.name = 'Base Provider'; + } + handleError(context, error) { + throw error; + } + } +})); + +// Mock the claude-code SDK to simulate it not being installed +jest.unstable_mockModule('@anthropic-ai/claude-code', () => { + throw new Error("Cannot find module '@anthropic-ai/claude-code'"); +}); + +// Import after mocking +const { ClaudeCodeProvider } = await import('../../src/ai-providers/claude-code.js'); + +describe('Claude Code Optional Dependency Integration', () => { + describe('when @anthropic-ai/claude-code is not installed', () => { + it('should allow provider instantiation', () => { + // Provider should instantiate without error + const provider = new ClaudeCodeProvider(); + expect(provider).toBeDefined(); + expect(provider.name).toBe('Claude Code'); + }); + + it('should allow client creation', () => { + const provider = new ClaudeCodeProvider(); + // Client creation should work + const client = provider.getClient({}); + expect(client).toBeDefined(); + expect(typeof client).toBe('function'); + }); + + it('should fail with clear error when trying to use the model', async () => { + const provider = new ClaudeCodeProvider(); + const client = provider.getClient({}); + const model = client('opus'); + + // The actual usage should fail with the lazy loading error + await expect(model.doGenerate({ + prompt: [{ role: 'user', content: 'Hello' }], + mode: { type: 'regular' } + })).rejects.toThrow("Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider."); + }); + + it('should provide helpful error message for streaming', async () => { + const provider = new ClaudeCodeProvider(); + const client = provider.getClient({}); + const model = client('sonnet'); + + await expect(model.doStream({ + prompt: [{ role: 'user', content: 'Hello' }], + mode: { type: 'regular' } + })).rejects.toThrow("Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider."); + }); + }); + + describe('provider behavior', () => { + it('should not require API key', () => { + const provider = new ClaudeCodeProvider(); + // Should not throw + expect(() => provider.validateAuth()).not.toThrow(); + expect(() => provider.validateAuth({ apiKey: null })).not.toThrow(); + }); + + it('should work with ai-services-unified when provider is configured', async () => { + // This tests that the provider can be selected but will fail appropriately + // when the actual model is used + const provider = new ClaudeCodeProvider(); + expect(provider).toBeDefined(); + + // In real usage, ai-services-unified would: + // 1. Get the provider instance (works) + // 2. Call provider.getClient() (works) + // 3. Create a model (works) + // 4. Try to generate (fails with clear error) + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/ai-providers/claude-code.test.js b/tests/unit/ai-providers/claude-code.test.js new file mode 100644 index 00000000..3edff893 --- /dev/null +++ b/tests/unit/ai-providers/claude-code.test.js @@ -0,0 +1,104 @@ +import { jest } from '@jest/globals'; + +// Mock the claude-code SDK module +jest.unstable_mockModule('../../../src/ai-providers/custom-sdk/claude-code/index.js', () => ({ + createClaudeCode: jest.fn(() => { + const provider = (modelId, settings) => ({ + // Mock language model + id: modelId, + settings + }); + provider.languageModel = jest.fn((id, settings) => ({ id, settings })); + provider.chat = provider.languageModel; + return provider; + }) +})); + +// Mock the base provider +jest.unstable_mockModule('../../../src/ai-providers/base-provider.js', () => ({ + BaseAIProvider: class { + constructor() { + this.name = 'Base Provider'; + } + handleError(context, error) { + throw error; + } + } +})); + +// Import after mocking +const { ClaudeCodeProvider } = await import('../../../src/ai-providers/claude-code.js'); + +describe('ClaudeCodeProvider', () => { + let provider; + + beforeEach(() => { + provider = new ClaudeCodeProvider(); + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should set the provider name to Claude Code', () => { + expect(provider.name).toBe('Claude Code'); + }); + }); + + describe('validateAuth', () => { + it('should not throw an error (no API key required)', () => { + expect(() => provider.validateAuth({})).not.toThrow(); + }); + + it('should not require any parameters', () => { + expect(() => provider.validateAuth()).not.toThrow(); + }); + + it('should work with any params passed', () => { + expect(() => provider.validateAuth({ + apiKey: 'some-key', + baseURL: 'https://example.com' + })).not.toThrow(); + }); + }); + + describe('getClient', () => { + it('should return a claude code client', () => { + const client = provider.getClient({}); + expect(client).toBeDefined(); + expect(typeof client).toBe('function'); + }); + + it('should create client without API key or base URL', () => { + const client = provider.getClient({}); + expect(client).toBeDefined(); + }); + + it('should handle params even though they are not used', () => { + const client = provider.getClient({ + baseURL: 'https://example.com', + apiKey: 'unused-key' + }); + expect(client).toBeDefined(); + }); + + it('should have languageModel and chat methods', () => { + const client = provider.getClient({}); + expect(client.languageModel).toBeDefined(); + expect(client.chat).toBeDefined(); + expect(client.chat).toBe(client.languageModel); + }); + }); + + describe('error handling', () => { + it('should handle client initialization errors', async () => { + // Force an error by making createClaudeCode throw + const { createClaudeCode } = await import('../../../src/ai-providers/custom-sdk/claude-code/index.js'); + createClaudeCode.mockImplementationOnce(() => { + throw new Error('Mock initialization error'); + }); + + // Create a new provider instance to use the mocked createClaudeCode + const errorProvider = new ClaudeCodeProvider(); + expect(() => errorProvider.getClient({})).toThrow('Mock initialization error'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/ai-providers/custom-sdk/claude-code/language-model.test.js b/tests/unit/ai-providers/custom-sdk/claude-code/language-model.test.js new file mode 100644 index 00000000..d45ae498 --- /dev/null +++ b/tests/unit/ai-providers/custom-sdk/claude-code/language-model.test.js @@ -0,0 +1,200 @@ +import { jest } from '@jest/globals'; + +// Mock modules before importing +jest.unstable_mockModule('@ai-sdk/provider', () => ({ + NoSuchModelError: class NoSuchModelError extends Error { + constructor({ modelId, modelType }) { + super(`No such model: ${modelId}`); + this.modelId = modelId; + this.modelType = modelType; + } + } +})); + +jest.unstable_mockModule('@ai-sdk/provider-utils', () => ({ + generateId: jest.fn(() => 'test-id-123') +})); + +jest.unstable_mockModule('../../../../../src/ai-providers/custom-sdk/claude-code/message-converter.js', () => ({ + convertToClaudeCodeMessages: jest.fn((prompt) => ({ + messagesPrompt: 'converted-prompt', + systemPrompt: 'system' + })) +})); + +jest.unstable_mockModule('../../../../../src/ai-providers/custom-sdk/claude-code/json-extractor.js', () => ({ + extractJson: jest.fn((text) => text) +})); + +jest.unstable_mockModule('../../../../../src/ai-providers/custom-sdk/claude-code/errors.js', () => ({ + createAPICallError: jest.fn((opts) => new Error(opts.message)), + createAuthenticationError: jest.fn((opts) => new Error(opts.message)) +})); + +// This mock will be controlled by tests +let mockClaudeCodeModule = null; +jest.unstable_mockModule('@anthropic-ai/claude-code', () => { + if (mockClaudeCodeModule) { + return mockClaudeCodeModule; + } + throw new Error("Cannot find module '@anthropic-ai/claude-code'"); +}); + +// Import the module under test +const { ClaudeCodeLanguageModel } = await import('../../../../../src/ai-providers/custom-sdk/claude-code/language-model.js'); + +describe('ClaudeCodeLanguageModel', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the module mock + mockClaudeCodeModule = null; + // Clear module cache to ensure fresh imports + jest.resetModules(); + }); + + describe('constructor', () => { + it('should initialize with valid model ID', () => { + const model = new ClaudeCodeLanguageModel({ + id: 'opus', + settings: { maxTurns: 5 } + }); + + expect(model.modelId).toBe('opus'); + expect(model.settings).toEqual({ maxTurns: 5 }); + expect(model.provider).toBe('claude-code'); + }); + + it('should throw NoSuchModelError for invalid model ID', async () => { + expect(() => new ClaudeCodeLanguageModel({ + id: '', + settings: {} + })).toThrow('No such model: '); + + expect(() => new ClaudeCodeLanguageModel({ + id: null, + settings: {} + })).toThrow('No such model: null'); + }); + }); + + describe('lazy loading of @anthropic-ai/claude-code', () => { + it('should throw error when package is not installed', async () => { + // Keep mockClaudeCodeModule as null to simulate missing package + const model = new ClaudeCodeLanguageModel({ + id: 'opus', + settings: {} + }); + + await expect(model.doGenerate({ + prompt: [{ role: 'user', content: 'test' }], + mode: { type: 'regular' } + })).rejects.toThrow("Claude Code SDK is not installed. Please install '@anthropic-ai/claude-code' to use the claude-code provider."); + }); + + it('should load package successfully when available', async () => { + // Mock successful package load + const mockQuery = jest.fn(async function* () { + yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }; + yield { type: 'result', subtype: 'done', usage: { output_tokens: 10, input_tokens: 5 } }; + }); + + mockClaudeCodeModule = { + query: mockQuery, + AbortError: class AbortError extends Error {} + }; + + // Need to re-import to get fresh module with mocks + jest.resetModules(); + const { ClaudeCodeLanguageModel: FreshModel } = await import('../../../../../src/ai-providers/custom-sdk/claude-code/language-model.js'); + + const model = new FreshModel({ + id: 'opus', + settings: {} + }); + + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: 'test' }], + mode: { type: 'regular' } + }); + + expect(result.text).toBe('Hello'); + expect(mockQuery).toHaveBeenCalled(); + }); + + it('should only attempt to load package once', async () => { + // Get a fresh import to ensure clean state + jest.resetModules(); + const { ClaudeCodeLanguageModel: TestModel } = await import('../../../../../src/ai-providers/custom-sdk/claude-code/language-model.js'); + + const model = new TestModel({ + id: 'opus', + settings: {} + }); + + // First call should throw + await expect(model.doGenerate({ + prompt: [{ role: 'user', content: 'test' }], + mode: { type: 'regular' } + })).rejects.toThrow("Claude Code SDK is not installed"); + + // Second call should also throw without trying to load again + await expect(model.doGenerate({ + prompt: [{ role: 'user', content: 'test' }], + mode: { type: 'regular' } + })).rejects.toThrow("Claude Code SDK is not installed"); + }); + }); + + describe('generateUnsupportedWarnings', () => { + it('should generate warnings for unsupported parameters', () => { + const model = new ClaudeCodeLanguageModel({ + id: 'opus', + settings: {} + }); + + const warnings = model.generateUnsupportedWarnings({ + temperature: 0.7, + maxTokens: 1000, + topP: 0.9, + seed: 42 + }); + + expect(warnings).toHaveLength(4); + expect(warnings[0]).toEqual({ + type: 'unsupported-setting', + setting: 'temperature', + details: 'Claude Code CLI does not support the temperature parameter. It will be ignored.' + }); + }); + + it('should return empty array when no unsupported parameters', () => { + const model = new ClaudeCodeLanguageModel({ + id: 'opus', + settings: {} + }); + + const warnings = model.generateUnsupportedWarnings({}); + expect(warnings).toEqual([]); + }); + }); + + describe('getModel', () => { + it('should map model IDs correctly', () => { + const model = new ClaudeCodeLanguageModel({ + id: 'opus', + settings: {} + }); + + expect(model.getModel()).toBe('opus'); + }); + + it('should return unmapped model IDs as-is', () => { + const model = new ClaudeCodeLanguageModel({ + id: 'custom-model', + settings: {} + }); + + expect(model.getModel()).toBe('custom-model'); + }); + }); +}); \ No newline at end of file