commit 42902445fb10b3f6b9cfcd0197d1b54e23cf07fe Author: Jan-Marlon Leibl Date: Wed May 28 11:54:24 2025 +0200 feat: add initial implementation of Simple AI Provider package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..bce8521 --- /dev/null +++ b/.npmignore @@ -0,0 +1,32 @@ +# Source files +src/ +examples/ +*.test.ts +*.spec.ts + +# Development files +.gitignore +tsconfig.json +bun.lock + +# Build artifacts (keep only dist) +node_modules/ + +# Documentation (keep README.md) +docs/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2415cc --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +# Simple AI Provider + +A professional, extensible TypeScript package for integrating multiple AI providers into your applications with a unified interface. Currently supports Claude (Anthropic) with plans to add more providers. + +## Features + +- 🎯 **Unified Interface**: Same API across all AI providers +- 🔒 **Type-Safe**: Full TypeScript support with comprehensive type definitions +- 🚀 **Easy to Use**: Simple factory functions and intuitive configuration +- 📡 **Streaming Support**: Real-time streaming responses where supported +- 🛡️ **Error Handling**: Robust error handling with categorized error types +- 🔧 **Extensible**: Easy to add new AI providers +- 📦 **Modern**: Built with ES modules and modern JavaScript features + +## Installation + +```bash +npm install simple-ai-provider +# or +yarn add simple-ai-provider +# or +bun add simple-ai-provider +``` + +## Quick Start + +### Basic Usage with Claude + +```typescript +import { createClaudeProvider } from 'simple-ai-provider'; + +// Create a Claude provider +const claude = createClaudeProvider('your-anthropic-api-key'); + +// Initialize the provider +await claude.initialize(); + +// Generate a completion +const response = await claude.complete({ + messages: [ + { role: 'user', content: 'Hello! How are you today?' } + ], + maxTokens: 100, + temperature: 0.7 +}); + +console.log(response.content); +``` + +### Streaming Responses + +```typescript +import { createClaudeProvider } from 'simple-ai-provider'; + +const claude = createClaudeProvider('your-anthropic-api-key'); +await claude.initialize(); + +// Stream a completion +for await (const chunk of claude.stream({ + messages: [ + { role: 'user', content: 'Write a short story about a robot.' } + ], + maxTokens: 500 +})) { + if (!chunk.isComplete) { + process.stdout.write(chunk.content); + } else { + console.log('\n\nUsage:', chunk.usage); + } +} +``` + +### Advanced Configuration + +```typescript +import { ClaudeProvider } from 'simple-ai-provider'; + +const claude = new ClaudeProvider({ + apiKey: 'your-anthropic-api-key', + defaultModel: 'claude-3-5-sonnet-20241022', + timeout: 30000, + maxRetries: 3, + baseUrl: 'https://api.anthropic.com' // optional custom endpoint +}); + +await claude.initialize(); + +const response = await claude.complete({ + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Explain quantum computing in simple terms.' } + ], + model: 'claude-3-5-haiku-20241022', + maxTokens: 300, + temperature: 0.5, + topP: 0.9, + stopSequences: ['\n\n'] +}); +``` + +## API Reference + +### Core Types + +#### `AIMessage` +```typescript +interface AIMessage { + role: 'system' | 'user' | 'assistant'; + content: string; + metadata?: Record; +} +``` + +#### `CompletionParams` +```typescript +interface CompletionParams { + messages: AIMessage[]; + model?: string; + maxTokens?: number; + temperature?: number; + topP?: number; + stopSequences?: string[]; + stream?: boolean; +} +``` + +#### `CompletionResponse` +```typescript +interface CompletionResponse { + content: string; + model: string; + usage: TokenUsage; + id: string; + metadata?: Record; +} +``` + +### Factory Functions + +#### `createClaudeProvider(apiKey, options?)` +Creates a Claude provider with simplified configuration. + +```typescript +const claude = createClaudeProvider('your-api-key', { + defaultModel: 'claude-3-5-sonnet-20241022', + timeout: 30000 +}); +``` + +#### `createProvider(type, config)` +Generic factory function for creating any provider type. + +```typescript +const claude = createProvider('claude', { + apiKey: 'your-api-key', + defaultModel: 'claude-3-5-sonnet-20241022' +}); +``` + +### Provider Methods + +#### `initialize(): Promise` +Initializes the provider and validates the configuration. + +#### `complete(params): Promise` +Generates a completion based on the provided parameters. + +#### `stream(params): AsyncIterable` +Generates a streaming completion. + +#### `getInfo(): ProviderInfo` +Returns information about the provider and its capabilities. + +#### `isInitialized(): boolean` +Checks if the provider has been initialized. + +## Error Handling + +The package provides comprehensive error handling with categorized error types: + +```typescript +import { AIProviderError, AIErrorType } from 'simple-ai-provider'; + +try { + const response = await claude.complete({ + messages: [{ role: 'user', content: 'Hello!' }] + }); +} catch (error) { + if (error instanceof AIProviderError) { + switch (error.type) { + case AIErrorType.AUTHENTICATION: + console.error('Invalid API key'); + break; + case AIErrorType.RATE_LIMIT: + console.error('Rate limit exceeded'); + break; + case AIErrorType.INVALID_REQUEST: + console.error('Invalid request parameters'); + break; + default: + console.error('Unknown error:', error.message); + } + } +} +``` + +## Supported Models + +### Claude (Anthropic) +- `claude-3-5-sonnet-20241022` (default) +- `claude-3-5-haiku-20241022` +- `claude-3-opus-20240229` +- `claude-3-sonnet-20240229` +- `claude-3-haiku-20240307` + +## Environment Variables + +You can set your API keys as environment variables: + +```bash +export ANTHROPIC_API_KEY="your-anthropic-api-key" +``` + +```typescript +const claude = createClaudeProvider(process.env.ANTHROPIC_API_KEY!); +``` + +## Best Practices + +1. **Always initialize providers** before using them +2. **Handle errors gracefully** with proper error types +3. **Use appropriate models** for your use case +4. **Set reasonable timeouts** for your application +5. **Implement retry logic** for production applications +6. **Monitor token usage** to control costs + +## Extending the Package + +To add a new AI provider, extend the `BaseAIProvider` class: + +```typescript +import { BaseAIProvider } from 'simple-ai-provider'; + +class MyCustomProvider extends BaseAIProvider { + protected async doInitialize(): Promise { + // Initialize your provider + } + + protected async doComplete(params: CompletionParams): Promise { + // Implement completion logic + } + + protected async *doStream(params: CompletionParams): AsyncIterable { + // Implement streaming logic + } + + public getInfo(): ProviderInfo { + return { + name: 'MyCustomProvider', + version: '1.0.0', + models: ['my-model'], + maxContextLength: 4096, + supportsStreaming: true + }; + } +} +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. + +## License + +MIT + +## Changelog + +### 1.0.0 +- Initial release +- Claude provider implementation +- Streaming support +- Comprehensive error handling +- TypeScript support diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ca38851 --- /dev/null +++ b/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "simple-ai-provider", + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^20.0.0", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.52.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ=="], + + "@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="], + + "@types/node": ["@types/node@20.17.51", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-hccptBl7C8lHiKxTBsY6vYYmqpmw1E/aGR/8fmueE+B390L3pdMOpNSRvFO4ZnXzW5+p2HBXV0yNABd2vdk22Q=="], + + "bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + + "bun-types/@types/node": ["@types/node@22.15.23", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-7Ec1zaFPF4RJ0eXu1YT/xgiebqwqoJz8rYPDi/O2BcZ++Wpt0Kq9cl0eg6NN6bYbPnR67ZLo7St5Q3UK0SnARw=="], + + "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts new file mode 100644 index 0000000..d16bd72 --- /dev/null +++ b/examples/basic-usage.ts @@ -0,0 +1,82 @@ +/** + * Basic usage example for Simple AI Provider + * Demonstrates how to use the Claude provider + */ + +import { createClaudeProvider, AIProviderError, AIErrorType } from '../src/index.js'; + +async function basicExample() { + // Replace with your actual API key + const apiKey = process.env.ANTHROPIC_API_KEY || 'your-api-key-here'; + + try { + // Create and initialize the Claude provider + console.log('Creating Claude provider...'); + const claude = createClaudeProvider(apiKey, { + defaultModel: 'claude-3-5-haiku-20241022', // Using faster model for demo + timeout: 30000 + }); + + console.log('Initializing provider...'); + await claude.initialize(); + + console.log('Provider info:', claude.getInfo()); + + // Basic completion + console.log('\n--- Basic Completion ---'); + const response = await claude.complete({ + messages: [ + { role: 'system', content: 'You are a helpful assistant that responds concisely.' }, + { role: 'user', content: 'What is TypeScript?' } + ], + maxTokens: 150, + temperature: 0.7 + }); + + console.log('Response:', response.content); + console.log('Usage:', response.usage); + + // Streaming example + console.log('\n--- Streaming Example ---'); + console.log('Streaming response for: "Write a haiku about coding"\n'); + + for await (const chunk of claude.stream({ + messages: [ + { role: 'user', content: 'Write a haiku about coding' } + ], + maxTokens: 100 + })) { + if (!chunk.isComplete) { + process.stdout.write(chunk.content); + } else { + console.log('\n\nStream completed. Usage:', chunk.usage); + } + } + + } catch (error) { + if (error instanceof AIProviderError) { + console.error(`AI Provider Error (${error.type}):`, error.message); + + switch (error.type) { + case AIErrorType.AUTHENTICATION: + console.error('Please check your API key.'); + break; + case AIErrorType.RATE_LIMIT: + console.error('You are being rate limited. Please wait and try again.'); + break; + case AIErrorType.INVALID_REQUEST: + console.error('Invalid request parameters.'); + break; + default: + console.error('An unexpected error occurred.'); + } + } else { + console.error('Unexpected error:', error); + } + } +} + +// Run the example +if (import.meta.main) { + basicExample().catch(console.error); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..94e6ac2 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "simple-ai-provider", + "version": "1.0.0", + "description": "A simple and extensible AI provider package for easy integration of multiple AI services", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "scripts": { + "build": "bun run build:clean && bun run build:types && bun run build:esm && bun run build:cjs", + "build:clean": "rm -rf dist", + "build:types": "tsc --project tsconfig.build.json", + "build:esm": "bun build src/index.ts --outfile dist/index.mjs --format esm", + "build:cjs": "bun build src/index.ts --outfile dist/index.js --format cjs", + "dev": "bun run src/index.ts", + "test": "bun test", + "prepublishOnly": "bun run build" + }, + "keywords": [ + "ai", + "claude", + "anthropic", + "provider", + "typescript", + "nodejs" + ], + "author": "Jan-Marlon Leibl", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://gitea.jleibl.net/jleibl/simple-ai-provider.git" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^20.0.0" + }, + "peerDependencies": { + "typescript": "^5" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f166f64 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,52 @@ +/** + * Simple AI Provider - Main Entry Point + * + * A professional, extensible package for integrating multiple AI providers + * into your applications with a unified interface. + * + * @author Jan-Marlon Leibl + * @version 1.0.0 + */ + +// Core types and interfaces +export type { + AIProviderConfig, + AIMessage, + MessageRole, + CompletionParams, + CompletionResponse, + CompletionChunk, + TokenUsage, + ProviderInfo +} from './types/index.js'; + +// Error handling +export { AIProviderError, AIErrorType } from './types/index.js'; + +// Base provider class +export { BaseAIProvider } from './providers/base.js'; + +// Concrete provider implementations +export { ClaudeProvider, type ClaudeConfig } from './providers/claude.js'; + +// Utility functions and factory +export { + createProvider, + createClaudeProvider, + ProviderRegistry, + type ProviderType, + type ProviderConfigMap +} from './utils/factory.js'; + +// Re-export everything from providers for convenience +export * from './providers/index.js'; + +/** + * Package version + */ +export const VERSION = '1.0.0'; + +/** + * List of supported providers + */ +export const SUPPORTED_PROVIDERS = ['claude'] as const; \ No newline at end of file diff --git a/src/providers/base.ts b/src/providers/base.ts new file mode 100644 index 0000000..7925bbe --- /dev/null +++ b/src/providers/base.ts @@ -0,0 +1,264 @@ +/** + * Abstract base class for all AI providers + * Provides common functionality and enforces a consistent interface + */ + +import type { + AIProviderConfig, + CompletionParams, + CompletionResponse, + CompletionChunk, + ProviderInfo +} from '../types/index.js'; +import { AIProviderError, AIErrorType } from '../types/index.js'; + +/** + * Abstract base class that all AI providers must extend + */ +export abstract class BaseAIProvider { + protected config: AIProviderConfig; + protected initialized: boolean = false; + + constructor(config: AIProviderConfig) { + this.config = this.validateConfig(config); + } + + /** + * Validates the provider configuration + * @param config - Configuration object to validate + * @returns Validated configuration + * @throws AIProviderError if configuration is invalid + */ + protected validateConfig(config: AIProviderConfig): AIProviderConfig { + if (!config.apiKey || typeof config.apiKey !== 'string') { + throw new AIProviderError( + 'API key is required and must be a string', + AIErrorType.INVALID_REQUEST + ); + } + + // Set default values + const validatedConfig = { + ...config, + timeout: config.timeout ?? 30000, + maxRetries: config.maxRetries ?? 3 + }; + + return validatedConfig; + } + + /** + * Initialize the provider (setup connections, validate credentials, etc.) + * Must be called before using the provider + */ + public async initialize(): Promise { + try { + await this.doInitialize(); + this.initialized = true; + } catch (error) { + throw this.handleError(error as Error); + } + } + + /** + * Check if the provider is initialized + */ + public isInitialized(): boolean { + return this.initialized; + } + + /** + * Generate a completion based on the provided parameters + * @param params - Parameters for the completion request + * @returns Promise resolving to completion response + */ + public async complete(params: CompletionParams): Promise { + this.ensureInitialized(); + this.validateCompletionParams(params); + + try { + return await this.doComplete(params); + } catch (error) { + throw this.handleError(error as Error); + } + } + + /** + * Generate a streaming completion + * @param params - Parameters for the completion request + * @returns AsyncIterable of completion chunks + */ + public async *stream(params: CompletionParams): AsyncIterable { + this.ensureInitialized(); + this.validateCompletionParams(params); + + try { + yield* this.doStream(params); + } catch (error) { + throw this.handleError(error as Error); + } + } + + /** + * Get information about this provider + * @returns Provider information and capabilities + */ + public abstract getInfo(): ProviderInfo; + + /** + * Provider-specific initialization logic + * Override this method in concrete implementations + */ + protected abstract doInitialize(): Promise; + + /** + * Provider-specific completion logic + * Override this method in concrete implementations + */ + protected abstract doComplete(params: CompletionParams): Promise; + + /** + * Provider-specific streaming logic + * Override this method in concrete implementations + */ + protected abstract doStream(params: CompletionParams): AsyncIterable; + + /** + * Ensures the provider is initialized before use + * @throws AIProviderError if not initialized + */ + protected ensureInitialized(): void { + if (!this.initialized) { + throw new AIProviderError( + 'Provider must be initialized before use. Call initialize() first.', + AIErrorType.INVALID_REQUEST + ); + } + } + + /** + * Validates completion parameters + * @param params - Parameters to validate + * @throws AIProviderError if parameters are invalid + */ + protected validateCompletionParams(params: CompletionParams): void { + if (!params.messages || !Array.isArray(params.messages) || params.messages.length === 0) { + throw new AIProviderError( + 'Messages array is required and must not be empty', + AIErrorType.INVALID_REQUEST + ); + } + + for (const message of params.messages) { + if (!message.role || !['system', 'user', 'assistant'].includes(message.role)) { + throw new AIProviderError( + 'Each message must have a valid role (system, user, or assistant)', + AIErrorType.INVALID_REQUEST + ); + } + + if (!message.content || typeof message.content !== 'string') { + throw new AIProviderError( + 'Each message must have non-empty string content', + AIErrorType.INVALID_REQUEST + ); + } + } + + if (params.temperature !== undefined && (params.temperature < 0 || params.temperature > 1)) { + throw new AIProviderError( + 'Temperature must be between 0.0 and 1.0', + AIErrorType.INVALID_REQUEST + ); + } + + if (params.topP !== undefined && (params.topP < 0 || params.topP > 1)) { + throw new AIProviderError( + 'Top-p must be between 0.0 and 1.0', + AIErrorType.INVALID_REQUEST + ); + } + + if (params.maxTokens !== undefined && params.maxTokens < 1) { + throw new AIProviderError( + 'Max tokens must be a positive integer', + AIErrorType.INVALID_REQUEST + ); + } + } + + /** + * Handles and transforms errors into AIProviderError instances + * @param error - The original error + * @returns AIProviderError with appropriate type and context + */ + protected handleError(error: Error): AIProviderError { + if (error instanceof AIProviderError) { + return error; + } + + // Handle common HTTP status codes + if ('status' in error) { + const status = (error as any).status; + switch (status) { + case 401: + case 403: + return new AIProviderError( + 'Authentication failed. Please check your API key.', + AIErrorType.AUTHENTICATION, + status, + error + ); + case 429: + return new AIProviderError( + 'Rate limit exceeded. Please try again later.', + AIErrorType.RATE_LIMIT, + status, + error + ); + case 404: + return new AIProviderError( + 'Model or endpoint not found.', + AIErrorType.MODEL_NOT_FOUND, + status, + error + ); + case 400: + return new AIProviderError( + 'Invalid request parameters.', + AIErrorType.INVALID_REQUEST, + status, + error + ); + } + } + + // Handle timeout errors + if (error.message.includes('timeout') || error.message.includes('ETIMEDOUT')) { + return new AIProviderError( + 'Request timed out. Please try again.', + AIErrorType.TIMEOUT, + undefined, + error + ); + } + + // Handle network errors + if (error.message.includes('network') || error.message.includes('ENOTFOUND')) { + return new AIProviderError( + 'Network error occurred. Please check your connection.', + AIErrorType.NETWORK, + undefined, + error + ); + } + + // Default to unknown error + return new AIProviderError( + `Unknown error: ${error.message}`, + AIErrorType.UNKNOWN, + undefined, + error + ); + } +} \ No newline at end of file diff --git a/src/providers/claude.ts b/src/providers/claude.ts new file mode 100644 index 0000000..cf9a14c --- /dev/null +++ b/src/providers/claude.ts @@ -0,0 +1,323 @@ +/** + * Claude AI Provider implementation using Anthropic's API + * Provides integration with Claude models through a standardized interface + */ + +import Anthropic from '@anthropic-ai/sdk'; +import type { + AIProviderConfig, + CompletionParams, + CompletionResponse, + CompletionChunk, + ProviderInfo, + AIMessage +} from '../types/index.js'; +import { BaseAIProvider } from './base.js'; +import { AIProviderError, AIErrorType } from '../types/index.js'; + +/** + * Configuration specific to Claude provider + */ +export interface ClaudeConfig extends AIProviderConfig { + /** Default model to use if not specified in requests (default: claude-3-5-sonnet-20241022) */ + defaultModel?: string; + /** Anthropic API version (default: 2023-06-01) */ + version?: string; +} + +/** + * Claude AI provider implementation + */ +export class ClaudeProvider extends BaseAIProvider { + private client: Anthropic | null = null; + private readonly defaultModel: string; + private readonly version: string; + + constructor(config: ClaudeConfig) { + super(config); + this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022'; + this.version = config.version || '2023-06-01'; + } + + /** + * Initialize the Claude provider by setting up the Anthropic client + */ + protected async doInitialize(): Promise { + try { + this.client = new Anthropic({ + apiKey: this.config.apiKey, + baseURL: this.config.baseUrl, + timeout: this.config.timeout, + maxRetries: this.config.maxRetries, + defaultHeaders: { + 'anthropic-version': this.version + } + }); + + // Test the connection by making a simple request + await this.validateConnection(); + } catch (error) { + throw new AIProviderError( + `Failed to initialize Claude provider: ${(error as Error).message}`, + AIErrorType.AUTHENTICATION, + undefined, + error as Error + ); + } + } + + /** + * Generate a completion using Claude + */ + protected async doComplete(params: CompletionParams): Promise { + if (!this.client) { + throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + } + + try { + const { system, messages } = this.convertMessages(params.messages); + + const response = await this.client.messages.create({ + model: params.model || this.defaultModel, + max_tokens: params.maxTokens || 1000, + temperature: params.temperature ?? 0.7, + top_p: params.topP, + stop_sequences: params.stopSequences, + system: system || undefined, + messages: messages.map(msg => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content + })) + }); + + return this.formatCompletionResponse(response); + } catch (error) { + throw this.handleAnthropicError(error as Error); + } + } + + /** + * Generate a streaming completion using Claude + */ + protected async *doStream(params: CompletionParams): AsyncIterable { + if (!this.client) { + throw new AIProviderError('Client not initialized', AIErrorType.INVALID_REQUEST); + } + + try { + const { system, messages } = this.convertMessages(params.messages); + + const stream = await this.client.messages.create({ + model: params.model || this.defaultModel, + max_tokens: params.maxTokens || 1000, + temperature: params.temperature ?? 0.7, + top_p: params.topP, + stop_sequences: params.stopSequences, + system: system || undefined, + messages: messages.map(msg => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content + })), + stream: true + }); + + let content = ''; + let messageId = ''; + + for await (const chunk of stream) { + if (chunk.type === 'message_start') { + messageId = chunk.message.id; + } else if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { + content += chunk.delta.text; + yield { + content: chunk.delta.text, + isComplete: false, + id: messageId + }; + } else if (chunk.type === 'message_delta' && chunk.usage) { + // Final chunk with usage information + yield { + content: '', + isComplete: true, + id: messageId, + usage: { + promptTokens: chunk.usage.input_tokens || 0, + completionTokens: chunk.usage.output_tokens || 0, + totalTokens: (chunk.usage.input_tokens || 0) + (chunk.usage.output_tokens || 0) + } + }; + } + } + } catch (error) { + throw this.handleAnthropicError(error as Error); + } + } + + /** + * Get information about the Claude provider + */ + public getInfo(): ProviderInfo { + return { + name: 'Claude', + version: '1.0.0', + models: [ + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229', + 'claude-3-sonnet-20240229', + 'claude-3-haiku-20240307' + ], + maxContextLength: 200000, // Claude 3.5 Sonnet context length + supportsStreaming: true, + capabilities: { + vision: true, + functionCalling: true, + systemMessages: true + } + }; + } + + /** + * Validate the connection by making a simple request + */ + private async validateConnection(): Promise { + if (!this.client) { + throw new Error('Client not initialized'); + } + + try { + // Make a minimal request to validate credentials + await this.client.messages.create({ + model: this.defaultModel, + max_tokens: 1, + messages: [{ role: 'user', content: 'Hi' }] + }); + } catch (error: any) { + if (error.status === 401 || error.status === 403) { + throw new AIProviderError( + 'Invalid API key. Please check your Anthropic API key.', + AIErrorType.AUTHENTICATION, + error.status + ); + } + // For other errors during validation, we'll let initialization proceed + // as they might be temporary issues + } + } + + /** + * Convert our generic message format to Claude's format + * Claude requires system messages to be separate from the conversation + */ + private convertMessages(messages: AIMessage[]): { system: string | null; messages: AIMessage[] } { + let system: string | null = null; + const conversationMessages: AIMessage[] = []; + + for (const message of messages) { + if (message.role === 'system') { + // Combine multiple system messages + if (system) { + system += '\n\n' + message.content; + } else { + system = message.content; + } + } else { + conversationMessages.push(message); + } + } + + return { system, messages: conversationMessages }; + } + + /** + * Format Anthropic's response to our standard format + */ + private formatCompletionResponse(response: any): CompletionResponse { + const content = response.content + .filter((block: any) => block.type === 'text') + .map((block: any) => block.text) + .join(''); + + return { + content, + model: response.model, + usage: { + promptTokens: response.usage.input_tokens, + completionTokens: response.usage.output_tokens, + totalTokens: response.usage.input_tokens + response.usage.output_tokens + }, + id: response.id, + metadata: { + stopReason: response.stop_reason, + stopSequence: response.stop_sequence + } + }; + } + + /** + * Handle Anthropic-specific errors and convert them to our standard format + */ + private handleAnthropicError(error: any): AIProviderError { + if (error instanceof AIProviderError) { + return error; + } + + const status = error.status || error.statusCode; + const message = error.message || 'Unknown Anthropic API error'; + + switch (status) { + case 400: + return new AIProviderError( + `Invalid request: ${message}`, + AIErrorType.INVALID_REQUEST, + status, + error + ); + case 401: + return new AIProviderError( + 'Authentication failed. Please check your Anthropic API key.', + AIErrorType.AUTHENTICATION, + status, + error + ); + case 403: + return new AIProviderError( + 'Access forbidden. Please check your API key permissions.', + AIErrorType.AUTHENTICATION, + status, + error + ); + case 404: + return new AIProviderError( + 'Model not found. Please check the model name.', + AIErrorType.MODEL_NOT_FOUND, + status, + error + ); + case 429: + return new AIProviderError( + 'Rate limit exceeded. Please slow down your requests.', + AIErrorType.RATE_LIMIT, + status, + error + ); + case 500: + case 502: + case 503: + case 504: + return new AIProviderError( + 'Anthropic service temporarily unavailable. Please try again later.', + AIErrorType.NETWORK, + status, + error + ); + default: + return new AIProviderError( + `Anthropic API error: ${message}`, + AIErrorType.UNKNOWN, + status, + error + ); + } + } +} \ No newline at end of file diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..7425232 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,7 @@ +/** + * Provider exports + * Centralizes all AI provider implementations for easy importing + */ + +export { BaseAIProvider } from './base.js'; +export { ClaudeProvider, type ClaudeConfig } from './claude.js'; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..1c0cbf3 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,147 @@ +/** + * Core types and interfaces for the Simple AI Provider package + * Defines the contract that all AI providers must implement + */ + +/** + * Configuration options for AI providers + */ +export interface AIProviderConfig { + /** API key for the AI service */ + apiKey: string; + /** Optional base URL for custom endpoints */ + baseUrl?: string; + /** Request timeout in milliseconds (default: 30000) */ + timeout?: number; + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Additional provider-specific options */ + [key: string]: any; +} + +/** + * Message role types supported by AI providers + */ +export type MessageRole = 'system' | 'user' | 'assistant'; + +/** + * Individual message in a conversation + */ +export interface AIMessage { + /** Role of the message sender */ + role: MessageRole; + /** Content of the message */ + content: string; + /** Optional metadata for the message */ + metadata?: Record; +} + +/** + * Parameters for AI completion requests + */ +export interface CompletionParams { + /** Array of messages forming the conversation */ + messages: AIMessage[]; + /** Model to use for completion */ + model?: string; + /** Maximum tokens to generate (default: 1000) */ + maxTokens?: number; + /** Temperature for randomness (0.0 to 1.0, default: 0.7) */ + temperature?: number; + /** Top-p for nucleus sampling (default: 1.0) */ + topP?: number; + /** Stop sequences to end generation */ + stopSequences?: string[]; + /** Whether to stream the response */ + stream?: boolean; + /** Additional provider-specific parameters */ + [key: string]: any; +} + +/** + * Usage statistics for a completion + */ +export interface TokenUsage { + /** Number of tokens in the prompt */ + promptTokens: number; + /** Number of tokens in the completion */ + completionTokens: number; + /** Total number of tokens used */ + totalTokens: number; +} + +/** + * Response from an AI completion request + */ +export interface CompletionResponse { + /** Generated content */ + content: string; + /** Model used for generation */ + model: string; + /** Token usage statistics */ + usage: TokenUsage; + /** Unique identifier for the request */ + id: string; + /** Provider-specific metadata */ + metadata?: Record; +} + +/** + * Streaming chunk from an AI completion + */ +export interface CompletionChunk { + /** Content delta for this chunk */ + content: string; + /** Whether this is the final chunk */ + isComplete: boolean; + /** Unique identifier for the request */ + id: string; + /** Token usage (only available on final chunk) */ + usage?: TokenUsage; +} + +/** + * Error types that can occur during AI operations + */ +export enum AIErrorType { + AUTHENTICATION = 'authentication', + RATE_LIMIT = 'rate_limit', + INVALID_REQUEST = 'invalid_request', + MODEL_NOT_FOUND = 'model_not_found', + NETWORK = 'network', + TIMEOUT = 'timeout', + UNKNOWN = 'unknown' +} + +/** + * Custom error class for AI provider errors + */ +export class AIProviderError extends Error { + constructor( + message: string, + public type: AIErrorType, + public statusCode?: number, + public originalError?: Error + ) { + super(message); + this.name = 'AIProviderError'; + } +} + +/** + * Provider information and capabilities + */ +export interface ProviderInfo { + /** Name of the provider */ + name: string; + /** Version of the provider implementation */ + version: string; + /** List of available models */ + models: string[]; + /** Maximum context length supported */ + maxContextLength: number; + /** Whether streaming is supported */ + supportsStreaming: boolean; + /** Additional capabilities */ + capabilities?: Record; +} \ No newline at end of file diff --git a/src/utils/factory.ts b/src/utils/factory.ts new file mode 100644 index 0000000..3a197a2 --- /dev/null +++ b/src/utils/factory.ts @@ -0,0 +1,104 @@ +/** + * Factory utilities for creating AI providers + * Provides convenient methods for instantiating and configuring providers + */ + +import type { AIProviderConfig } from '../types/index.js'; +import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js'; +import { BaseAIProvider } from '../providers/base.js'; + +/** + * Supported AI provider types + */ +export type ProviderType = 'claude'; + +/** + * Configuration map for different provider types + */ +export interface ProviderConfigMap { + claude: ClaudeConfig; +} + +/** + * Factory function to create AI providers + * @param type - The type of provider to create + * @param config - Configuration for the provider + * @returns Configured AI provider instance + */ +export function createProvider( + type: T, + config: ProviderConfigMap[T] +): BaseAIProvider { + switch (type) { + case 'claude': + return new ClaudeProvider(config as ClaudeConfig); + default: + throw new Error(`Unsupported provider type: ${type}`); + } +} + +/** + * Create a Claude provider with simplified configuration + * @param apiKey - Anthropic API key + * @param options - Optional additional configuration + * @returns Configured Claude provider instance + */ +export function createClaudeProvider( + apiKey: string, + options: Partial> = {} +): ClaudeProvider { + return new ClaudeProvider({ + apiKey, + ...options + }); +} + +/** + * Provider registry for dynamic provider creation + */ +export class ProviderRegistry { + private static providers = new Map BaseAIProvider>(); + + /** + * Register a new provider type + * @param name - Name of the provider + * @param providerClass - Provider class constructor + */ + static register(name: string, providerClass: new (config: AIProviderConfig) => BaseAIProvider): void { + this.providers.set(name.toLowerCase(), providerClass); + } + + /** + * Create a provider by name + * @param name - Name of the provider + * @param config - Configuration for the provider + * @returns Provider instance + */ + static create(name: string, config: AIProviderConfig): BaseAIProvider { + const ProviderClass = this.providers.get(name.toLowerCase()); + if (!ProviderClass) { + throw new Error(`Provider '${name}' is not registered`); + } + return new ProviderClass(config); + } + + /** + * Get list of registered provider names + * @returns Array of registered provider names + */ + static getRegisteredProviders(): string[] { + return Array.from(this.providers.keys()); + } + + /** + * Check if a provider is registered + * @param name - Name of the provider + * @returns True if provider is registered + */ + static isRegistered(name: string): boolean { + return this.providers.has(name.toLowerCase()); + } +} + +// Pre-register built-in providers +ProviderRegistry.register('claude', ClaudeProvider); \ No newline at end of file diff --git a/tests/claude.test.ts b/tests/claude.test.ts new file mode 100644 index 0000000..d4396d6 --- /dev/null +++ b/tests/claude.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for Claude Provider + */ + +import { describe, it, expect, beforeEach } from 'bun:test'; +import { ClaudeProvider, 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 between 0.0 and 1.0'); + }); + + 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('Each message must have a valid role'); + }); + + 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).handleAnthropicError(error); + + expect(providerError).toBeInstanceOf(AIProviderError); + expect(providerError.type).toBe(AIErrorType.AUTHENTICATION); + }); + + it('should handle rate limit errors', () => { + const error = new Error('Rate limited'); + (error as any).status = 429; + + const providerError = (provider as any).handleAnthropicError(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).handleAnthropicError(error); + + expect(providerError).toBeInstanceOf(AIProviderError); + expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND); + }); + }); + + 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).convertMessages(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).convertMessages(messages); + + expect(result.system).toBeNull(); + expect(result.messages).toHaveLength(2); + }); + }); +}); \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..31319c3 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "outDir": "./dist", + "moduleResolution": "node", + "verbatimModuleSyntax": false + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "tests", + "**/*.test.ts", + "**/*.spec.ts" + ] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..389388a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Module resolution + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "tests" + ] +}