diff --git a/Makefile b/Makefile index c690d1c..7e70a5b 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ .DEFAULT_GOAL := default .PHONY: default install lint test test-unit test-integration nox nox-unit nox-integration test-deps upgrade build clean docs docs-deploy -.PHONY: test-openai test-anthropic test-google test-cerebras test-cohere test-grok test-groq +.PHONY: test-openai test-anthropic test-google test-cerebras test-cohere test-grok test-groq test-openrouter .PHONY: test-bare test-all-extras clean-cassettes help default: install lint test @@ -57,6 +57,9 @@ test-grok: test-groq: uv run nox -s test_groq +test-openrouter: + uv run nox -s test_openrouter + test-bare: uv run nox -s test_bare @@ -120,6 +123,7 @@ help: @echo " make test-cohere - Test chimeric[cohere] only" @echo " make test-grok - Test chimeric[grok] only" @echo " make test-groq - Test chimeric[groq] only" + @echo " make test-openrouter - Test chimeric[openrouter] only" @echo " make test-bare - Test bare installation (no extras)" @echo " make test-all-extras - Test all extras installation" @echo "" diff --git a/README.md b/README.md index 717be0a..1312fed 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ [![Groq](https://img.shields.io/badge/Groq-F55036?logo=groq&logoColor=white)](https://groq.com/) [![Cohere](https://img.shields.io/badge/Cohere-39594A?logo=cohere&logoColor=white)](https://cohere.ai/) [![Cerebras](https://img.shields.io/badge/Cerebras-FF6B35?logo=cerebras&logoColor=white)](https://cerebras.ai/) +[![OpenRouter](https://img.shields.io/badge/OpenRouter-8A2BE2?logo=openrouter&logoColor=white)](https://openrouter.ai/) ## 📖 Documentation @@ -54,7 +55,14 @@ export ANTHROPIC_API_KEY="your-key-here" ```python from chimeric import Chimeric -client = Chimeric() # Auto-detects API keys from environment +# Auto-detect from environment variables +client = Chimeric() + +# Or specify providers explicitly +client = Chimeric( + openai_api_key="sk-...", + anthropic_api_key="sk-ant-..." +) response = client.generate( model="gpt-4o", @@ -100,6 +108,21 @@ response = client.generate( print(response.content) ``` +### Provider Initialization +```python +# Only initialize specific providers +client = Chimeric(openai_api_key="sk-...") # Only OpenAI + +# Mix explicit keys with environment detection +client = Chimeric( + openai_api_key="sk-...", # Explicit OpenAI + detect_from_env=True # Plus any from environment +) + +# Auto-detect all from environment +client = Chimeric() # Detects all available providers +``` + ### Multi-Provider Switching ```python # Seamlessly switch between providers @@ -116,8 +139,8 @@ for model in models: ## 🔧 Key Features -- **Multi-Provider Support**: Switch between 7 major AI providers seamlessly -- **Automatic Detection**: Auto-detects available API keys from environment +- **Multi-Provider Support**: Switch between 8 major AI providers seamlessly +- **Flexible Initialization**: Auto-detect from environment or specify providers explicitly - **Unified Interface**: Consistent API across all providers - **Streaming Support**: Real-time response streaming - **Function Calling**: Tool integration with decorators diff --git a/docs/openrouter-provider.md b/docs/openrouter-provider.md new file mode 100644 index 0000000..76953ce --- /dev/null +++ b/docs/openrouter-provider.md @@ -0,0 +1,783 @@ +# OpenRouter Provider Guide + +OpenRouter is a unified API that provides access to 200+ AI models from multiple providers including OpenAI, Anthropic, Google, Meta, Microsoft, and more. The chimeric OpenRouter provider gives you seamless access to this vast ecosystem of models through a single, consistent interface. + +## 🌟 Key Features + +- **200+ Models**: Access models from OpenAI, Anthropic, Google, Meta, Microsoft, Perplexity, and more +- **Cost Optimization**: Often cheaper than direct provider APIs with transparent pricing +- **Unified Interface**: Same API for all models, no need to learn different SDKs +- **Model Fallbacks**: Automatic failover if a model is unavailable +- **Free Models**: Access to free models for testing and development +- **Higher Rate Limits**: Better rate limits than individual provider APIs +- **Streaming Support**: Real-time response streaming for all compatible models +- **Tool Calling**: Function calling support for models that support it +- **Async Support**: Full async/await support for high-performance applications + +## 📦 Installation + +```bash +# Install chimeric with OpenAI support (OpenRouter is OpenAI-compatible) +pip install "chimeric[openai]" + +# Or with uv (recommended) +uv add "chimeric[openai]" + +# For development with all extras +uv add "chimeric[all]" +``` + +## 🔐 Authentication + +### Get Your API Key + +1. Visit [OpenRouter.ai](https://openrouter.ai) +2. Sign up for a free account +3. Navigate to the API Keys section +4. Generate a new API key + +### Set Up Environment Variable + +```bash +# Set environment variable (recommended) +export OPENROUTER_API_KEY="your-api-key-here" + +# Or add to your .env file +echo "OPENROUTER_API_KEY=your-api-key-here" >> .env +``` + +## 🚀 Quick Start + +### Basic Usage + +```python +from chimeric import Chimeric + +# Initialize with environment variable +client = Chimeric() + +# Or pass API key directly +client = Chimeric(openrouter_api_key="your-api-key") + +# Simple text generation +response = client.generate( + model="openai/gpt-4o-mini", + messages="Hello! Explain quantum computing in simple terms." +) + +print(response.content) +``` + +### List Available Models + +```python +# Get all available models +models = client.list_models("openrouter") +print(f"Total models available: {len(models)}") + +# Show first few models +for model in models[:5]: + print(f"- {model.id}: {model.name}") +``` + +## 🎯 Model Selection + +OpenRouter provides access to models from many providers. Here are some popular choices: + +### OpenAI Models +```python +# GPT-4o models (latest and most capable) +response = client.generate( + model="openai/gpt-4o", + messages="Write a Python function to calculate fibonacci numbers" +) + +# GPT-4o-mini (faster, cheaper) +response = client.generate( + model="openai/gpt-4o-mini", + messages="Summarize the benefits of renewable energy" +) +``` + +### Anthropic Models +```python +# Claude 3.5 Sonnet (excellent for reasoning and coding) +response = client.generate( + model="anthropic/claude-3-5-sonnet-20241022", + messages="Debug this Python code and explain the issues" +) + +# Claude 3.5 Haiku (fast and efficient) +response = client.generate( + model="anthropic/claude-3-5-haiku-20241022", + messages="Translate this text to Spanish" +) +``` + +### Google Models +```python +# Gemini 2.0 Flash (Google's latest) +response = client.generate( + model="google/gemini-2.0-flash-exp", + messages="Analyze this data and provide insights" +) +``` + +### Free Models +```python +# Meta Llama 3.1 8B (free tier) +response = client.generate( + model="meta-llama/llama-3.1-8b-instruct:free", + messages="Write a short story about a robot" +) + +# Other free models +free_models = [ + "meta-llama/llama-3.2-3b-instruct:free", + "huggingfaceh4/zephyr-7b-beta:free", + "openchat/openchat-7b:free" +] +``` + +## 🌊 Streaming Responses + +Get real-time responses as they're generated: + +```python +# Sync streaming +print("Response: ", end="") +for chunk in client.generate( + model="openai/gpt-4o-mini", + messages="Tell me a story about space exploration", + stream=True +): + print(chunk.content, end="", flush=True) +print() # New line at end +``` + +### Async Streaming + +```python +import asyncio + +async def stream_response(): + client = Chimeric() + + print("Async Response: ", end="") + async for chunk in client.agenerate( + model="anthropic/claude-3-5-haiku-20241022", + messages="Explain machine learning concepts", + stream=True + ): + print(chunk.content, end="", flush=True) + print() + +asyncio.run(stream_response()) +``` + +## 🛠️ Tool Calling (Function Calling) + +Enable models to call functions and interact with external systems: + +### Basic Tool Usage + +```python +from chimeric import Chimeric + +client = Chimeric() + +# Define tools using decorators +@client.tool() +def get_weather(city: str, units: str = "fahrenheit") -> str: + """Get current weather information for a city. + + Args: + city: Name of the city + units: Temperature units (fahrenheit or celsius) + """ + # In a real implementation, call a weather API + return f"Weather in {city}: 72°{units[0].upper()}, partly cloudy" + +@client.tool() +def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict: + """Calculate tip and total bill amount. + + Args: + bill_amount: Original bill amount in dollars + tip_percentage: Tip percentage (default 15%) + """ + tip = bill_amount * (tip_percentage / 100) + total = bill_amount + tip + return { + "bill_amount": bill_amount, + "tip_percentage": tip_percentage, + "tip_amount": round(tip, 2), + "total_amount": round(total, 2) + } + +# Use tools in conversation +response = client.generate( + model="openai/gpt-4o-mini", # Supports tool calling + messages="What's the weather in New York and calculate a 20% tip on a $85 bill?" +) + +print(response.content) +``` + +### Async Tools + +```python +import asyncio +import aiohttp + +@client.tool() +async def fetch_news(topic: str, count: int = 3) -> str: + """Fetch latest news articles about a topic. + + Args: + topic: News topic to search for + count: Number of articles to return + """ + # Simulate async API call + await asyncio.sleep(0.1) + return f"Found {count} articles about {topic}: [Article 1], [Article 2], [Article 3]" + +async def main(): + response = await client.agenerate( + model="anthropic/claude-3-5-sonnet-20241022", + messages="Get me the latest news about artificial intelligence" + ) + print(response.content) + +asyncio.run(main()) +``` + +## ⚙️ Configuration Options + +### Custom Headers (Recommended) + +OpenRouter supports custom headers for better analytics and potentially better rates: + +```python +client = Chimeric( + openrouter_api_key="your-api-key", + default_headers={ + "HTTP-Referer": "https://your-website.com", # Your website URL + "X-Title": "Your Application Name" # Your app name + } +) +``` + +### Model Parameters + +Control model behavior with various parameters: + +```python +response = client.generate( + model="openai/gpt-4o-mini", + messages="Write a formal business email", + + # Control randomness + temperature=0.3, # Lower = more deterministic (0.0-2.0) + top_p=0.9, # Nucleus sampling (0.0-1.0) + + # Control length + max_tokens=500, # Maximum response tokens + + # Control formatting + response_format={"type": "json_object"}, # For JSON responses + + # Other parameters + frequency_penalty=0.1, # Reduce repetition + presence_penalty=0.1, # Encourage new topics +) +``` + +### Timeout and Retries + +```python +client = Chimeric( + openrouter_api_key="your-api-key", + timeout=60, # Request timeout in seconds + max_retries=3, # Number of retries on failure +) +``` + +## 🔄 Multiple Models and Fallbacks + +Compare responses from different models or implement fallbacks: + +```python +async def compare_models(prompt: str): + client = Chimeric() + + models = [ + "openai/gpt-4o-mini", + "anthropic/claude-3-5-haiku-20241022", + "google/gemini-2.0-flash-exp" + ] + + tasks = [] + for model in models: + task = client.agenerate( + model=model, + messages=prompt, + max_tokens=100 + ) + tasks.append(task) + + # Get responses concurrently + responses = await asyncio.gather(*tasks, return_exceptions=True) + + for model, response in zip(models, responses): + if isinstance(response, Exception): + print(f"{model}: Error - {response}") + else: + print(f"{model}: {response.content[:100]}...") + +# Run comparison +asyncio.run(compare_models("Explain the theory of relativity")) +``` + +## 🏷️ Advanced Use Cases + +### Content Generation Pipeline + +```python +class ContentGenerator: + def __init__(self): + self.client = Chimeric() + + async def generate_blog_post(self, topic: str) -> dict: + """Generate a complete blog post with title, outline, and content.""" + + # Generate title + title_response = await self.client.agenerate( + model="openai/gpt-4o-mini", + messages=f"Generate a compelling blog post title about: {topic}", + max_tokens=50 + ) + + # Generate outline + outline_response = await self.client.agenerate( + model="anthropic/claude-3-5-haiku-20241022", + messages=f"Create a detailed outline for a blog post titled: {title_response.content}", + max_tokens=200 + ) + + # Generate full content + content_response = await self.client.agenerate( + model="anthropic/claude-3-5-sonnet-20241022", + messages=f"""Write a comprehensive blog post: +Title: {title_response.content} +Outline: {outline_response.content} +Make it engaging and informative.""", + max_tokens=1500 + ) + + return { + "title": title_response.content.strip(), + "outline": outline_response.content, + "content": content_response.content, + "word_count": len(content_response.content.split()) + } + +# Usage +generator = ContentGenerator() +blog_post = asyncio.run(generator.generate_blog_post("sustainable technology")) +print(f"Generated: {blog_post['title']}") +print(f"Word count: {blog_post['word_count']}") +``` + +### Code Analysis and Generation + +```python +@client.tool() +def run_python_code(code: str) -> str: + """Execute Python code safely and return the output. + + Args: + code: Python code to execute + """ + # In production, use a sandboxed environment + try: + # Simple example - use proper sandboxing in production + exec_globals = {"__builtins__": {}} + exec(code, exec_globals) + return "Code executed successfully" + except Exception as e: + return f"Error: {str(e)}" + +# Code generation and testing +response = client.generate( + model="anthropic/claude-3-5-sonnet-20241022", + messages=""" + Write a Python function to find the longest common subsequence between two strings. + Then test it with examples. + """, + tools=[run_python_code] +) + +print(response.content) +``` + +## 🚨 Error Handling + +Robust error handling for production applications: + +```python +from chimeric.exceptions import ( + ModelNotSupportedError, + ProviderError, + ChimericError +) +import asyncio +from typing import Optional + +class RobustOpenRouterClient: + def __init__(self): + self.client = Chimeric() + self.fallback_models = [ + "openai/gpt-4o-mini", + "anthropic/claude-3-5-haiku-20241022", + "meta-llama/llama-3.1-8b-instruct:free" + ] + + async def generate_with_fallback( + self, + messages: str, + preferred_model: str = "openai/gpt-4o" + ) -> Optional[str]: + """Generate response with automatic fallback to alternative models.""" + + models_to_try = [preferred_model] + self.fallback_models + + for model in models_to_try: + try: + response = await self.client.agenerate( + model=model, + messages=messages, + timeout=30 + ) + print(f"✅ Success with {model}") + return response.content + + except ModelNotSupportedError: + print(f"❌ Model {model} not supported, trying next...") + continue + + except ProviderError as e: + print(f"⚠️ Provider error with {model}: {e}, trying next...") + continue + + except asyncio.TimeoutError: + print(f"⏰ Timeout with {model}, trying next...") + continue + + except Exception as e: + print(f"🚫 Unexpected error with {model}: {e}") + continue + + print("❌ All models failed") + return None + +# Usage +client = RobustOpenRouterClient() +response = asyncio.run( + client.generate_with_fallback( + "Explain quantum computing", + preferred_model="anthropic/claude-3-5-sonnet-20241022" + ) +) + +if response: + print(f"Response: {response[:100]}...") +else: + print("Failed to generate response") +``` + +## 💰 Cost Optimization + +### Monitor Usage + +```python +def track_usage(client): + """Track token usage across requests.""" + total_prompt_tokens = 0 + total_completion_tokens = 0 + + # Make requests and track usage + response = client.generate( + model="openai/gpt-4o-mini", + messages="Write a short poem about programming" + ) + + if hasattr(response, 'usage') and response.usage: + total_prompt_tokens += response.usage.prompt_tokens + total_completion_tokens += response.usage.completion_tokens + + print(f"Request used:") + print(f"- Prompt tokens: {response.usage.prompt_tokens}") + print(f"- Completion tokens: {response.usage.completion_tokens}") + print(f"- Total tokens: {response.usage.total_tokens}") + + return response.content + +content = track_usage(client) +print(f"Generated: {content}") +``` + +### Choose Cost-Effective Models + +```python +# Model cost tiers (approximate) +COST_TIERS = { + "free": [ + "meta-llama/llama-3.1-8b-instruct:free", + "meta-llama/llama-3.2-3b-instruct:free", + "huggingfaceh4/zephyr-7b-beta:free" + ], + "cheap": [ + "openai/gpt-4o-mini", + "anthropic/claude-3-5-haiku-20241022", + "google/gemini-1.5-flash" + ], + "premium": [ + "openai/gpt-4o", + "anthropic/claude-3-5-sonnet-20241022", + "google/gemini-2.0-flash-exp" + ] +} + +def choose_model_by_budget(task_complexity: str) -> str: + """Choose appropriate model based on task complexity and budget.""" + if task_complexity == "simple": + return COST_TIERS["free"][0] + elif task_complexity == "medium": + return COST_TIERS["cheap"][0] + else: + return COST_TIERS["premium"][0] + +# Usage +model = choose_model_by_budget("simple") +response = client.generate( + model=model, + messages="What is 2+2?" +) +``` + +## 🏭 Production Best Practices + +### Rate Limiting + +```python +import asyncio +from asyncio import Semaphore + +class RateLimitedClient: + def __init__(self, max_concurrent_requests: int = 5): + self.client = Chimeric() + self.semaphore = Semaphore(max_concurrent_requests) + + async def generate_with_rate_limit(self, **kwargs): + async with self.semaphore: + return await self.client.agenerate(**kwargs) + +# Usage +rate_limited_client = RateLimitedClient(max_concurrent_requests=3) + +async def batch_generate(prompts: list[str]): + tasks = [ + rate_limited_client.generate_with_rate_limit( + model="openai/gpt-4o-mini", + messages=prompt + ) + for prompt in prompts + ] + + return await asyncio.gather(*tasks, return_exceptions=True) + +prompts = [ + "Explain photosynthesis", + "Write a haiku about technology", + "Describe machine learning", +] + +responses = asyncio.run(batch_generate(prompts)) +for i, response in enumerate(responses): + if isinstance(response, Exception): + print(f"Prompt {i+1}: Error - {response}") + else: + print(f"Prompt {i+1}: {response.content[:50]}...") +``` + +### Configuration Management + +```python +import os +from dataclasses import dataclass +from typing import Optional + +@dataclass +class OpenRouterConfig: + api_key: str + default_model: str = "openai/gpt-4o-mini" + max_tokens: int = 1000 + temperature: float = 0.7 + timeout: int = 30 + max_retries: int = 3 + + @classmethod + def from_env(cls) -> 'OpenRouterConfig': + return cls( + api_key=os.getenv("OPENROUTER_API_KEY", ""), + default_model=os.getenv("OPENROUTER_DEFAULT_MODEL", "openai/gpt-4o-mini"), + max_tokens=int(os.getenv("OPENROUTER_MAX_TOKENS", "1000")), + temperature=float(os.getenv("OPENROUTER_TEMPERATURE", "0.7")), + timeout=int(os.getenv("OPENROUTER_TIMEOUT", "30")), + max_retries=int(os.getenv("OPENROUTER_MAX_RETRIES", "3")) + ) + +def create_configured_client() -> Chimeric: + config = OpenRouterConfig.from_env() + return Chimeric( + openrouter_api_key=config.api_key, + timeout=config.timeout, + max_retries=config.max_retries + ) + +# Usage +client = create_configured_client() +``` + +### Logging and Monitoring + +```python +import logging +import time +from functools import wraps + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def log_openrouter_requests(func): + @wraps(func) + async def wrapper(*args, **kwargs): + start_time = time.time() + model = kwargs.get('model', 'unknown') + + try: + logger.info(f"Starting request to {model}") + result = await func(*args, **kwargs) + + duration = time.time() - start_time + logger.info(f"Request to {model} completed in {duration:.2f}s") + + if hasattr(result, 'usage') and result.usage: + logger.info(f"Token usage - Prompt: {result.usage.prompt_tokens}, " + f"Completion: {result.usage.completion_tokens}") + + return result + + except Exception as e: + duration = time.time() - start_time + logger.error(f"Request to {model} failed after {duration:.2f}s: {e}") + raise + + return wrapper + +# Apply to client methods +client = Chimeric() +client.agenerate = log_openrouter_requests(client.agenerate) +``` + +## 🔍 Troubleshooting + +### Common Issues and Solutions + +#### 1. API Key Issues +```python +# Test API key validity +try: + client = Chimeric(openrouter_api_key="test-key") + models = client.list_models("openrouter") + print("✅ API key is valid") +except Exception as e: + print(f"❌ API key error: {e}") +``` + +#### 2. Model Availability +```python +# Check if a specific model is available +def check_model_availability(model_name: str) -> bool: + try: + models = client.list_models("openrouter") + available_models = [m.id for m in models] + return model_name in available_models + except: + return False + +# Usage +if check_model_availability("openai/gpt-4o"): + print("✅ Model is available") +else: + print("❌ Model not available") +``` + +#### 3. Timeout Issues +```python +# Increase timeout for long-running requests +client = Chimeric( + openrouter_api_key="your-key", + timeout=120 # 2 minutes +) + +# Or handle timeouts gracefully +import asyncio + +async def generate_with_timeout(prompt: str, timeout: int = 60): + try: + response = await asyncio.wait_for( + client.agenerate( + model="anthropic/claude-3-5-sonnet-20241022", + messages=prompt + ), + timeout=timeout + ) + return response.content + except asyncio.TimeoutError: + return "Request timed out. Try a shorter prompt or increase timeout." + +result = asyncio.run(generate_with_timeout("Write a very long essay about AI")) +``` + +## 📚 Additional Resources + +### Official Links +- [OpenRouter Website](https://openrouter.ai) +- [OpenRouter Documentation](https://openrouter.ai/docs) +- [OpenRouter Models](https://openrouter.ai/models) +- [OpenRouter API Reference](https://openrouter.ai/docs/api) + +### Model Comparisons +Visit [OpenRouter Models](https://openrouter.ai/models) to compare: +- Model capabilities and pricing +- Context length limits +- Special features (vision, function calling, etc.) +- Performance benchmarks + +### Community and Support +- Join the [OpenRouter Discord](https://discord.gg/openrouter) +- Check the [OpenRouter GitHub](https://github.com/OpenRouterTeam) +- Read the [chimeric documentation](https://github.com/Johnson-f/chimeric) + +## 🎯 Next Steps + +1. **Start Simple**: Begin with basic text generation using free models +2. **Experiment**: Try different models for your specific use case +3. **Add Tools**: Implement function calling for interactive applications +4. **Scale Up**: Use async for high-performance applications +5. **Monitor**: Track usage and optimize for cost and performance +6. **Production**: Implement proper error handling, logging, and rate limiting + +The OpenRouter provider in chimeric gives you access to the entire ecosystem of AI models through a single, consistent interface. Start experimenting and building amazing AI-powered applications! diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index f9e6952..d6d9a19 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -35,15 +35,29 @@ export CEREBRAS_API_KEY="csk-..." export GROK_API_KEY="xai-..." # or export GROK_API_TOKEN="xai-..." + +# OpenRouter +export OPENROUTER_API_KEY="sk-or-..." ``` ### Direct Initialization -You can also provide API keys directly when creating the client: +You can also provide API keys directly when creating the client. **Important**: When you provide explicit API keys, only those specific providers are initialized (environment auto-detection is disabled by default): ```python from chimeric import Chimeric +# Only initialize specific providers +client = Chimeric(openai_api_key="sk-...") # Only OpenAI + +# Initialize multiple specific providers +client = Chimeric( + openai_api_key="sk-...", + anthropic_api_key="sk-ant-...", + google_api_key="AIza..." +) + +# All providers client = Chimeric( openai_api_key="sk-...", anthropic_api_key="sk-ant-...", @@ -51,21 +65,46 @@ client = Chimeric( cohere_api_key="your-key", groq_api_key="gsk_...", cerebras_api_key="csk-...", - grok_api_key="xai-..." + grok_api_key="xai-...", + openrouter_api_key="sk-or-..." ) ``` ### Mixed Configuration -Combine environment variables with direct initialization: +By default, when you provide explicit API keys, only those providers are initialized. To also auto-detect additional providers from environment variables, use the `detect_from_env` parameter: + +```python +# Initialize OpenAI explicitly, plus any others from environment +client = Chimeric( + openai_api_key="sk-...", # Explicit OpenAI + detect_from_env=True # Auto-detect others from env vars +) + +# Alternative: Auto-detect everything from environment +client = Chimeric() # Detects all available providers from env vars +``` + +### Provider Selection Examples ```python -# Some keys from environment, others provided directly +# Scenario 1: Only specific providers +client = Chimeric( + openai_api_key="sk-...", + anthropic_api_key="sk-ant-..." +) +# Result: Only OpenAI and Anthropic are initialized + +# Scenario 2: Mix explicit + environment detection client = Chimeric( - openai_api_key="sk-...", # Explicit key - # anthropic_api_key will be read from ANTHROPIC_API_KEY env var - # google_api_key will be read from GOOGLE_API_KEY env var + openai_api_key="sk-...", # Always use this OpenAI key + detect_from_env=True # Also check env for other providers ) +# Result: OpenAI (explicit) + any others found in environment + +# Scenario 3: Environment-only detection +client = Chimeric() +# Result: All providers found in environment variables ``` ### Provider-Specific Configuration diff --git a/noxfile.py b/noxfile.py index d645bd7..6676eb3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -5,7 +5,7 @@ package = "chimeric" python_versions = ["3.11", "3.12", "3.13"] latest_python_version = python_versions[-1] -providers = ["openai", "anthropic", "google", "cerebras", "cohere", "grok", "groq"] +providers = ["openai", "anthropic", "google", "cerebras", "cohere", "grok", "groq", "openrouter"] nox.needs_version = ">= 2024.10.9" nox.options.sessions = ("unit", "integration") @@ -89,6 +89,13 @@ def test_groq(session: Session) -> None: session.run("uv", "run", "pytest", "tests/integration", "-m", "groq", "--no-cov", external=True) +@nox.session(python=latest_python_version) +def test_openrouter(session: Session) -> None: + """Test chimeric[openrouter] installation and functionality.""" + session.run("uv", "sync", "--extra", "openrouter", "--dev", external=True) + session.run("uv", "run", "pytest", "tests/integration", "-m", "openrouter", "--no-cov", external=True) + + @nox.session(python=latest_python_version) def test_bare(session: Session) -> None: """Test bare chimeric installation (no optional dependencies).""" diff --git a/pyproject.toml b/pyproject.toml index 06bb60f..b0a5ee4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,9 @@ cerebras = [ grok = [ "xai-sdk>=1.0.0", ] +openrouter = [ + "openai>=1.84.0", +] all = [ "openai>=1.84.0", "anthropic>=0.52.2", @@ -93,6 +96,7 @@ all = [ "groq>=0.4.0", "cerebras-cloud-sdk>=1.35.0", "xai-sdk>=1.0.0", + # openrouter uses openai, so it's already included above ] @@ -235,8 +239,9 @@ markers = [ "cohere: tests requiring Cohere provider", "grok: tests requiring Grok provider", "groq: tests requiring Groq provider", + "openrouter: tests requiring OpenRouter provider", "bare_install: tests with no optional dependencies", - "all_extras: tests with all optional dependencies", + "all_extras: tests with all extras dependencies", "vcr: tests using VCR cassettes", ] filterwarnings = [ diff --git a/src/chimeric/chimeric.py b/src/chimeric/chimeric.py index 19fe210..08f1f78 100644 --- a/src/chimeric/chimeric.py +++ b/src/chimeric/chimeric.py @@ -47,6 +47,11 @@ def _build_provider_mappings() -> tuple[dict[Provider, type], dict[Provider, typ Provider.COHERE: ("chimeric.providers.cohere.client", "CohereClient", "CohereAsyncClient"), Provider.GROK: ("chimeric.providers.grok.client", "GrokClient", "GrokAsyncClient"), Provider.GROQ: ("chimeric.providers.groq.client", "GroqClient", "GroqAsyncClient"), + Provider.OPENROUTER: ( + "chimeric.providers.openrouter.client", + "OpenRouterClient", + "OpenRouterAsyncClient", + ), } # Try to import each provider and add to mappings if successful @@ -100,11 +105,15 @@ def __init__( cohere_api_key: str | None = None, grok_api_key: str | None = None, groq_api_key: str | None = None, + openrouter_api_key: str | None = None, + detect_from_env: bool = False, **kwargs: Any, ) -> None: """Initialize Chimeric client with provider configuration. API keys can be provided explicitly or via environment variables. + By default, if explicit keys are provided, only those providers are initialized. + If no explicit keys are provided, auto-detection from environment variables occurs. Environment variables: - OPENAI_API_KEY @@ -114,17 +123,34 @@ def __init__( - COHERE_API_KEY or CO_API_KEY - GROK_API_KEY or XAI_API_KEY - GROQ_API_KEY + - OPENROUTER_API_KEY Args: - openai_api_key: OpenAI API key (defaults to OPENAI_API_KEY env var) - anthropic_api_key: Anthropic API key (defaults to ANTHROPIC_API_KEY env var) - google_api_key: Google AI API key (defaults to GOOGLE_API_KEY or GEMINI_API_KEY env var) - cerebras_api_key: Cerebras API key (defaults to CEREBRAS_API_KEY env var) - cohere_api_key: Cohere API key (defaults to COHERE_API_KEY or CO_API_KEY env var) - grok_api_key: xAI Grok API key (defaults to GROK_API_KEY or XAI_API_KEY env var) - groq_api_key: Groq API key (defaults to GROQ_API_KEY env var) + openai_api_key: OpenAI API key. If provided, only initialize OpenAI provider. + anthropic_api_key: Anthropic API key. If provided, only initialize Anthropic provider. + google_api_key: Google AI API key. If provided, only initialize Google provider. + cerebras_api_key: Cerebras API key. If provided, only initialize Cerebras provider. + cohere_api_key: Cohere API key. If provided, only initialize Cohere provider. + grok_api_key: xAI Grok API key. If provided, only initialize Grok provider. + groq_api_key: Groq API key. If provided, only initialize Groq provider. + openrouter_api_key: OpenRouter API key. If provided, only initialize OpenRouter provider. + detect_from_env: If True, auto-detect additional providers from environment variables + even when explicit keys are provided. Defaults to False. **kwargs: Provider-specific options (timeout, base_url, max_retries, etc.) + Examples: + Initialize only OpenAI: + >>> client = Chimeric(openai_api_key="sk-...") + + Initialize OpenAI and Anthropic only: + >>> client = Chimeric(openai_api_key="sk-...", anthropic_api_key="sk-ant-...") + + Initialize from environment variables: + >>> client = Chimeric() + + Mix explicit keys with environment detection: + >>> client = Chimeric(openai_api_key="sk-...", detect_from_env=True) + Raises: ChimericError: If no providers can be initialized """ @@ -138,6 +164,19 @@ def __init__( # Mapping of canonical model names to their providers self._model_provider_mapping: dict[str, Provider] = {} + # Collect all explicitly provided API keys + explicit_keys = [ + openai_api_key, + anthropic_api_key, + google_api_key, + cerebras_api_key, + cohere_api_key, + grok_api_key, + groq_api_key, + openrouter_api_key, + ] + has_explicit_keys = any(key is not None for key in explicit_keys) + # Initialize providers from explicit API keys. self._initialize_providers_from_config( openai_api_key, @@ -147,11 +186,15 @@ def __init__( cohere_api_key, grok_api_key, groq_api_key, + openrouter_api_key, **kwargs, ) - # Auto-detect providers from environment variables. - self._detect_providers_from_environment(kwargs) + # Auto-detect providers from environment variables only if: + # 1. No explicit keys were provided, OR + # 2. detect_from_env is explicitly set to True + if not has_explicit_keys or detect_from_env: + self._detect_providers_from_environment(kwargs) def _initialize_providers_from_config( self, @@ -162,6 +205,7 @@ def _initialize_providers_from_config( cohere_api_key: str | None = None, grok_api_key: str | None = None, groq_api_key: str | None = None, + openrouter_api_key: str | None = None, **kwargs: Any, ) -> None: """Initializes providers from explicitly provided API keys. @@ -174,6 +218,7 @@ def _initialize_providers_from_config( cohere_api_key: Cohere API key. grok_api_key: Grok API key. groq_api_key: Groq API key. + openrouter_api_key: OpenRouter API key. **kwargs: Additional provider-specific configuration parameters. """ provider_configs: list[tuple[Provider, str | None]] = [ @@ -184,6 +229,7 @@ def _initialize_providers_from_config( (Provider.COHERE, cohere_api_key), (Provider.GROK, grok_api_key), (Provider.GROQ, groq_api_key), + (Provider.OPENROUTER, openrouter_api_key), ] # Initialize providers that have API keys provided. @@ -211,6 +257,7 @@ def _detect_providers_from_environment(self, kwargs: dict[str, Any]) -> None: Provider.COHERE: ["COHERE_API_KEY", "CO_API_KEY"], Provider.GROK: ["GROK_API_KEY", "XAI_API_KEY"], Provider.GROQ: ["GROQ_API_KEY"], + Provider.OPENROUTER: ["OPENROUTER_API_KEY"], } # Check environment variables for each provider. diff --git a/src/chimeric/providers/cohere/client.py b/src/chimeric/providers/cohere/client.py index 5d08fe1..8f844b0 100644 --- a/src/chimeric/providers/cohere/client.py +++ b/src/chimeric/providers/cohere/client.py @@ -4,18 +4,18 @@ from cohere import AsyncClientV2 as AsyncCohere from cohere import ( ChatResponse, - MessageStartV2ChatStreamResponse, - ContentStartV2ChatStreamResponse, + CitationEndV2ChatStreamResponse, + CitationStartV2ChatStreamResponse, ContentDeltaV2ChatStreamResponse, ContentEndV2ChatStreamResponse, - ToolPlanDeltaV2ChatStreamResponse, - ToolCallStartV2ChatStreamResponse, + ContentStartV2ChatStreamResponse, + DebugV2ChatStreamResponse, + MessageEndV2ChatStreamResponse, + MessageStartV2ChatStreamResponse, ToolCallDeltaV2ChatStreamResponse, ToolCallEndV2ChatStreamResponse, - CitationStartV2ChatStreamResponse, - CitationEndV2ChatStreamResponse, - MessageEndV2ChatStreamResponse, - DebugV2ChatStreamResponse, + ToolCallStartV2ChatStreamResponse, + ToolPlanDeltaV2ChatStreamResponse, ) from cohere import ClientV2 as Cohere diff --git a/src/chimeric/providers/openrouter/__init__.py b/src/chimeric/providers/openrouter/__init__.py new file mode 100644 index 0000000..b5d8ddd --- /dev/null +++ b/src/chimeric/providers/openrouter/__init__.py @@ -0,0 +1,3 @@ +from .client import OpenRouterAsyncClient, OpenRouterClient + +__all__ = ["OpenRouterAsyncClient", "OpenRouterClient"] diff --git a/src/chimeric/providers/openrouter/client.py b/src/chimeric/providers/openrouter/client.py new file mode 100644 index 0000000..7d5fd5c --- /dev/null +++ b/src/chimeric/providers/openrouter/client.py @@ -0,0 +1,688 @@ +from typing import Any + +from openai import NOT_GIVEN, AsyncOpenAI, OpenAI +from openai.types.chat import ChatCompletion, ChatCompletionChunk + +from chimeric.base import ChimericAsyncClient, ChimericClient +from chimeric.types import ( + Capability, + ChimericStreamChunk, + Message, + ModelSummary, + Tool, + ToolCall, + ToolExecutionResult, + Usage, +) +from chimeric.utils import StreamProcessor, create_stream_chunk + + +class OpenRouterClient(ChimericClient[OpenAI, ChatCompletion, ChatCompletionChunk]): + """Synchronous OpenRouter Client for interacting with multiple LLM providers via OpenRouter API. + + This client provides a unified interface for synchronous interactions with + various LLM providers through OpenRouter's API gateway. OpenRouter provides + access to models from OpenAI, Anthropic, Google, Meta, Cohere, and many other + providers through a single API interface that's compatible with OpenAI's format. + + The client supports: + - Access to 200+ models from different providers + - Advanced text generation with GPT-4, Claude, Gemini, Llama, and more + - Function/tool calling with automatic execution + - Streaming responses with real-time processing + - Model routing and fallback capabilities + - Cost optimization through provider selection + - Model listing and metadata retrieval + + Note: + OpenRouter acts as a gateway, providing access to the best models from + different providers while handling authentication, routing, and billing. + This allows you to use the best model for each specific task. + + Example: + ```python + from chimeric.providers.openrouter import OpenRouterClient + from chimeric.tools import ToolManager + + tool_manager = ToolManager() + client = OpenRouterClient(api_key="your-openrouter-key", tool_manager=tool_manager) + + response = client.chat_completion( + messages="What's the capital of France?", + model="openai/gpt-4o" # or "anthropic/claude-3-5-sonnet-20241022" + ) + print(response.common.content) + ``` + + Attributes: + api_key (str): The OpenRouter API key for authentication. + tool_manager (ToolManager): Manager for handling tool registration and execution. + """ + + def __init__(self, api_key: str, tool_manager, **kwargs: Any) -> None: + """Initialize the synchronous OpenRouter client. + + Args: + api_key: The OpenRouter API key for authentication. + tool_manager: The tool manager instance for handling function calls. + **kwargs: Additional keyword arguments to pass to the OpenAI client + constructor. OpenRouter supports most OpenAI client parameters. + Common parameters include: + - base_url: Automatically set to OpenRouter's endpoint + - timeout: Request timeout in seconds + - max_retries: Maximum number of retries + - default_headers: Additional headers (e.g., HTTP-Referer, X-Title) + + Raises: + ValueError: If api_key is None or empty. + ProviderError: If client initialization fails. + """ + self._provider_name = "OpenRouter" + # Set OpenRouter's base URL if not provided + if "base_url" not in kwargs: + kwargs["base_url"] = "https://openrouter.ai/api/v1" + super().__init__(api_key=api_key, tool_manager=tool_manager, **kwargs) + + # ==================================================================== + # Required abstract method implementations + # ==================================================================== + + def _get_client_type(self) -> type: + """Get the synchronous OpenAI client class type. + + Returns: + The OpenAI client class from the openai library. + """ + return OpenAI + + def _init_client(self, client_type: type, **kwargs: Any) -> OpenAI: + """Initializes the synchronous OpenAI client pointed to OpenRouter.""" + return OpenAI(api_key=self.api_key, **kwargs) + + def _get_capabilities(self) -> Capability: + """Get supported features for the OpenRouter provider. + + Returns: + Capability object indicating which features are supported: + - streaming: True (supports real-time streaming responses) + - tools: True (supports function calling and tool use) + + Note: + OpenRouter supports the full OpenAI API interface including + streaming and tool calling across most models. + """ + return Capability(streaming=True, tools=True) + + def _list_models_impl(self) -> list[ModelSummary]: + """Lists available models from the OpenRouter API. + + Returns: + A list of ModelSummary objects containing model information + from OpenRouter's model registry. + """ + return [ + ModelSummary( + id=model.id, + name=getattr(model, "name", model.id), + owned_by=getattr(model, "owned_by", "openrouter"), + created_at=getattr(model, "created", None), + metadata=model.__dict__ if hasattr(model, "__dict__") else {}, + ) + for model in self.client.models.list() + ] + + def _messages_to_provider_format(self, messages: list[Message]) -> Any: + """Converts standardized messages to OpenRouter/OpenAI chat format. + + OpenRouter uses the same message format as OpenAI's chat completions API. + """ + formatted_messages = [] + for msg in messages: + if msg.role == "tool": + # Tool result message - format for OpenAI chat completions + formatted_messages.append( + { + "role": "tool", + "content": str(msg.content), + "tool_call_id": msg.tool_call_id, + } + ) + elif msg.role == "assistant" and msg.tool_calls: + # Assistant message with tool calls + tool_calls_formatted = [ + { + "id": tool_call.call_id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": tool_call.arguments, + }, + } + for tool_call in msg.tool_calls + ] + formatted_messages.append( + { + "role": "assistant", + "content": msg.content, + "tool_calls": tool_calls_formatted, + } + ) + else: + # Regular message - convert to standard format + formatted_messages.append(msg.model_dump(exclude_none=True)) + + return formatted_messages + + def _tools_to_provider_format(self, tools: list[Tool]) -> Any: + """Converts standardized tools to OpenRouter/OpenAI format.""" + return [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters.model_dump() if tool.parameters else {}, + }, + } + for tool in tools + ] + + def _make_provider_request( + self, + messages: Any, + model: str, + stream: bool, + tools: Any = None, + **kwargs: Any, + ) -> Any: + """Makes the actual API request to OpenRouter.""" + tools_param = NOT_GIVEN if tools is None else tools + + return self.client.chat.completions.create( + model=model, messages=messages, stream=stream, tools=tools_param, **kwargs + ) + + def _process_provider_stream_event( + self, event: ChatCompletionChunk, processor: StreamProcessor + ) -> ChimericStreamChunk[ChatCompletionChunk] | None: + """Processes an OpenRouter stream event using the standardized processor.""" + if not event.choices: + return None + + choice = event.choices[0] + delta = choice.delta + + # Handle content deltas + if delta.content: + return create_stream_chunk( + native_event=event, processor=processor, content_delta=delta.content + ) + + # Handle tool call deltas + if delta.tool_calls: + for tool_call_delta in delta.tool_calls: + if tool_call_delta.id: + # New tool call starting + processor.process_tool_call_start( + tool_call_delta.id, + tool_call_delta.function.name if tool_call_delta.function else "", + tool_call_delta.id, + ) + elif tool_call_delta.function and tool_call_delta.function.arguments: + # Tool call arguments delta - need the tool call ID from index + # For OpenAI streaming, we track by index + tool_call_id = f"call_{tool_call_delta.index}" + processor.process_tool_call_delta( + tool_call_id, tool_call_delta.function.arguments + ) + + # Handle completion + if choice.finish_reason: + return create_stream_chunk( + native_event=event, + processor=processor, + finish_reason=event.choices[0].finish_reason, + ) + + return None + + def _extract_usage_from_response(self, response: ChatCompletion) -> Usage: + """Extracts usage information from OpenRouter response.""" + if not response.usage: + return Usage() + + return Usage( + prompt_tokens=response.usage.prompt_tokens, + completion_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + ) + + def _extract_content_from_response(self, response: ChatCompletion) -> str | list[Any]: + """Extracts content from OpenRouter response.""" + if not response.choices: + return "" + + choice = response.choices[0] + return choice.message.content or "" + + def _extract_tool_calls_from_response(self, response: ChatCompletion) -> list[ToolCall] | None: + """Extracts tool calls from OpenRouter response.""" + if not response.choices: + return None + + choice = response.choices[0] + if not choice.message.tool_calls: + return None + + return [ + ToolCall( + call_id=tool_call.id, + name=tool_call.function.name, + arguments=tool_call.function.arguments, + ) + for tool_call in choice.message.tool_calls + ] + + def _update_messages_with_tool_calls( + self, + messages: list[Any], + assistant_response: ChatCompletion | ChatCompletionChunk | Any, + tool_calls: list[ToolCall], + tool_results: list[ToolExecutionResult], + ) -> list[Any]: + """Updates message history with assistant response and tool results. + + For OpenRouter/OpenAI, this follows the chat completions format where: + 1. Assistant message with tool calls is added + 2. Tool result messages are added for each tool execution + """ + updated_messages = list(messages) + + # Add the assistant message with tool calls + if isinstance(assistant_response, ChatCompletion) and assistant_response.choices: + choice = assistant_response.choices[0] + if choice.message.tool_calls: + updated_messages.append( + { + "role": "assistant", + "content": choice.message.content, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in choice.message.tool_calls + ], + } + ) + else: + # For streaming responses, reconstruct the assistant message + tool_calls_formatted = [ + { + "id": tool_call.call_id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": tool_call.arguments, + }, + } + for tool_call in tool_calls + ] + updated_messages.append( + { + "role": "assistant", + "content": None, + "tool_calls": tool_calls_formatted, + } + ) + + # Add the tool result messages + for result in tool_results: + updated_messages.append( + { + "role": "tool", + "content": result.result if not result.is_error else f"Error: {result.error}", + "tool_call_id": result.call_id, + } + ) + + return updated_messages + + +class OpenRouterAsyncClient(ChimericAsyncClient[AsyncOpenAI, ChatCompletion, ChatCompletionChunk]): + """Asynchronous OpenRouter Client for interacting with multiple LLM providers via OpenRouter API. + + This client provides a unified interface for asynchronous interactions with + various LLM providers through OpenRouter's API gateway. OpenRouter provides + access to models from OpenAI, Anthropic, Google, Meta, Cohere, and many other + providers through a single API interface that's compatible with OpenAI's format. + + The async client supports all the same features as the synchronous client: + - Asynchronous access to 200+ models from different providers + - Asynchronous advanced text generation with concurrent request processing + - Asynchronous function/tool calling with automatic execution + - Asynchronous streaming responses with real-time processing + - Model routing and fallback capabilities + - Cost optimization through provider selection + - Model listing and metadata retrieval + + Note: + OpenRouter acts as a gateway, providing access to the best models from + different providers while handling authentication, routing, and billing. + The async client is ideal for high-throughput applications and concurrent + request processing across multiple models. + + Example: + ```python + import asyncio + from chimeric.providers.openrouter import OpenRouterAsyncClient + from chimeric.tools import ToolManager + + async def main(): + tool_manager = ToolManager() + client = OpenRouterAsyncClient(api_key="your-openrouter-key", tool_manager=tool_manager) + + response = await client.chat_completion( + messages="What's the capital of France?", + model="openai/gpt-4o" # or "anthropic/claude-3-5-sonnet-20241022" + ) + print(response.common.content) + + asyncio.run(main()) + ``` + + Attributes: + api_key (str): The OpenRouter API key for authentication. + tool_manager (ToolManager): Manager for handling tool registration and execution. + """ + + def __init__(self, api_key: str, tool_manager, **kwargs: Any) -> None: + """Initialize the asynchronous OpenRouter client. + + Args: + api_key: The OpenRouter API key for authentication. + tool_manager: The tool manager instance for handling function calls. + **kwargs: Additional keyword arguments to pass to the AsyncOpenAI client + constructor. OpenRouter supports most OpenAI client parameters. + Common parameters include: + - base_url: Automatically set to OpenRouter's endpoint + - timeout: Request timeout in seconds + - max_retries: Maximum number of retries + - default_headers: Additional headers (e.g., HTTP-Referer, X-Title) + + Raises: + ValueError: If api_key is None or empty. + ProviderError: If client initialization fails. + """ + self._provider_name = "OpenRouter" + # Set OpenRouter's base URL if not provided + if "base_url" not in kwargs: + kwargs["base_url"] = "https://openrouter.ai/api/v1" + super().__init__(api_key=api_key, tool_manager=tool_manager, **kwargs) + + # ==================================================================== + # Required abstract method implementations + # ==================================================================== + + def _get_async_client_type(self) -> type: + """Get the asynchronous OpenAI client class type. + + Returns: + The AsyncOpenAI client class from the openai library. + """ + return AsyncOpenAI + + def _init_async_client(self, async_client_type: type, **kwargs: Any) -> AsyncOpenAI: + """Initializes the asynchronous OpenAI client pointed to OpenRouter.""" + return AsyncOpenAI(api_key=self.api_key, **kwargs) + + def _get_capabilities(self) -> Capability: + """Get supported features for the OpenRouter provider. + + Returns: + Capability object indicating which features are supported: + - streaming: True (supports real-time streaming responses) + - tools: True (supports function calling and tool use) + + Note: + OpenRouter supports the full OpenAI API interface including + streaming and tool calling across most models. + """ + return Capability(streaming=True, tools=True) + + async def _list_models_impl(self) -> list[ModelSummary]: + """Lists available models from the OpenRouter API asynchronously. + + Returns: + A list of ModelSummary objects containing model information + from OpenRouter's model registry. + """ + models_response = await self._async_client.models.list() + return [ + ModelSummary( + id=model.id, + name=getattr(model, "name", model.id), + owned_by=getattr(model, "owned_by", "openrouter"), + created_at=getattr(model, "created", None), + metadata=model.__dict__ if hasattr(model, "__dict__") else {}, + ) + for model in models_response + ] + + def _messages_to_provider_format(self, messages: list[Message]) -> Any: + """Converts standardized messages to OpenRouter/OpenAI chat format. + + OpenRouter uses the same message format as OpenAI's chat completions API. + """ + formatted_messages = [] + for msg in messages: + if msg.role == "tool": + # Tool result message - format for OpenAI chat completions + formatted_messages.append( + { + "role": "tool", + "content": str(msg.content), + "tool_call_id": msg.tool_call_id, + } + ) + elif msg.role == "assistant" and msg.tool_calls: + # Assistant message with tool calls + tool_calls_formatted = [ + { + "id": tool_call.call_id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": tool_call.arguments, + }, + } + for tool_call in msg.tool_calls + ] + formatted_messages.append( + { + "role": "assistant", + "content": msg.content, + "tool_calls": tool_calls_formatted, + } + ) + else: + # Regular message - convert to standard format + formatted_messages.append(msg.model_dump(exclude_none=True)) + + return formatted_messages + + def _tools_to_provider_format(self, tools: list[Tool]) -> Any: + """Converts standardized tools to OpenRouter/OpenAI format.""" + return [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters.model_dump() if tool.parameters else {}, + }, + } + for tool in tools + ] + + async def _make_async_provider_request( + self, + messages: Any, + model: str, + stream: bool, + tools: Any = None, + **kwargs: Any, + ) -> Any: + """Makes the actual async API request to OpenRouter.""" + tools_param = NOT_GIVEN if tools is None else tools + + return await self._async_client.chat.completions.create( + model=model, messages=messages, stream=stream, tools=tools_param, **kwargs + ) + + def _process_provider_stream_event( + self, event: ChatCompletionChunk, processor: StreamProcessor + ) -> ChimericStreamChunk[ChatCompletionChunk] | None: + """Processes an OpenRouter async stream event using the standardized processor. + + This is the same implementation as the sync client since event processing + is identical. + """ + if event.choices and event.choices[0].delta.content: + delta = event.choices[0].delta.content + return create_stream_chunk(native_event=event, processor=processor, content_delta=delta) + + # Handle tool calls in streaming + if event.choices and event.choices[0].delta.tool_calls: + for tool_call_delta in event.choices[0].delta.tool_calls: + call_id = tool_call_delta.id or f"tool_call_{getattr(tool_call_delta, 'index', 0)}" + + if tool_call_delta.function and tool_call_delta.function.name: + processor.process_tool_call_start(call_id, tool_call_delta.function.name) + + if tool_call_delta.function and tool_call_delta.function.arguments: + processor.process_tool_call_delta(call_id, tool_call_delta.function.arguments) + + # Handle completion + if event.choices and event.choices[0].finish_reason: + # Mark any streaming tool calls as complete + for call_id in processor.state.tool_calls: + processor.process_tool_call_complete(call_id) + + return create_stream_chunk( + native_event=event, + processor=processor, + finish_reason=event.choices[0].finish_reason, + ) + + return None + + def _extract_usage_from_response(self, response: ChatCompletion) -> Usage: + """Extracts usage information from OpenRouter response.""" + if not response.usage: + return Usage() + + return Usage( + prompt_tokens=response.usage.prompt_tokens, + completion_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + ) + + def _extract_content_from_response(self, response: ChatCompletion) -> str | list[Any]: + """Extracts content from OpenRouter response.""" + if not response.choices: + return "" + + choice = response.choices[0] + return choice.message.content or "" + + def _extract_tool_calls_from_response(self, response: ChatCompletion) -> list[ToolCall] | None: + """Extracts tool calls from OpenRouter response.""" + if not response.choices: + return None + + choice = response.choices[0] + if not choice.message.tool_calls: + return None + + return [ + ToolCall( + call_id=tool_call.id, + name=tool_call.function.name, + arguments=tool_call.function.arguments, + ) + for tool_call in choice.message.tool_calls + ] + + def _update_messages_with_tool_calls( + self, + messages: list[Any], + assistant_response: ChatCompletion | ChatCompletionChunk | Any, + tool_calls: list[ToolCall], + tool_results: list[ToolExecutionResult], + ) -> list[Any]: + """Updates message history with assistant response and tool results asynchronously. + + For OpenRouter/OpenAI, this follows the chat completions format where: + 1. Assistant message with tool calls is added + 2. Tool result messages are added for each tool execution + """ + updated_messages = list(messages) + + # Add the assistant message with tool calls + if isinstance(assistant_response, ChatCompletion) and assistant_response.choices: + choice = assistant_response.choices[0] + if choice.message.tool_calls: + updated_messages.append( + { + "role": "assistant", + "content": choice.message.content, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in choice.message.tool_calls + ], + } + ) + else: + # For streaming responses, reconstruct the assistant message + tool_calls_formatted = [ + { + "id": tool_call.call_id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": tool_call.arguments, + }, + } + for tool_call in tool_calls + ] + updated_messages.append( + { + "role": "assistant", + "content": None, + "tool_calls": tool_calls_formatted, + } + ) + + # Add the tool result messages + for result in tool_results: + updated_messages.append( + { + "role": "tool", + "content": result.result if not result.is_error else f"Error: {result.error}", + "tool_call_id": result.call_id, + } + ) + + return updated_messages diff --git a/src/chimeric/types.py b/src/chimeric/types.py index d5fec0d..28f5e4a 100644 --- a/src/chimeric/types.py +++ b/src/chimeric/types.py @@ -82,6 +82,7 @@ class Provider(Enum): COHERE = "cohere" GROK = "grok" GROQ = "groq" + OPENROUTER = "openrouter" ################### diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d1e955c..509c172 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -30,6 +30,7 @@ def real_api_keys(): "cohere_api_key": os.environ.get("COHERE_API_KEY", os.environ.get("CO_API_KEY")), "grok_api_key": os.environ.get("GROK_API_KEY", os.environ.get("GROK_API_TOKEN")), "groq_api_key": os.environ.get("GROQ_API_KEY"), + "openrouter_api_key": os.environ.get("OPENROUTER_API_KEY"), } @@ -113,6 +114,13 @@ def provider_specific_kwargs(): "groq": { "base_url": "https://api.groq.com/openai/v1", }, + "openrouter": { + "base_url": "https://openrouter.ai/api/v1", + "default_headers": { + "HTTP-Referer": "https://github.com/test/test", + "X-Title": "Chimeric Test Suite", + }, + }, } diff --git a/tests/integration/test_initialization.py b/tests/integration/test_initialization.py index 71c0fe2..c4b974a 100644 --- a/tests/integration/test_initialization.py +++ b/tests/integration/test_initialization.py @@ -30,7 +30,6 @@ def test_openai_only_env_initialization(api_keys): # Only OpenAI should be initialized chimeric = Chimeric() - assert len(chimeric.providers) == 1 assert Provider.OPENAI in chimeric.providers diff --git a/tests/integration/test_provider_openrouter.py b/tests/integration/test_provider_openrouter.py new file mode 100644 index 0000000..8d7a857 --- /dev/null +++ b/tests/integration/test_provider_openrouter.py @@ -0,0 +1,628 @@ +from collections.abc import AsyncGenerator + +import pytest + +from chimeric import Chimeric +from chimeric.exceptions import ProviderError + +from .vcr_config import get_cassette_path, get_vcr + + +@pytest.mark.openrouter +def test_openrouter_model_listing(api_keys): + """Test OpenRouter model listing functionality.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + cassette_path = get_cassette_path("openrouter", "test_model_listing") + + with get_vcr().use_cassette(cassette_path): + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + + models = chimeric.list_models() + assert len(models) > 0 + + # Should have models from multiple providers (OpenAI, Anthropic, etc.) + model_ids = [model.id.lower() for model in models] + # OpenRouter should have GPT models + assert any("gpt" in model_id for model_id in model_ids) + # And likely Claude models + # Note: This might vary based on OpenRouter's available models + + print(f"Found {len(models)} OpenRouter models") + + +@pytest.mark.openrouter +def test_openrouter_sync_generation(api_keys): + """Test OpenRouter synchronous generation functionality.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_sync_generation") + + with get_vcr().use_cassette(cassette_path): + response = chimeric.generate( + model="openai/gpt-4o-mini", # Use OpenRouter format + messages=[{"role": "user", "content": "Hello, respond briefly."}], + stream=False, + max_tokens=20, + ) + + assert response is not None + assert response.content + + +@pytest.mark.openrouter +@pytest.mark.asyncio +async def test_openrouter_async_generation(api_keys): + """Test OpenRouter asynchronous generation functionality.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_async_generation") + + with get_vcr().use_cassette(cassette_path): + response = await chimeric.agenerate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello, respond briefly."}], + stream=False, + max_tokens=20, + ) + + assert response is not None + assert response.content + + +@pytest.mark.openrouter +def test_openrouter_sync_streaming(api_keys): + """Test OpenRouter synchronous streaming functionality.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_sync_streaming") + + with get_vcr().use_cassette(cassette_path): + response = chimeric.generate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Tell me a short story about a robot."}], + stream=True, + max_tokens=50, + ) + + # Collect all chunks and verify streaming works + assert response is not None + chunks = list(response) + assert len(chunks) > 0 + content_chunks = [chunk for chunk in chunks if hasattr(chunk, "content") and chunk.content] + assert len(content_chunks) > 0, "At least some chunks should have content" + + +@pytest.mark.openrouter +@pytest.mark.asyncio +async def test_openrouter_async_streaming(api_keys): + """Test OpenRouter asynchronous streaming functionality.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_async_streaming") + + with get_vcr().use_cassette(cassette_path): + response = await chimeric.agenerate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Tell me a short story about a robot."}], + stream=True, + max_tokens=50, + ) + + # Collect all chunks and verify streaming works + assert response is not None + assert isinstance(response, AsyncGenerator), ( + "Response should be an AsyncGenerator when streaming" + ) + chunks = [chunk async for chunk in response] + assert len(chunks) > 0 + content_chunks = [chunk for chunk in chunks if chunk.content] + assert len(content_chunks) > 0, "At least some chunks should have content" + + +@pytest.mark.openrouter +def test_openrouter_sync_tools_non_streaming(api_keys): + """Test OpenRouter sync generation with tools (non-streaming).""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_sync_tools_non_streaming") + + with get_vcr().use_cassette(cassette_path): + # Track tool calls + tool_calls = {"add": 0, "subtract": 0, "joke": 0} + + @chimeric.tool() + def add(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Adds two numbers together. + Args: + x: the first number + y: the second number + + Returns: + The sum of x and y. + """ + print("Adding numbers:", x, y) + tool_calls["add"] += 1 + return x + y + + @chimeric.tool() + def subtract(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Subtracts the second number from the first. + Args: + x: the first number + y: the second number + + Returns: + The result of x - y. + """ + print("Subtracting numbers:", x, y) + tool_calls["subtract"] += 1 + return x - y + + @chimeric.tool() + def joke() -> str: # type: ignore[reportUnusedFunction] + """ + Returns a joke. + """ + print("Telling a joke...") + tool_calls["joke"] += 1 + return "Why did the chicken cross the road? To get to the other side!" + + response = chimeric.generate( + model="openai/gpt-4o-mini", # Use a model that supports tools + messages=[{"role": "user", "content": "What is 2+2-4-10+50? Tell me a joke."}], + stream=False, + ) + + assert response is not None + assert response.content + + # Verify tools were called + assert tool_calls["add"] > 0, "Add function should have been called" + assert tool_calls["subtract"] > 0, "Subtract function should have been called" + assert tool_calls["joke"] > 0, "Joke function should have been called" + + # Print summary for debugging + print(f"Tool call counts: {tool_calls}") + + +@pytest.mark.openrouter +def test_openrouter_sync_tools_streaming(api_keys): + """Test OpenRouter sync generation with tools (streaming).""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_sync_tools_streaming") + + with get_vcr().use_cassette(cassette_path): + # Track tool calls + tool_calls = {"add": 0, "subtract": 0, "joke": 0} + + @chimeric.tool() + def add(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Adds two numbers together. + Args: + x: the first number + y: the second number + + Returns: + The sum of x and y. + """ + print("Adding numbers:", x, y) + tool_calls["add"] += 1 + return x + y + + @chimeric.tool() + def subtract(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Subtracts the second number from the first. + Args: + x: the first number + y: the second number + + Returns: + The result of x - y. + """ + print("Subtracting numbers:", x, y) + tool_calls["subtract"] += 1 + return x - y + + @chimeric.tool() + def joke() -> str: # type: ignore[reportUnusedFunction] + """ + Returns a joke. + """ + print("Telling a joke...") + tool_calls["joke"] += 1 + return "Why did the chicken cross the road? To get to the other side!" + + response = chimeric.generate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "What is 2+2-4-10+50? Tell me a joke."}], + stream=True, + ) + + # Collect all chunks and verify at least some have content + assert response is not None + chunks = list(response) + assert len(chunks) > 0 + content_chunks = [chunk for chunk in chunks if hasattr(chunk, "content") and chunk.content] + assert len(content_chunks) > 0, "At least some chunks should have content" + + # Verify tools were actually called + assert tool_calls["add"] > 0, "Add function should have been called" + assert tool_calls["subtract"] > 0, "Subtract function should have been called" + assert tool_calls["joke"] > 0, "Joke function should have been called" + + # Print summary for debugging + print(f"Tool call counts: {tool_calls}") + + +@pytest.mark.openrouter +@pytest.mark.asyncio +async def test_openrouter_async_tools_streaming(api_keys): + """Test OpenRouter async generation with tools (streaming).""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_async_tools_streaming") + + with get_vcr().use_cassette(cassette_path): + # Track tool calls + tool_calls = {"add": 0, "subtract": 0, "joke": 0} + + @chimeric.tool() + def add(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Adds two numbers together. + Args: + x: the first number + y: the second number + + Returns: + The sum of x and y. + """ + print("Adding numbers:", x, y) + tool_calls["add"] += 1 + return x + y + + @chimeric.tool() + def subtract(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Subtracts the second number from the first. + Args: + x: the first number + y: the second number + + Returns: + The result of x - y. + """ + print("Subtracting numbers:", x, y) + tool_calls["subtract"] += 1 + return x - y + + @chimeric.tool() + def joke() -> str: # type: ignore[reportUnusedFunction] + """ + Returns a joke. + """ + print("Telling a joke...") + tool_calls["joke"] += 1 + return "Why did the chicken cross the road? To get to the other side!" + + response = await chimeric.agenerate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "What is 2+2-4-10+50? Tell me a joke."}], + stream=True, + ) + + # Collect all chunks and verify at least some have content + assert response is not None + assert isinstance(response, AsyncGenerator), ( + "Response should be an AsyncGenerator when streaming" + ) + chunks = [chunk async for chunk in response] + assert len(chunks) > 0 + content_chunks = [chunk for chunk in chunks if chunk.content] + assert len(content_chunks) > 0, "At least some chunks should have content" + + # Verify tools were actually called + assert tool_calls["add"] > 0, "Add function should have been called" + assert tool_calls["subtract"] > 0, "Subtract function should have been called" + assert tool_calls["joke"] > 0, "Joke function should have been called" + + # Print summary for debugging + print(f"Tool call counts: {tool_calls}") + + +@pytest.mark.openrouter +@pytest.mark.asyncio +async def test_openrouter_async_tools_non_streaming(api_keys): + """Test OpenRouter async generation with tools (non-streaming).""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_async_tools_non_streaming") + + with get_vcr().use_cassette(cassette_path): + # Track tool calls + tool_calls = {"add": 0, "subtract": 0, "joke": 0} + + @chimeric.tool() + def add(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Adds two numbers together. + Args: + x: the first number + y: the second number + + Returns: + The sum of x and y. + """ + print("Adding numbers:", x, y) + tool_calls["add"] += 1 + return x + y + + @chimeric.tool() + def subtract(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """ + Subtracts the second number from the first. + Args: + x: the first number + y: the second number + + Returns: + The result of x - y. + """ + print("Subtracting numbers:", x, y) + tool_calls["subtract"] += 1 + return x - y + + @chimeric.tool() + def joke() -> str: # type: ignore[reportUnusedFunction] + """ + Returns a joke. + """ + print("Telling a joke...") + tool_calls["joke"] += 1 + return "Why did the chicken cross the road? To get to the other side!" + + response = await chimeric.agenerate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "What is 2+2-4-10+50? Tell me a joke."}], + stream=False, + ) + + assert response is not None + assert response.content + + # Verify tools were actually called + assert tool_calls["add"] > 0, "Add function should have been called" + assert tool_calls["subtract"] > 0, "Subtract function should have been called" + assert tool_calls["joke"] > 0, "Joke function should have been called" + + # Print summary for debugging + print(f"Tool call counts: {tool_calls}") + + +@pytest.mark.openrouter +def test_openrouter_different_models(api_keys): + """Test OpenRouter with different provider models.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_different_models") + + with get_vcr().use_cassette(cassette_path): + # Test OpenAI model through OpenRouter + response1 = chimeric.generate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Say 'OpenAI model works'"}], + stream=False, + max_tokens=10, + ) + assert response1 is not None + assert response1.content + + # Test Anthropic model through OpenRouter (if available) + try: + response2 = chimeric.generate( + model="anthropic/claude-3-haiku-20240307", + messages=[{"role": "user", "content": "Say 'Anthropic model works'"}], + stream=False, + max_tokens=10, + ) + assert response2 is not None + assert response2.content + print("Anthropic model test successful") + except Exception as e: + print(f"Anthropic model test skipped: {e}") + + # Test Meta model through OpenRouter (if available) + try: + response3 = chimeric.generate( + model="meta-llama/llama-3.1-8b-instruct:free", + messages=[{"role": "user", "content": "Say 'Meta model works'"}], + stream=False, + max_tokens=10, + ) + assert response3 is not None + assert response3.content + print("Meta model test successful") + except Exception as e: + print(f"Meta model test skipped: {e}") + + +@pytest.mark.openrouter +def test_openrouter_init_kwargs_propagation(api_keys): + """Test OpenRouter kwargs propagation through the stack with custom headers.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + # Initialize Chimeric with custom OpenRouter-specific parameters + chimeric = Chimeric( + openrouter_api_key=api_keys["openrouter_api_key"], + timeout=60, + max_retries=3, + default_headers={ + "HTTP-Referer": "https://github.com/test/chimeric", + "X-Title": "Chimeric Test Suite", + }, + # Fake params that other providers might use + anthropic_fake_param="should_be_ignored", + google_vertex_project="fake_project", + cohere_fake_setting=True, + ) + + cassette_path = get_cassette_path("openrouter", "test_kwargs_propagation") + + with get_vcr().use_cassette(cassette_path): + # Test with generation kwargs + response = chimeric.generate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello, respond briefly."}], + temperature=0.1, + max_tokens=20, + stream=False, + ) + + assert response is not None + assert response.content + + +@pytest.mark.openrouter +def test_openrouter_provider_routing_features(api_keys): + """Test OpenRouter-specific features like provider routing.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_provider_routing") + + with get_vcr().use_cassette(cassette_path): + # Test with OpenRouter-specific parameters in extra_body + # Note: These would typically be passed via extra_body in the OpenAI client + response = chimeric.generate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello, respond briefly."}], + stream=False, + max_tokens=20, + # OpenRouter-specific parameters could be added here if needed + ) + + assert response is not None + assert response.content + + +@pytest.mark.openrouter +def test_openrouter_async_tools_with_different_types(api_keys): + """Test OpenRouter async tools with different return types.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_async_tools_different_types") + + @chimeric.tool() + async def async_calculate(x: int, y: int) -> int: # type: ignore[reportUnusedFunction] + """Async calculation function.""" + return x * y + + @chimeric.tool() + def sync_format(text: str) -> str: # type: ignore[reportUnusedFunction] + """Sync text formatting function.""" + return f"Formatted: {text.upper()}" + + with get_vcr().use_cassette(cassette_path): + response = chimeric.generate( + model="openai/gpt-4o-mini", + messages=[ + {"role": "user", "content": "Calculate 3 times 4, then format the word 'hello'"} + ], + stream=False, + ) + + assert response is not None + assert response.content + + +@pytest.mark.openrouter +def test_openrouter_invalid_generate_kwargs_raises_provider_error(api_keys): + """Test that invalid kwargs in generate raise ProviderError.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + cassette_path = get_cassette_path("openrouter", "test_invalid_kwargs_raises_provider_error") + + with get_vcr().use_cassette(cassette_path): + # Test with an invalid parameter that doesn't exist in OpenAI/OpenRouter API + with pytest.raises(ProviderError) as exc_info: + chimeric.generate( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello"}], + invalid_openrouter_parameter="this_should_fail", + stream=False, + ) + + # Verify the error contains provider information + assert "OpenRouter" in str(exc_info.value) or "openrouter" in str(exc_info.value).lower() + print(f"ProviderError raised as expected: {exc_info.value}") + + +@pytest.mark.openrouter +def test_openrouter_capabilities(api_keys): + """Test OpenRouter capabilities detection.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + + # Test capabilities + capabilities = chimeric.get_capabilities("openrouter") + assert capabilities.streaming is True + assert capabilities.tools is True + + print(f"OpenRouter capabilities: {capabilities}") + + +@pytest.mark.openrouter +def test_openrouter_model_filtering(api_keys): + """Test OpenRouter model filtering functionality.""" + if "openrouter_api_key" not in api_keys: + pytest.skip("OpenRouter API key not found") + + cassette_path = get_cassette_path("openrouter", "test_model_filtering") + + with get_vcr().use_cassette(cassette_path): + chimeric = Chimeric(openrouter_api_key=api_keys["openrouter_api_key"]) + + # Get all models + all_models = chimeric.list_models("openrouter") + assert len(all_models) > 0 + + # Check model metadata + for model in all_models[:5]: # Check first 5 models + assert model.id is not None + assert model.name is not None + assert model.provider == "openrouter" + + print(f"First few OpenRouter models: {[m.id for m in all_models[:5]]}") diff --git a/tests/integration/vcr_config.py b/tests/integration/vcr_config.py index cc9a36d..56ed403 100644 --- a/tests/integration/vcr_config.py +++ b/tests/integration/vcr_config.py @@ -33,6 +33,7 @@ def get_vcr() -> vcr.VCR: "cohere-api-key", "grok-api-key", "groq-api-key", + "openrouter-api-key", "x-goog-api-key", "x-goog-request-id", "x-request-id", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 3f7ede1..fec51f6 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -18,6 +18,7 @@ def isolate_environment(monkeypatch): "GROK_API_KEY", "GROK_API_TOKEN", "GROQ_API_KEY", + "OPENROUTER_API_KEY", ] for var in api_key_vars: @@ -35,6 +36,7 @@ def mock_api_keys(): "cohere_api_key": "test-cohere-key", "grok_api_key": "test-grok-key", "groq_api_key": "test-groq-key", + "openrouter_api_key": "test-openrouter-key", } diff --git a/tests/unit/providers/test_openrouter.py b/tests/unit/providers/test_openrouter.py new file mode 100644 index 0000000..6397fd5 --- /dev/null +++ b/tests/unit/providers/test_openrouter.py @@ -0,0 +1,1459 @@ +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from openai.types.chat import ChatCompletion, ChatCompletionChunk + +from chimeric.providers.openrouter import OpenRouterAsyncClient, OpenRouterClient +from chimeric.types import Capability, Message, Tool, ToolCall, ToolExecutionResult, ToolParameters +from chimeric.utils import StreamProcessor +from conftest import BaseProviderTestSuite + + +class TestOpenRouterClient(BaseProviderTestSuite): + """Test suite for OpenRouter sync client""" + + client_class = OpenRouterClient + provider_name = "OpenRouter" + mock_client_path = "chimeric.providers.openrouter.client.OpenAI" + + @property + def sample_response(self): + """Create a sample OpenRouter response.""" + response = Mock(spec=ChatCompletion) + + # Mock the choice + choice = Mock() + choice.message = Mock() + choice.message.content = "Hello there" + choice.message.tool_calls = None + choice.finish_reason = "stop" + + response.choices = [choice] + response.usage = Mock(prompt_tokens=10, completion_tokens=20, total_tokens=30) + response.model = "openai/gpt-4o-mini" + response.id = "chatcmpl-123" + + return response + + @property + def sample_stream_events(self): + """Create sample OpenRouter stream events.""" + events = [] + + # Content delta event + chunk1 = Mock(spec=ChatCompletionChunk) + choice1 = Mock() + choice1.delta = Mock() + choice1.delta.content = "Hello" + choice1.delta.tool_calls = None + choice1.finish_reason = None + chunk1.choices = [choice1] + events.append(chunk1) + + # Tool call start event + chunk2 = Mock(spec=ChatCompletionChunk) + choice2 = Mock() + choice2.delta = Mock() + choice2.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = "call_123" + tool_call_delta.function = Mock() + tool_call_delta.function.name = "test_tool" + tool_call_delta.function.arguments = None + tool_call_delta.index = 0 + choice2.delta.tool_calls = [tool_call_delta] + choice2.finish_reason = None + chunk2.choices = [choice2] + events.append(chunk2) + + # Tool call arguments event + chunk3 = Mock(spec=ChatCompletionChunk) + choice3 = Mock() + choice3.delta = Mock() + choice3.delta.content = None + tool_call_delta2 = Mock() + tool_call_delta2.id = None + tool_call_delta2.function = Mock() + tool_call_delta2.function.name = None + tool_call_delta2.function.arguments = '{"x": 10}' + tool_call_delta2.index = 0 + choice3.delta.tool_calls = [tool_call_delta2] + choice3.finish_reason = None + chunk3.choices = [choice3] + events.append(chunk3) + + # Finish event + chunk4 = Mock(spec=ChatCompletionChunk) + choice4 = Mock() + choice4.delta = Mock() + choice4.delta.content = None + choice4.delta.tool_calls = None + choice4.finish_reason = "stop" + chunk4.choices = [choice4] + events.append(chunk4) + + return events + + # ===== Initialization Tests ===== + + def test_client_initialization_success(self): + """Test successful client initialization with all parameters.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_openai: + client = self.client_class( + api_key="test-key", + tool_manager=tool_manager, + timeout=30, + default_headers={"X-Title": "Test"}, + ) + + assert client.api_key == "test-key" + assert client.tool_manager == tool_manager + assert client._provider_name == self.provider_name + # Should be called with OpenRouter base URL + mock_openai.assert_called_once_with( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + timeout=30, + default_headers={"X-Title": "Test"}, + ) + + def test_client_initialization_custom_base_url(self): + """Test client initialization with custom base URL.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_openai: + self.client_class( + api_key="test-key", + tool_manager=tool_manager, + base_url="https://custom.openrouter.ai/api/v1", + ) + + # Should use the custom base URL + mock_openai.assert_called_once_with( + api_key="test-key", base_url="https://custom.openrouter.ai/api/v1" + ) + + # ===== Capability Tests ===== + + def test_capabilities(self): + """Test provider capabilities.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + capabilities = client._get_capabilities() + + assert isinstance(capabilities, Capability) + assert capabilities.streaming is True + assert capabilities.tools is True + + # ===== Model Listing Tests ===== + + def test_list_models_success(self): + """Test successful model listing.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_openai: + mock_client = MagicMock() + mock_openai.return_value = mock_client + + # Mock model list response + mock_model = Mock(id="openai/gpt-4o-mini") + mock_model.name = "GPT-4o Mini" + mock_model.owned_by = "openai" + mock_model.created = 1234567890 + mock_client.models.list.return_value = [mock_model] + + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + models = client._list_models_impl() + + assert len(models) == 1 + assert models[0].id == "openai/gpt-4o-mini" + assert models[0].name == "GPT-4o Mini" + assert models[0].owned_by == "openai" + + # ===== Message Formatting Tests ===== + + def test_messages_to_provider_format_basic(self): + """Test basic message formatting.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [ + Message(role="system", content="You are helpful"), + Message(role="user", content="Hello"), + ] + + formatted = client._messages_to_provider_format(messages) + + assert len(formatted) == 2 + assert formatted[0]["role"] == "system" + assert formatted[0]["content"] == "You are helpful" + assert formatted[1]["role"] == "user" + assert formatted[1]["content"] == "Hello" + + def test_messages_to_provider_format_with_tool_calls(self): + """Test message formatting with tool calls.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + tool_call = ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}') + messages = [Message(role="assistant", content="I'll help you", tool_calls=[tool_call])] + + formatted = client._messages_to_provider_format(messages) + + assert len(formatted) == 1 + assert formatted[0]["role"] == "assistant" + assert formatted[0]["content"] == "I'll help you" + assert len(formatted[0]["tool_calls"]) == 1 + assert formatted[0]["tool_calls"][0]["id"] == "call_123" + assert formatted[0]["tool_calls"][0]["function"]["name"] == "test_tool" + + def test_messages_to_provider_format_tool_result(self): + """Test message formatting with tool result.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [Message(role="tool", content="Result: 42", tool_call_id="call_123")] + + formatted = client._messages_to_provider_format(messages) + + assert len(formatted) == 1 + assert formatted[0]["role"] == "tool" + assert formatted[0]["content"] == "Result: 42" + assert formatted[0]["tool_call_id"] == "call_123" + + # ===== Tool Formatting Tests ===== + + def test_tools_to_provider_format(self): + """Test tool formatting.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + tool_params = ToolParameters( + type="object", properties={"x": {"type": "integer"}}, required=["x"] + ) + tools = [Tool(name="test_tool", description="A test tool", parameters=tool_params)] + + formatted = client._tools_to_provider_format(tools) + + assert len(formatted) == 1 + assert formatted[0]["type"] == "function" + assert formatted[0]["function"]["name"] == "test_tool" + assert formatted[0]["function"]["description"] == "A test tool" + assert formatted[0]["function"]["parameters"]["type"] == "object" + + # ===== API Request Tests ===== + + def test_make_provider_request(self): + """Test making provider request.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_openai: + mock_client = MagicMock() + mock_openai.return_value = mock_client + + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + tools = [{"type": "function", "function": {"name": "test"}}] + + client._make_provider_request( + messages=messages, + model="openai/gpt-4o-mini", + stream=False, + tools=tools, + temperature=0.7, + ) + + mock_client.chat.completions.create.assert_called_once_with( + model="openai/gpt-4o-mini", + messages=messages, + stream=False, + tools=tools, + temperature=0.7, + ) + + # ===== Stream Processing Tests ===== + + def test_process_provider_stream_event_content(self): + """Test processing stream event with content.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = "Hello" + choice.delta.tool_calls = None + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + assert result is not None + assert result.common.content == "Hello" + + def test_process_provider_stream_event_no_choices(self): + """Test processing stream event with no choices.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + chunk.choices = [] + + result = client._process_provider_stream_event(chunk, processor) + + assert result is None + + def test_process_provider_stream_event_tool_call(self): + """Test processing stream event with tool call.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = "call_123" + tool_call_delta.function = Mock() + tool_call_delta.function.name = "test_tool" + tool_call_delta.function.arguments = None + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Tool call start events don't return chunks immediately, + # they just update the processor state + assert result is None + + def test_process_provider_stream_event_tool_call_arguments(self): + """Test processing stream event with tool call arguments only.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + # First, add a tool call to the processor + processor.process_tool_call_start("call_123", "test_tool", "call_123") + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = None # No ID, just arguments + tool_call_delta.function = Mock() + tool_call_delta.function.name = None + tool_call_delta.function.arguments = '{"x": 10}' + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Tool call argument events don't return chunks immediately + assert result is None + + def test_process_provider_stream_event_tool_call_no_function(self): + """Test processing stream event with tool call but no function.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = "call_123" + tool_call_delta.function = None # No function + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Tool call start events don't return chunks immediately + assert result is None + + def test_process_provider_stream_event_tool_call_with_arguments_no_function(self): + """Test processing stream event with tool call arguments but no function.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = None # No ID + tool_call_delta.function = None # No function either + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Should not process anything and return None + assert result is None + + def test_process_provider_stream_event_finish(self): + """Test processing stream event with finish reason.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + choice.delta.tool_calls = None + choice.finish_reason = "stop" + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + assert result is not None + assert result.common.finish_reason == "stop" + + # ===== Response Extraction Tests ===== + + def test_extract_usage_from_response(self): + """Test usage extraction from response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = self.sample_response + usage = client._extract_usage_from_response(response) + + assert usage.prompt_tokens == 10 + assert usage.completion_tokens == 20 + assert usage.total_tokens == 30 + + def test_extract_usage_from_response_no_usage(self): + """Test usage extraction when response has no usage.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + response.usage = None + + usage = client._extract_usage_from_response(response) + + assert usage.prompt_tokens == 0 + assert usage.completion_tokens == 0 + assert usage.total_tokens == 0 + + def test_extract_content_from_response(self): + """Test content extraction from response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = self.sample_response + content = client._extract_content_from_response(response) + + assert content == "Hello there" + + def test_extract_content_from_response_empty_choices(self): + """Test content extraction when response has no choices.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + response.choices = [] + + content = client._extract_content_from_response(response) + + assert content == "" + + def test_extract_content_from_response_none_content(self): + """Test content extraction when message content is None.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = None + response.choices = [choice] + + content = client._extract_content_from_response(response) + + assert content == "" + + def test_extract_tool_calls_from_response(self): + """Test tool call extraction from response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + tool_call = Mock() + tool_call.id = "call_123" + tool_call.function = Mock() + tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"x": 10}' + choice.message.tool_calls = [tool_call] + response.choices = [choice] + + tool_calls = client._extract_tool_calls_from_response(response) + + assert tool_calls is not None + assert len(tool_calls) == 1 + assert tool_calls[0].call_id == "call_123" + assert tool_calls[0].name == "test_tool" + assert tool_calls[0].arguments == '{"x": 10}' + + def test_extract_tool_calls_from_response_no_tool_calls(self): + """Test tool call extraction when no tool calls present.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.tool_calls = None + response.choices = [choice] + + tool_calls = client._extract_tool_calls_from_response(response) + + assert tool_calls is None + + def test_extract_tool_calls_from_response_empty_choices(self): + """Test tool call extraction with empty choices.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + response.choices = [] + + tool_calls = client._extract_tool_calls_from_response(response) + + assert tool_calls is None + + # ===== Message Update Tests ===== + + def test_update_messages_with_tool_calls(self): + """Test updating messages with tool calls and results.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a response with tool calls + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = "I'll help with that calculation" + tool_call = Mock() + tool_call.id = "call_123" + tool_call.function = Mock() + tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"x": 10}' + choice.message.tool_calls = [tool_call] + response.choices = [choice] + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + assert len(updated) == 3 # original + assistant + tool result + assert updated[1]["role"] == "assistant" + assert updated[1]["content"] == "I'll help with that calculation" + assert updated[2]["role"] == "tool" + assert updated[2]["tool_call_id"] == "call_123" + + def test_update_messages_with_tool_calls_stream_response(self): + """Test updating messages with tool calls for streaming response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a streaming response (not ChatCompletion) + stream_event = Mock(spec=ChatCompletionChunk) + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, stream_event, tool_calls, tool_results + ) + + assert len(updated) == 3 # original + assistant + tool result + assert updated[1]["role"] == "assistant" + assert updated[1]["content"] is None # None for streaming + assert updated[2]["role"] == "tool" + assert updated[2]["tool_call_id"] == "call_123" + + def test_update_messages_with_tool_calls_error_result(self): + """Test updating messages with tool calls when tool execution fails.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a response with tool calls + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = "I'll help with that calculation" + tool_call = Mock() + tool_call.id = "call_123" + tool_call.function = Mock() + tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"x": 10}' + choice.message.tool_calls = [tool_call] + response.choices = [choice] + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", + name="test_tool", + arguments='{"x": 10}', + error="Tool failed", + is_error=True + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + assert len(updated) == 3 # original + assistant + tool result + assert updated[2]["role"] == "tool" + assert updated[2]["content"] == "Error: Tool failed" + assert updated[2]["tool_call_id"] == "call_123" + + def test_update_messages_with_tool_calls_response_no_tool_calls(self): + """Test updating messages when response has no tool calls.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a ChatCompletion response without tool calls + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = "I'll help with that calculation" + choice.message.tool_calls = None # No tool calls + response.choices = [choice] + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + # When ChatCompletion response has no tool calls, the condition at line 302 fails + # so no assistant message is added, only tool results + assert len(updated) == 2 # original + tool result only + assert updated[1]["role"] == "tool" + assert updated[1]["tool_call_id"] == "call_123" + + def test_update_messages_with_tool_calls_non_completion_response(self): + """Test updating messages with non-ChatCompletion response (else branch).""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a non-ChatCompletion response (e.g., streaming chunk) + response = Mock(spec=ChatCompletionChunk) # Not ChatCompletion + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + # Should go to else branch and add assistant message + tool results + assert len(updated) == 3 # original + assistant + tool result + assert updated[1]["role"] == "assistant" + assert updated[1]["content"] is None # None for streaming + assert updated[2]["role"] == "tool" + assert updated[2]["tool_call_id"] == "call_123" + + +class TestOpenRouterAsyncClient(BaseProviderTestSuite): + """Test suite for OpenRouter async client""" + + client_class = OpenRouterAsyncClient + provider_name = "OpenRouter" + mock_client_path = "chimeric.providers.openrouter.client.AsyncOpenAI" + + @property + def sample_response(self): + """Create a sample OpenRouter response.""" + response = Mock(spec=ChatCompletion) + + choice = Mock() + choice.message = Mock() + choice.message.content = "Hello there" + choice.message.tool_calls = None + choice.finish_reason = "stop" + + response.choices = [choice] + response.usage = Mock(prompt_tokens=10, completion_tokens=20, total_tokens=30) + response.model = "openai/gpt-4o-mini" + response.id = "chatcmpl-123" + + return response + + @property + def sample_stream_events(self): + """Create sample OpenRouter stream events.""" + events = [] + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = "Hello" + choice.delta.tool_calls = None + choice.finish_reason = None + chunk.choices = [choice] + events.append(chunk) + + return events + + # ===== Async Initialization Tests ===== + + def test_async_client_initialization(self): + """Test async client initialization.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_async_openai: + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + assert client.api_key == "test-key" + assert client._provider_name == self.provider_name + mock_async_openai.assert_called_once_with( + api_key="test-key", base_url="https://openrouter.ai/api/v1" + ) + + def test_async_client_initialization_custom_base_url(self): + """Test async client initialization with custom base URL.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_async_openai: + self.client_class( + api_key="test-key", + tool_manager=tool_manager, + base_url="https://custom.openrouter.ai/api/v1", + ) + + # Should use the custom base URL + mock_async_openai.assert_called_once_with( + api_key="test-key", base_url="https://custom.openrouter.ai/api/v1" + ) + + # ===== Async Capability Tests ===== + + def test_async_capabilities(self): + """Test async provider capabilities.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + capabilities = client._get_capabilities() + + assert isinstance(capabilities, Capability) + assert capabilities.streaming is True + assert capabilities.tools is True + + # ===== Async Model Listing Tests ===== + + def test_async_list_models(self): + """Test async model listing.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_async_openai: + mock_client = AsyncMock() + mock_async_openai.return_value = mock_client + + mock_model = Mock() + mock_model.id = "openai/gpt-4o-mini" + mock_model.name = "GPT-4o Mini" + mock_model.owned_by = "openai" + mock_model.created = 1234567890 + mock_client.models.list.return_value = [mock_model] + + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + import asyncio + + async def test(): + models = await client._list_models_impl() + assert len(models) == 1 + assert models[0].id == "openai/gpt-4o-mini" + assert models[0].name == "GPT-4o Mini" + assert models[0].owned_by == "openai" + + asyncio.run(test()) + + # ===== Async Message Formatting Tests ===== + + def test_async_messages_to_provider_format_basic(self): + """Test async basic message formatting.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [ + Message(role="system", content="You are helpful"), + Message(role="user", content="Hello"), + ] + + formatted = client._messages_to_provider_format(messages) + + assert len(formatted) == 2 + assert formatted[0]["role"] == "system" + assert formatted[0]["content"] == "You are helpful" + assert formatted[1]["role"] == "user" + assert formatted[1]["content"] == "Hello" + + def test_async_messages_to_provider_format_with_tool_calls(self): + """Test async message formatting with tool calls.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + tool_call = ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}') + messages = [Message(role="assistant", content="I'll help you", tool_calls=[tool_call])] + + formatted = client._messages_to_provider_format(messages) + + assert len(formatted) == 1 + assert formatted[0]["role"] == "assistant" + assert formatted[0]["content"] == "I'll help you" + assert len(formatted[0]["tool_calls"]) == 1 + assert formatted[0]["tool_calls"][0]["id"] == "call_123" + assert formatted[0]["tool_calls"][0]["function"]["name"] == "test_tool" + + def test_async_messages_to_provider_format_tool_result(self): + """Test async message formatting with tool result.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [Message(role="tool", content="Result: 42", tool_call_id="call_123")] + + formatted = client._messages_to_provider_format(messages) + + assert len(formatted) == 1 + assert formatted[0]["role"] == "tool" + assert formatted[0]["content"] == "Result: 42" + assert formatted[0]["tool_call_id"] == "call_123" + + # ===== Async Tool Formatting Tests ===== + + def test_async_tools_to_provider_format(self): + """Test async tool formatting.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + tool_params = ToolParameters( + type="object", properties={"x": {"type": "integer"}}, required=["x"] + ) + tools = [Tool(name="test_tool", description="A test tool", parameters=tool_params)] + + formatted = client._tools_to_provider_format(tools) + + assert len(formatted) == 1 + assert formatted[0]["type"] == "function" + assert formatted[0]["function"]["name"] == "test_tool" + assert formatted[0]["function"]["description"] == "A test tool" + assert formatted[0]["function"]["parameters"]["type"] == "object" + + # ===== Async API Request Tests ===== + + def test_async_make_provider_request(self): + """Test async provider request.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path) as mock_async_openai: + mock_client = AsyncMock() + mock_async_openai.return_value = mock_client + + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + import asyncio + + async def test(): + await client._make_async_provider_request( + messages=[{"role": "user", "content": "Hello"}], + model="openai/gpt-4o-mini", + stream=False, + tools=None, + ) + + mock_client.chat.completions.create.assert_called_once() + _, kwargs = mock_client.chat.completions.create.call_args + from openai import NOT_GIVEN + + assert kwargs.get("tools") == NOT_GIVEN + + asyncio.run(test()) + + # ===== Async Stream Processing Tests ===== + + def test_async_process_provider_stream_event_content(self): + """Test async processing stream event with content.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = "Hello" + choice.delta.tool_calls = None + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + assert result is not None + assert result.common.content == "Hello" + + def test_async_process_provider_stream_event_no_choices(self): + """Test async processing stream event with no choices.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + chunk.choices = [] + + result = client._process_provider_stream_event(chunk, processor) + + assert result is None + + def test_async_process_provider_stream_event_tool_call(self): + """Test async processing stream event with tool call.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = "call_123" + tool_call_delta.function = Mock() + tool_call_delta.function.name = "test_tool" + tool_call_delta.function.arguments = None + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Tool call start events don't return chunks immediately + assert result is None + + def test_async_process_provider_stream_event_tool_call_arguments(self): + """Test async processing stream event with tool call arguments only.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + # First, add a tool call to the processor + processor.process_tool_call_start("call_123", "test_tool", "call_123") + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = None # No ID, just arguments + tool_call_delta.function = Mock() + tool_call_delta.function.name = None + tool_call_delta.function.arguments = '{"x": 10}' + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Tool call argument events don't return chunks immediately + assert result is None + + def test_async_process_provider_stream_event_tool_call_no_function(self): + """Test async processing stream event with tool call but no function.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = "call_123" + tool_call_delta.function = None # No function + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Tool call start events don't return chunks immediately + assert result is None + + def test_async_process_provider_stream_event_tool_call_with_arguments_no_function(self): + """Test async processing stream event with tool call arguments but no function.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + tool_call_delta = Mock() + tool_call_delta.id = None # No ID + tool_call_delta.function = None # No function either + tool_call_delta.index = 0 + choice.delta.tool_calls = [tool_call_delta] + choice.finish_reason = None + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + # Should not process anything and return None + assert result is None + + def test_async_process_provider_stream_event_finish(self): + """Test async processing stream event with finish reason.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + processor = StreamProcessor() + + chunk = Mock(spec=ChatCompletionChunk) + choice = Mock() + choice.delta = Mock() + choice.delta.content = None + choice.delta.tool_calls = None + choice.finish_reason = "stop" + chunk.choices = [choice] + + result = client._process_provider_stream_event(chunk, processor) + + assert result is not None + assert result.common.finish_reason == "stop" + + # ===== Async Response Extraction Tests ===== + + def test_async_extract_usage_from_response(self): + """Test async usage extraction from response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = self.sample_response + usage = client._extract_usage_from_response(response) + + assert usage.prompt_tokens == 10 + assert usage.completion_tokens == 20 + assert usage.total_tokens == 30 + + def test_async_extract_usage_from_response_no_usage(self): + """Test async usage extraction when response has no usage.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + response.usage = None + + usage = client._extract_usage_from_response(response) + + assert usage.prompt_tokens == 0 + assert usage.completion_tokens == 0 + assert usage.total_tokens == 0 + + def test_async_extract_content_from_response(self): + """Test async content extraction from response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = self.sample_response + content = client._extract_content_from_response(response) + + assert content == "Hello there" + + def test_async_extract_content_from_response_empty_choices(self): + """Test async content extraction when response has no choices.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + response.choices = [] + + content = client._extract_content_from_response(response) + + assert content == "" + + def test_async_extract_content_from_response_none_content(self): + """Test async content extraction when message content is None.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = None + response.choices = [choice] + + content = client._extract_content_from_response(response) + + assert content == "" + + def test_async_extract_tool_calls_from_response(self): + """Test async tool call extraction from response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + tool_call = Mock() + tool_call.id = "call_123" + tool_call.function = Mock() + tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"x": 10}' + choice.message.tool_calls = [tool_call] + response.choices = [choice] + + tool_calls = client._extract_tool_calls_from_response(response) + + assert tool_calls is not None + assert len(tool_calls) == 1 + assert tool_calls[0].call_id == "call_123" + assert tool_calls[0].name == "test_tool" + assert tool_calls[0].arguments == '{"x": 10}' + + def test_async_extract_tool_calls_from_response_no_tool_calls(self): + """Test async tool call extraction when no tool calls present.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.tool_calls = None + response.choices = [choice] + + tool_calls = client._extract_tool_calls_from_response(response) + + assert tool_calls is None + + def test_async_extract_tool_calls_from_response_empty_choices(self): + """Test async tool call extraction with empty choices.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + response = Mock(spec=ChatCompletion) + response.choices = [] + + tool_calls = client._extract_tool_calls_from_response(response) + + assert tool_calls is None + + # ===== Async Message Update Tests ===== + + def test_async_update_messages_with_tool_calls(self): + """Test async updating messages with tool calls and results.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a response with tool calls + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = "I'll help with that calculation" + tool_call = Mock() + tool_call.id = "call_123" + tool_call.function = Mock() + tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"x": 10}' + choice.message.tool_calls = [tool_call] + response.choices = [choice] + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + assert len(updated) == 3 # original + assistant + tool result + assert updated[1]["role"] == "assistant" + assert updated[1]["content"] == "I'll help with that calculation" + assert updated[2]["role"] == "tool" + assert updated[2]["tool_call_id"] == "call_123" + + def test_async_update_messages_with_tool_calls_stream_response(self): + """Test async updating messages with tool calls for streaming response.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a streaming response (not ChatCompletion) + stream_event = Mock(spec=ChatCompletionChunk) + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, stream_event, tool_calls, tool_results + ) + + assert len(updated) == 3 # original + assistant + tool result + assert updated[1]["role"] == "assistant" + assert updated[1]["content"] is None # None for streaming + assert updated[2]["role"] == "tool" + assert updated[2]["tool_call_id"] == "call_123" + + def test_async_update_messages_with_tool_calls_error_result(self): + """Test async updating messages with tool calls when tool execution fails.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a response with tool calls + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = "I'll help with that calculation" + tool_call = Mock() + tool_call.id = "call_123" + tool_call.function = Mock() + tool_call.function.name = "test_tool" + tool_call.function.arguments = '{"x": 10}' + choice.message.tool_calls = [tool_call] + response.choices = [choice] + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", + name="test_tool", + arguments='{"x": 10}', + error="Tool failed", + is_error=True + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + assert len(updated) == 3 # original + assistant + tool result + assert updated[2]["role"] == "tool" + assert updated[2]["content"] == "Error: Tool failed" + assert updated[2]["tool_call_id"] == "call_123" + + def test_async_update_messages_with_tool_calls_response_no_tool_calls(self): + """Test async updating messages when response has no tool calls.""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a ChatCompletion response without tool calls + response = Mock(spec=ChatCompletion) + choice = Mock() + choice.message = Mock() + choice.message.content = "I'll help with that calculation" + choice.message.tool_calls = None # No tool calls + response.choices = [choice] + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + # When ChatCompletion response has no tool calls, the condition fails + # so no assistant message is added, only tool results + assert len(updated) == 2 # original + tool result only + assert updated[1]["role"] == "tool" + assert updated[1]["tool_call_id"] == "call_123" + + def test_async_update_messages_with_tool_calls_non_completion_response(self): + """Test async updating messages with non-ChatCompletion response (else branch).""" + tool_manager = self.create_tool_manager() + + with patch(self.mock_client_path): + client = self.client_class(api_key="test-key", tool_manager=tool_manager) + + messages = [{"role": "user", "content": "Hello"}] + + # Create a non-ChatCompletion response (e.g., streaming chunk) + response = Mock(spec=ChatCompletionChunk) # Not ChatCompletion + + tool_calls = [ToolCall(call_id="call_123", name="test_tool", arguments='{"x": 10}')] + tool_results = [ + ToolExecutionResult( + call_id="call_123", name="test_tool", arguments='{"x": 10}', result="Result: 10" + ) + ] + + updated = client._update_messages_with_tool_calls( + messages, response, tool_calls, tool_results + ) + + # Should go to else branch and add assistant message + tool results + assert len(updated) == 3 # original + assistant + tool result + assert updated[1]["role"] == "assistant" + assert updated[1]["content"] is None # None for streaming + assert updated[2]["role"] == "tool" + assert updated[2]["tool_call_id"] == "call_123" \ No newline at end of file diff --git a/tests/unit/test_chimeric.py b/tests/unit/test_chimeric.py index 0cd4798..86a7995 100644 --- a/tests/unit/test_chimeric.py +++ b/tests/unit/test_chimeric.py @@ -281,7 +281,7 @@ def test_environment_variable_initialization(self): assert chimeric.providers[Provider.GOOGLE].api_key == "env-google-key" def test_mixed_initialization(self): - """Test initialization with both explicit and environment variables.""" + """Test initialization with both explicit and environment variables using detect_from_env.""" env_vars = {"OPENAI_API_KEY": "env-openai-key"} with ( @@ -301,13 +301,83 @@ def test_mixed_initialization(self): }, ), ): - chimeric = Chimeric(anthropic_api_key="explicit-anthropic-key") + # With detect_from_env=True, should get both explicit and environment providers + chimeric = Chimeric(anthropic_api_key="explicit-anthropic-key", detect_from_env=True) assert len(chimeric.providers) == 2 # Explicit key should take precedence over environment assert chimeric.providers[Provider.ANTHROPIC].api_key == "explicit-anthropic-key" assert chimeric.providers[Provider.OPENAI].api_key == "env-openai-key" + def test_explicit_only_initialization(self): + """Test initialization with explicit keys only (no environment detection).""" + env_vars = {"OPENAI_API_KEY": "env-openai-key"} + + with ( + patch.dict(os.environ, env_vars), + patch.dict( + PROVIDER_CLIENTS, + { + Provider.OPENAI: MockProviderClient, + Provider.ANTHROPIC: MockProviderClient, + }, + ), + patch.dict( + ASYNC_PROVIDER_CLIENTS, + { + Provider.OPENAI: MockAsyncProviderClient, + Provider.ANTHROPIC: MockAsyncProviderClient, + }, + ), + ): + # Without detect_from_env, should only get explicitly provided providers + chimeric = Chimeric(anthropic_api_key="explicit-anthropic-key") + + assert len(chimeric.providers) == 1 + # Only the explicitly provided provider should be initialized + assert Provider.ANTHROPIC in chimeric.providers + assert Provider.OPENAI not in chimeric.providers + assert chimeric.providers[Provider.ANTHROPIC].api_key == "explicit-anthropic-key" + + def test_detect_from_env_parameter(self): + """Test the detect_from_env parameter behavior.""" + env_vars = { + "OPENAI_API_KEY": "env-openai-key", + "ANTHROPIC_API_KEY": "env-anthropic-key" + } + + with ( + patch.dict(os.environ, env_vars), + patch.dict( + PROVIDER_CLIENTS, + { + Provider.OPENAI: MockProviderClient, + Provider.ANTHROPIC: MockProviderClient, + }, + ), + patch.dict( + ASYNC_PROVIDER_CLIENTS, + { + Provider.OPENAI: MockAsyncProviderClient, + Provider.ANTHROPIC: MockAsyncProviderClient, + }, + ), + ): + # Test detect_from_env=False (default behavior) + chimeric1 = Chimeric(openai_api_key="explicit-openai-key", detect_from_env=False) + assert len(chimeric1.providers) == 1 + assert Provider.OPENAI in chimeric1.providers + assert Provider.ANTHROPIC not in chimeric1.providers + + # Test detect_from_env=True + chimeric2 = Chimeric(openai_api_key="explicit-openai-key", detect_from_env=True) + assert len(chimeric2.providers) == 2 + assert Provider.OPENAI in chimeric2.providers + assert Provider.ANTHROPIC in chimeric2.providers + # Explicit key should take precedence + assert chimeric2.providers[Provider.OPENAI].api_key == "explicit-openai-key" + assert chimeric2.providers[Provider.ANTHROPIC].api_key == "env-anthropic-key" + def test_alternative_environment_variables(self): """Test initialization with alternative environment variable names.""" env_vars = { diff --git a/uv.lock b/uv.lock index 5bbba31..2f77b6b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <4.0" resolution-markers = [ "platform_python_implementation != 'PyPy'", @@ -325,6 +325,9 @@ groq = [ openai = [ { name = "openai" }, ] +openrouter = [ + { name = "openai" }, +] [package.dev-dependencies] dev = [ @@ -364,11 +367,12 @@ requires-dist = [ { name = "groq", marker = "extra == 'groq'", specifier = ">=0.4.0" }, { name = "openai", marker = "extra == 'all'", specifier = ">=1.84.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.84.0" }, + { name = "openai", marker = "extra == 'openrouter'", specifier = ">=1.84.0" }, { name = "pydantic", specifier = ">=2.11.5" }, { name = "xai-sdk", marker = "extra == 'all'", specifier = ">=1.0.0" }, { name = "xai-sdk", marker = "extra == 'grok'", specifier = ">=1.0.0" }, ] -provides-extras = ["all", "anthropic", "cerebras", "cohere", "google", "grok", "groq", "openai"] +provides-extras = ["all", "anthropic", "cerebras", "cohere", "google", "grok", "groq", "openai", "openrouter"] [package.metadata.requires-dev] dev = [