diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6a7023 --- /dev/null +++ b/.gitignore @@ -0,0 +1,134 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/API_PROXY.md b/API_PROXY.md new file mode 100644 index 0000000..d468afd --- /dev/null +++ b/API_PROXY.md @@ -0,0 +1,505 @@ +# Claude API Proxy 🚀 + +**OpenAI-compatible API using your Claude subscription instead of expensive API keys!** + +The Claude API Proxy converts your Claude subscription into an OpenAI-compatible REST API, allowing you to use tools like Claude Code, LangChain, and other AI frameworks **without paying for API access**. + +## 🎯 Why Use This? + +| Scenario | Solution | +|----------|----------| +| ❌ You have Claude subscription but no API access | ✅ Use subscription via API proxy | +| ❌ API keys are too expensive for your use case | ✅ Use subscription pricing instead | +| ❌ Tools require OpenAI-compatible API | ✅ Proxy provides compatible interface | +| ❌ Want to control Claude Code remotely | ✅ API proxy enables remote access | + +## 🚀 Quick Start + +### 1. Install + +```bash +pip install claude-cli-auth +``` + +### 2. Authenticate + +```bash +# One-time authentication with Claude +claude auth login +``` + +### 3. Start the Proxy + +```bash +# Start on localhost:8000 +claude-api-proxy --port 8000 + +# Or bind to all interfaces for remote access +claude-api-proxy --host 0.0.0.0 --port 8000 + +# With custom timeout +claude-api-proxy --port 8000 --timeout 300 +``` + +### 4. Use It! + +```bash +# Test with curl +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4", + "messages": [{"role": "user", "content": "Hello!"}], + "stream": false + }' +``` + +## 📚 Usage Examples + +### With OpenAI Python Client + +```python +from openai import OpenAI + +# Point to your proxy instead of OpenAI +client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy-key" # Not validated, but required by client +) + +# Use exactly like OpenAI! +response = client.chat.completions.create( + model="claude-sonnet-4", + messages=[ + {"role": "user", "content": "Explain quantum computing"} + ] +) + +print(response.choices[0].message.content) +``` + +### Streaming Responses + +```python +# Streaming works too! +stream = client.chat.completions.create( + model="claude-sonnet-4", + messages=[ + {"role": "user", "content": "Write a Python function for fibonacci"} + ], + stream=True +) + +for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="") +``` + +### With LangChain + +```python +from langchain_openai import ChatOpenAI + +# Initialize LangChain with your proxy +llm = ChatOpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy-key", + model="claude-sonnet-4" +) + +# Use as normal +response = llm.invoke("Explain machine learning") +print(response.content) + +# Streaming +for chunk in llm.stream("Write a poem"): + print(chunk.content, end="") +``` + +### With Claude Code + +To use with Claude Code or other tools that expect Anthropic API: + +```bash +# Set environment variables +export ANTHROPIC_API_URL=http://localhost:8000/v1 +export ANTHROPIC_API_KEY=dummy-key + +# Now use Claude Code as normal! +``` + +## 🔧 API Endpoints + +### Chat Completions + +**POST** `/v1/chat/completions` + +OpenAI-compatible chat completions endpoint. + +**Request:** +```json +{ + "model": "claude-sonnet-4", + "messages": [ + {"role": "user", "content": "Hello!"} + ], + "stream": false, + "temperature": 1.0, + "max_tokens": null, + "user": "optional-user-id" +} +``` + +**Response (non-streaming):** +```json +{ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "created": 1699564800, + "model": "claude-sonnet-4", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you today?" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + } +} +``` + +**Response (streaming):** +``` +data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1699564800,"model":"claude-sonnet-4","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1699564800,"model":"claude-sonnet-4","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1699564800,"model":"claude-sonnet-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] +``` + +### List Models + +**GET** `/v1/models` + +List available models (OpenAI-compatible format). + +**Response:** +```json +{ + "object": "list", + "data": [ + { + "id": "claude-sonnet-4", + "object": "model", + "created": 1699564800, + "owned_by": "anthropic" + }, + { + "id": "claude-opus-4", + "object": "model", + "created": 1699564800, + "owned_by": "anthropic" + } + ] +} +``` + +### Health Check + +**GET** `/health` + +Check proxy health and statistics. + +**Response:** +```json +{ + "status": "healthy", + "claude_authenticated": true, + "stats": { + "total_requests": 42, + "streaming_requests": 15, + "failed_requests": 2, + "total_cost": 0.125 + }, + "active_requests": 0 +} +``` + +## 🔒 Security Considerations + +### Local Development +```bash +# Safe: Only accessible from your machine +claude-api-proxy --host 127.0.0.1 --port 8000 +``` + +### Remote Access +```bash +# Warning: Accessible from anywhere! +# Only use on trusted networks or with additional security +claude-api-proxy --host 0.0.0.0 --port 8000 +``` + +**Important Security Notes:** +- ⚠️ The proxy does **NOT** validate API keys (uses subscription auth) +- ⚠️ Binding to `0.0.0.0` exposes the proxy to your network +- ⚠️ Use firewall rules or VPN for remote access +- ✅ Consider running behind nginx with authentication +- ✅ Use SSH tunneling for secure remote access + +### Secure Remote Access (SSH Tunnel) + +```bash +# On remote server +claude-api-proxy --host 127.0.0.1 --port 8000 + +# On local machine +ssh -L 8000:localhost:8000 user@remote-server + +# Now use http://localhost:8000 locally! +``` + +## 🎛️ Configuration + +### Command-Line Options + +```bash +claude-api-proxy \ + --host 0.0.0.0 \ # Host to bind to (default: 127.0.0.1) + --port 8000 \ # Port to bind to (default: 8000) + --timeout 300 \ # Query timeout in seconds (default: 120) + --debug # Enable debug logging +``` + +### Environment Variables + +The proxy respects Claude CLI configuration: + +```bash +# Custom Claude CLI path +export CLAUDE_CLI_PATH=/custom/path/to/claude + +# Custom config directory +export CLAUDE_CONFIG_DIR=~/.claude-custom +``` + +## 📊 Monitoring + +### API Documentation + +The proxy provides interactive API documentation: + +``` +http://localhost:8000/docs # Swagger UI +http://localhost:8000/redoc # ReDoc +``` + +### Health Monitoring + +```bash +# Check health +curl http://localhost:8000/health + +# Watch health in real-time +watch -n 1 'curl -s http://localhost:8000/health | jq' +``` + +### Logging + +```bash +# Enable debug logging +claude-api-proxy --debug --port 8000 + +# Logs include: +# - Request/response details +# - Claude CLI execution +# - Streaming events +# - Error details +# - Performance metrics +``` + +## 🐛 Troubleshooting + +### Proxy won't start + +**Error:** `Claude CLI is not authenticated` +```bash +# Solution: Authenticate with Claude +claude auth login +``` + +**Error:** `Address already in use` +```bash +# Solution: Use different port +claude-api-proxy --port 8001 +``` + +### Requests failing + +**Error:** `503 Service Unavailable` +```bash +# Check health +curl http://localhost:8000/health + +# Check Claude authentication +claude auth status +``` + +**Error:** Timeout errors +```bash +# Increase timeout +claude-api-proxy --timeout 300 +``` + +### Streaming not working + +**Issue:** Stream not showing real-time updates + +Most likely cause: The Claude CLI doesn't support real-time streaming in all cases. The proxy will still work, but updates may come in larger chunks rather than token-by-token. + +**Workaround:** Use non-streaming mode if real-time updates aren't critical. + +## 🔬 Advanced Usage + +### Programmatic Usage + +```python +from claude_cli_auth.api_proxy import ClaudeAPIProxy +from claude_cli_auth import AuthConfig + +# Custom configuration +config = AuthConfig( + timeout_seconds=300, + max_turns=20, +) + +# Initialize proxy +proxy = ClaudeAPIProxy( + config=config, + host="127.0.0.1", + port=8000 +) + +# Use programmatically +from claude_cli_auth.api_proxy import ChatCompletionRequest, ChatMessage + +request = ChatCompletionRequest( + model="claude-sonnet-4", + messages=[ChatMessage(role="user", content="Hello!")] +) + +response = await proxy.handle_chat_completion(request) +print(response.choices[0].message.content) + +# Cleanup +await proxy.shutdown() +``` + +### Running as a Service + +#### systemd (Linux) + +Create `/etc/systemd/system/claude-api-proxy.service`: + +```ini +[Unit] +Description=Claude API Proxy +After=network.target + +[Service] +Type=simple +User=youruser +WorkingDirectory=/home/youruser +ExecStart=/usr/local/bin/claude-api-proxy --host 127.0.0.1 --port 8000 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Enable and start +sudo systemctl enable claude-api-proxy +sudo systemctl start claude-api-proxy + +# Check status +sudo systemctl status claude-api-proxy +``` + +#### Docker + +```dockerfile +FROM python:3.11-slim + +# Install Node.js for Claude CLI +RUN apt-get update && apt-get install -y nodejs npm + +# Install Claude CLI +RUN npm install -g @anthropic-ai/claude-code + +# Install Python package +RUN pip install claude-cli-auth + +# Authenticate (you'll need to handle this) +# COPY .claude /root/.claude + +EXPOSE 8000 + +CMD ["claude-api-proxy", "--host", "0.0.0.0", "--port", "8000"] +``` + +## 💡 Use Cases + +### 1. Remote Claude Code Access +Access Claude Code from anywhere using the API proxy: +```bash +# On server +claude-api-proxy --host 0.0.0.0 --port 8000 + +# From anywhere +export ANTHROPIC_API_URL=http://server:8000/v1 +claude-code +``` + +### 2. Cost Savings +Use subscription pricing instead of API pricing: +- Claude Pro: ~$20/month unlimited +- Claude API: $3-15 per million tokens +- **Savings:** Potentially thousands of dollars for heavy users + +### 3. Team Access +Share your subscription with your team (within terms of service): +```bash +# Central proxy server +claude-api-proxy --host 0.0.0.0 --port 8000 + +# Team members connect +export ANTHROPIC_API_URL=http://team-server:8000/v1 +``` + +### 4. Integration Testing +Test AI integrations without API costs: +```python +# In tests +client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="test" +) + +# Test your code without API charges! +``` + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +**Built with** ❤️ **for developers who want to use Claude without breaking the bank!** 💰 diff --git a/README.md b/README.md index 69cfa7d..bc88552 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ A production-ready Python module for **Claude AI integration without API keys**. ## 🎯 Key Features - **🔑 No API Keys Required** - Uses `claude auth login` authentication -- **💳 Subscription Compatible** - Works with Claude subscription instead of API access +- **💳 Subscription Compatible** - Works with Claude subscription instead of API access +- **🌐 OpenAI-Compatible API Proxy** - Expose your subscription as an OpenAI-compatible REST API - **🔄 Triple Fallback System** - SDK → CLI → Graceful error handling - **💾 Session Management** - Persistent conversations and context - **⚡ Production Ready** - Comprehensive error handling and logging @@ -270,6 +271,67 @@ result = await ask_claude("Explain machine learning in simple terms") print(result) ``` +## 🌐 OpenAI-Compatible API Proxy + +**NEW!** Use your Claude subscription as an OpenAI-compatible REST API! + +### Why Use the API Proxy? + +- ✅ Use subscription pricing instead of expensive API rates +- ✅ Compatible with OpenAI client libraries and tools +- ✅ Enable remote access to Claude Code +- ✅ Integrate with LangChain, AutoGPT, and other frameworks + +### Quick Start + +```bash +# Start the proxy server +claude-api-proxy --port 8000 + +# Use with any OpenAI-compatible client +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +### With OpenAI Python Client + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy-key" # Not validated +) + +response = client.chat.completions.create( + model="claude-sonnet-4", + messages=[{"role": "user", "content": "Hello!"}] +) + +print(response.choices[0].message.content) +``` + +### With LangChain + +```python +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy-key", + model="claude-sonnet-4" +) + +response = llm.invoke("Explain quantum computing") +print(response.content) +``` + +**📖 Full documentation:** [API_PROXY.md](API_PROXY.md) + ## 🔒 Security & Privacy - **No API Keys Stored** - Uses secure Claude CLI authentication diff --git a/examples/api_proxy_usage.py b/examples/api_proxy_usage.py new file mode 100644 index 0000000..580d141 --- /dev/null +++ b/examples/api_proxy_usage.py @@ -0,0 +1,315 @@ +"""Example usage of Claude API Proxy. + +This example demonstrates how to use the Claude API Proxy to access Claude +via subscription instead of API keys, using OpenAI-compatible interfaces. +""" + +import asyncio +from pathlib import Path + + +# ===== Example 1: Start the proxy server ===== +def example_start_server(): + """Example: Start the API proxy server.""" + print("=" * 60) + print("Example 1: Start the API Proxy Server") + print("=" * 60) + print() + print("To start the proxy server, run:") + print() + print(" claude-api-proxy --host 0.0.0.0 --port 8000") + print() + print("Or with custom timeout:") + print() + print(" claude-api-proxy --host 0.0.0.0 --port 8000 --timeout 300") + print() + print("The server will be available at:") + print(" - API endpoint: http://localhost:8000/v1/chat/completions") + print(" - Health check: http://localhost:8000/health") + print(" - API docs: http://localhost:8000/docs") + print() + + +# ===== Example 2: Use with curl ===== +def example_curl_usage(): + """Example: Use the proxy with curl.""" + print("=" * 60) + print("Example 2: Use with curl") + print("=" * 60) + print() + print("Non-streaming request:") + print() + print("""curl http://localhost:8000/v1/chat/completions \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "claude-sonnet-4", + "messages": [ + {"role": "user", "content": "Hello! How are you?"} + ], + "stream": false + }'""") + print() + print("Streaming request:") + print() + print("""curl http://localhost:8000/v1/chat/completions \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "claude-sonnet-4", + "messages": [ + {"role": "user", "content": "Write a Python function to calculate fibonacci"} + ], + "stream": true + }'""") + print() + + +# ===== Example 3: Use with OpenAI Python client ===== +def example_openai_client(): + """Example: Use the proxy with OpenAI Python client.""" + print("=" * 60) + print("Example 3: Use with OpenAI Python Client") + print("=" * 60) + print() + print("First, install the OpenAI client:") + print() + print(" pip install openai") + print() + print("Then use it like this:") + print() + print("""from openai import OpenAI + +# Initialize client pointing to your proxy +client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy-key" # Not validated, but required by client +) + +# Non-streaming request +response = client.chat.completions.create( + model="claude-sonnet-4", + messages=[ + {"role": "user", "content": "Hello! How are you?"} + ] +) + +print(response.choices[0].message.content) + +# Streaming request +stream = client.chat.completions.create( + model="claude-sonnet-4", + messages=[ + {"role": "user", "content": "Write a Python function"} + ], + stream=True +) + +for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="") +""") + print() + + +# ===== Example 4: Use with Claude Code ===== +def example_claude_code(): + """Example: Use the proxy with Claude Code.""" + print("=" * 60) + print("Example 4: Use with Claude Code") + print("=" * 60) + print() + print("To use the proxy with Claude Code, you need to configure it") + print("to use a custom API endpoint.") + print() + print("1. Start the proxy server:") + print(" claude-api-proxy --host 0.0.0.0 --port 8000") + print() + print("2. Configure Claude Code to use custom endpoint:") + print(" (This depends on how Claude Code is configured)") + print() + print(" You might need to set environment variables like:") + print(" export ANTHROPIC_API_URL=http://localhost:8000/v1") + print(" export ANTHROPIC_API_KEY=dummy-key") + print() + print("3. Use Claude Code as normal!") + print() + + +# ===== Example 5: Use with LangChain ===== +def example_langchain(): + """Example: Use the proxy with LangChain.""" + print("=" * 60) + print("Example 5: Use with LangChain") + print("=" * 60) + print() + print("You can use the proxy with LangChain by using the OpenAI integration:") + print() + print("""from langchain_openai import ChatOpenAI + +# Initialize LangChain with your proxy +llm = ChatOpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy-key", + model="claude-sonnet-4" +) + +# Use as normal +response = llm.invoke("Hello! How are you?") +print(response.content) + +# Streaming +for chunk in llm.stream("Write a Python function"): + print(chunk.content, end="") +""") + print() + + +# ===== Example 6: Programmatic usage ===== +async def example_programmatic(): + """Example: Programmatic usage of the proxy.""" + print("=" * 60) + print("Example 6: Programmatic Usage") + print("=" * 60) + print() + print("You can also use the proxy programmatically in your Python code:") + print() + print("""from claude_cli_auth import ClaudeAuthManager +from claude_cli_auth.api_proxy import ClaudeAPIProxy, ChatCompletionRequest, ChatMessage + +# Initialize proxy +proxy = ClaudeAPIProxy( + host="127.0.0.1", + port=8000 +) + +# Create request +request = ChatCompletionRequest( + model="claude-sonnet-4", + messages=[ + ChatMessage(role="user", content="Hello!") + ], + stream=False +) + +# Handle request +response = await proxy.handle_chat_completion(request) + +print(response.choices[0].message.content) + +# Shutdown +await proxy.shutdown() +""") + print() + + +# ===== Example 7: Health check ===== +def example_health_check(): + """Example: Health check endpoint.""" + print("=" * 60) + print("Example 7: Health Check") + print("=" * 60) + print() + print("Check if the proxy is healthy:") + print() + print(" curl http://localhost:8000/health") + print() + print("Example response:") + print("""{ + "status": "healthy", + "claude_authenticated": true, + "stats": { + "total_requests": 42, + "streaming_requests": 15, + "failed_requests": 2, + "total_cost": 0.125 + }, + "active_requests": 0 +}""") + print() + + +# ===== Example 8: List models ===== +def example_list_models(): + """Example: List available models.""" + print("=" * 60) + print("Example 8: List Available Models") + print("=" * 60) + print() + print("Get list of available models:") + print() + print(" curl http://localhost:8000/v1/models") + print() + print("Example response:") + print("""{ + "object": "list", + "data": [ + { + "id": "claude-sonnet-4", + "object": "model", + "created": 1699564800, + "owned_by": "anthropic" + }, + { + "id": "claude-opus-4", + "object": "model", + "created": 1699564800, + "owned_by": "anthropic" + } + ] +}""") + print() + + +# ===== Main ===== +def main(): + """Run all examples.""" + print() + print("╔" + "═" * 58 + "╗") + print("║" + " " * 10 + "Claude API Proxy - Usage Examples" + " " * 15 + "║") + print("╚" + "═" * 58 + "╝") + print() + + example_start_server() + input("Press Enter to continue...") + print() + + example_curl_usage() + input("Press Enter to continue...") + print() + + example_openai_client() + input("Press Enter to continue...") + print() + + example_claude_code() + input("Press Enter to continue...") + print() + + example_langchain() + input("Press Enter to continue...") + print() + + asyncio.run(example_programmatic()) + input("Press Enter to continue...") + print() + + example_health_check() + input("Press Enter to continue...") + print() + + example_list_models() + print() + + print("=" * 60) + print("All examples shown!") + print("=" * 60) + print() + print("To get started:") + print(" 1. Install: pip install claude-cli-auth") + print(" 2. Authenticate: claude auth login") + print(" 3. Start proxy: claude-api-proxy --port 8000") + print(" 4. Use with any OpenAI-compatible tool!") + print() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index e73e090..702a2a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ python = "^3.8.1" pydantic = "^2.0.0" aiofiles = "^23.0.0" tenacity = "^8.0.0" +fastapi = "^0.104.0" +uvicorn = {extras = ["standard"], version = "^0.24.0"} +sse-starlette = "^1.8.0" [tool.poetry.group.dev.dependencies] pytest = "^7.0.0" @@ -45,6 +48,7 @@ mypy = "^1.0.0" [tool.poetry.scripts] claude-cli-auth = "claude_cli_auth.cli:main" +claude-api-proxy = "claude_cli_auth.api_proxy:main" [tool.black] line-length = 88 diff --git a/src/claude_cli_auth/__init__.py b/src/claude_cli_auth/__init__.py index 813df2a..3347d2e 100644 --- a/src/claude_cli_auth/__init__.py +++ b/src/claude_cli_auth/__init__.py @@ -62,6 +62,16 @@ from .models import AuthConfig, ClaudeResponse, SessionInfo, StreamUpdate from .sdk_interface import SDKInterface +# API Proxy is optional - only import if FastAPI is available +try: + from .api_proxy import ClaudeAPIProxy, ChatCompletionRequest, ChatMessage + _API_PROXY_AVAILABLE = True +except ImportError: + _API_PROXY_AVAILABLE = False + ClaudeAPIProxy = None # type: ignore + ChatCompletionRequest = None # type: ignore + ChatMessage = None # type: ignore + # Version info __version__ = "1.0.0" __author__ = "David Strejc" @@ -72,22 +82,27 @@ __all__ = [ # Main interface "ClaudeAuthManager", - + # Core components - "AuthManager", + "AuthManager", "CLIInterface", "SDKInterface", - + + # API Proxy (optional) + "ClaudeAPIProxy", + "ChatCompletionRequest", + "ChatMessage", + # Models "AuthConfig", "ClaudeResponse", - "CLIResponse", + "CLIResponse", "SessionInfo", "StreamUpdate", - + # Exceptions "ClaudeAuthError", - "ClaudeAuthManagerError", + "ClaudeAuthManagerError", "ClaudeCLIError", "ClaudeConfigError", "ClaudeNetworkError", @@ -96,10 +111,10 @@ "ClaudeSessionError", "ClaudeTimeoutError", "ClaudeToolValidationError", - + # Metadata "__version__", - "__author__", + "__author__", "__email__", "__license__", ] diff --git a/src/claude_cli_auth/api_proxy.py b/src/claude_cli_auth/api_proxy.py new file mode 100644 index 0000000..7470428 --- /dev/null +++ b/src/claude_cli_auth/api_proxy.py @@ -0,0 +1,701 @@ +"""OpenAI-compatible API proxy for Claude subscription access. + +This module provides an OpenAI-compatible REST API that uses Claude subscription +authentication instead of API keys. It allows tools like Claude Code to connect +using your subscription instead of paying for API access. + +Features: +- OpenAI-compatible /v1/chat/completions endpoint +- Streaming support (SSE) +- Session management +- Cost tracking +- Multiple concurrent requests +- Health checks + +Example usage: + Start the server: + ```bash + claude-api-proxy --port 8000 --host 0.0.0.0 + ``` + + Use with curl: + ```bash + curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4", + "messages": [{"role": "user", "content": "Hello!"}], + "stream": false + }' + ``` + + Use with OpenAI client: + ```python + from openai import OpenAI + + client = OpenAI( + base_url="http://localhost:8000/v1", + api_key="dummy-key" # Not validated, but required by client + ) + + response = client.chat.completions.create( + model="claude-sonnet-4", + messages=[{"role": "user", "content": "Hello!"}] + ) + ``` +""" + +import argparse +import asyncio +import json +import logging +import time +import uuid +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Dict, List, Optional + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, Field +from sse_starlette.sse import EventSourceResponse + +from .facade import ClaudeAuthManager +from .models import AuthConfig, StreamType, StreamUpdate + +logger = logging.getLogger(__name__) + + +# ===== OpenAI-compatible Request/Response Models ===== + + +class ChatMessage(BaseModel): + """Chat message in OpenAI format.""" + + role: str = Field(..., description="Role: system, user, or assistant") + content: str = Field(..., description="Message content") + name: Optional[str] = Field(None, description="Optional name of the message author") + + +class ChatCompletionRequest(BaseModel): + """OpenAI-compatible chat completion request.""" + + model: str = Field( + default="claude-sonnet-4", + description="Model to use (mapped to Claude models)" + ) + messages: List[ChatMessage] = Field(..., description="List of messages") + temperature: Optional[float] = Field( + default=1.0, + ge=0.0, + le=2.0, + description="Sampling temperature" + ) + max_tokens: Optional[int] = Field( + default=None, + ge=1, + description="Maximum tokens to generate" + ) + stream: bool = Field(default=False, description="Whether to stream responses") + user: Optional[str] = Field(None, description="User identifier for tracking") + + +class ChatCompletionChoice(BaseModel): + """Single completion choice.""" + + index: int + message: ChatMessage + finish_reason: str # "stop", "length", "error" + + +class ChatCompletionUsage(BaseModel): + """Token usage information.""" + + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + +class ChatCompletionResponse(BaseModel): + """OpenAI-compatible chat completion response.""" + + id: str + object: str = "chat.completion" + created: int + model: str + choices: List[ChatCompletionChoice] + usage: Optional[ChatCompletionUsage] = None + + +class ChatCompletionChunk(BaseModel): + """Streaming chat completion chunk.""" + + id: str + object: str = "chat.completion.chunk" + created: int + model: str + choices: List[Dict[str, Any]] + + +class ErrorResponse(BaseModel): + """Error response.""" + + error: Dict[str, Any] + + +# ===== Format Converters ===== + + +class FormatConverter: + """Convert between OpenAI and Claude formats.""" + + @staticmethod + def openai_to_claude_prompt(messages: List[ChatMessage]) -> str: + """Convert OpenAI messages to Claude prompt. + + Args: + messages: List of OpenAI chat messages + + Returns: + Combined prompt string for Claude + """ + # Combine all messages into a single prompt + # Claude CLI works best with a single prompt string + prompt_parts = [] + + for msg in messages: + role = msg.role + content = msg.content + + if role == "system": + prompt_parts.append(f"System Instructions: {content}") + elif role == "user": + prompt_parts.append(f"User: {content}") + elif role == "assistant": + prompt_parts.append(f"Assistant: {content}") + + return "\n\n".join(prompt_parts) + + @staticmethod + def claude_to_openai_response( + claude_content: str, + request_id: str, + model: str, + cost: float = 0.0, + ) -> ChatCompletionResponse: + """Convert Claude response to OpenAI format. + + Args: + claude_content: Response content from Claude + request_id: Request identifier + model: Model name used + cost: Cost in USD + + Returns: + OpenAI-compatible response + """ + # Estimate tokens from cost (approximate) + # Claude Sonnet costs roughly $3/M input, $15/M output + estimated_tokens = int(cost * 100000) if cost > 0 else 0 + + return ChatCompletionResponse( + id=request_id, + object="chat.completion", + created=int(time.time()), + model=model, + choices=[ + ChatCompletionChoice( + index=0, + message=ChatMessage( + role="assistant", + content=claude_content + ), + finish_reason="stop" + ) + ], + usage=ChatCompletionUsage( + prompt_tokens=estimated_tokens // 3, + completion_tokens=estimated_tokens * 2 // 3, + total_tokens=estimated_tokens, + ) + ) + + @staticmethod + def stream_update_to_chunk( + update: StreamUpdate, + request_id: str, + model: str, + ) -> Optional[ChatCompletionChunk]: + """Convert Claude stream update to OpenAI chunk. + + Args: + update: Claude stream update + request_id: Request identifier + model: Model name + + Returns: + OpenAI-compatible chunk or None if not relevant + """ + # Only convert assistant messages to chunks + if update.type != StreamType.ASSISTANT: + return None + + if not update.content: + return None + + return ChatCompletionChunk( + id=request_id, + object="chat.completion.chunk", + created=int(time.time()), + model=model, + choices=[{ + "index": 0, + "delta": {"content": update.content}, + "finish_reason": None + }] + ) + + +# ===== API Proxy Server ===== + + +class ClaudeAPIProxy: + """OpenAI-compatible API proxy for Claude subscription access.""" + + def __init__( + self, + config: Optional[AuthConfig] = None, + host: str = "127.0.0.1", + port: int = 8000, + ): + """Initialize API proxy. + + Args: + config: Claude authentication configuration + host: Host to bind to + port: Port to bind to + """ + self.config = config or AuthConfig() + self.host = host + self.port = port + + # Initialize Claude manager + self.claude = ClaudeAuthManager(config=self.config) + + # Request tracking + self.active_requests: Dict[str, asyncio.Task] = {} + + # Stats + self.stats = { + "total_requests": 0, + "streaming_requests": 0, + "failed_requests": 0, + "total_cost": 0.0, + } + + logger.info( + f"ClaudeAPIProxy initialized - host: {host}, port: {port}, " + f"config: {self.config.to_dict()}" + ) + + async def handle_chat_completion( + self, + request: ChatCompletionRequest, + ): + """Handle chat completion request. + + Args: + request: Chat completion request + + Returns: + Response or streaming response (ChatCompletionResponse or StreamingResponse) + + Raises: + HTTPException: If request fails + """ + request_id = f"chatcmpl-{uuid.uuid4()}" + + logger.info( + f"Handling chat completion - request_id: {request_id}, " + f"model: {request.model}, stream: {request.stream}, " + f"messages: {len(request.messages)}, user: {request.user}" + ) + + # Update stats + self.stats["total_requests"] += 1 + if request.stream: + self.stats["streaming_requests"] += 1 + + try: + # Convert OpenAI format to Claude prompt + prompt = FormatConverter.openai_to_claude_prompt(request.messages) + + # Handle streaming vs non-streaming + if request.stream: + return await self._handle_streaming( + prompt=prompt, + request_id=request_id, + model=request.model, + user=request.user, + ) + else: + return await self._handle_non_streaming( + prompt=prompt, + request_id=request_id, + model=request.model, + user=request.user, + ) + + except Exception as e: + self.stats["failed_requests"] += 1 + logger.error( + f"Chat completion failed - request_id: {request_id}, " + f"error: {str(e)}, error_type: {type(e).__name__}" + ) + + raise HTTPException( + status_code=500, + detail={ + "error": { + "message": str(e), + "type": type(e).__name__, + "code": "internal_error" + } + } + ) + + async def _handle_non_streaming( + self, + prompt: str, + request_id: str, + model: str, + user: Optional[str] = None, + ) -> ChatCompletionResponse: + """Handle non-streaming request. + + Args: + prompt: Claude prompt + request_id: Request identifier + model: Model name + user: User identifier + + Returns: + Chat completion response + """ + # Execute Claude query + response = await self.claude.query( + prompt=prompt, + user_id=user, + ) + + # Update stats + self.stats["total_cost"] += response.cost + + # Convert to OpenAI format + return FormatConverter.claude_to_openai_response( + claude_content=response.content, + request_id=request_id, + model=model, + cost=response.cost, + ) + + async def _handle_streaming( + self, + prompt: str, + request_id: str, + model: str, + user: Optional[str] = None, + ) -> StreamingResponse: + """Handle streaming request. + + Args: + prompt: Claude prompt + request_id: Request identifier + model: Model name + user: User identifier + + Returns: + Streaming response + """ + # Use a queue to pass updates from callback to generator + update_queue: asyncio.Queue = asyncio.Queue() + + async def stream_callback(update: StreamUpdate) -> None: + """Handle stream updates from Claude.""" + await update_queue.put(update) + + async def event_generator() -> AsyncIterator[str]: + """Generate SSE events.""" + try: + # Start Claude query task + query_task = asyncio.create_task( + self.claude.query( + prompt=prompt, + user_id=user, + stream_callback=stream_callback, + ) + ) + + # Process updates as they arrive + while True: + try: + # Wait for update with timeout + update = await asyncio.wait_for( + update_queue.get(), + timeout=0.1 + ) + + # Convert to OpenAI chunk + chunk = FormatConverter.stream_update_to_chunk( + update=update, + request_id=request_id, + model=model, + ) + + if chunk: + yield f"data: {chunk.model_dump_json()}\n\n" + + except asyncio.TimeoutError: + # Check if query is done + if query_task.done(): + break + continue + + # Wait for final result + response = await query_task + + # Update stats + self.stats["total_cost"] += response.cost + + # Send final chunk with finish_reason + final_chunk = ChatCompletionChunk( + id=request_id, + object="chat.completion.chunk", + created=int(time.time()), + model=model, + choices=[{ + "index": 0, + "delta": {}, + "finish_reason": "stop" + }] + ) + + yield f"data: {final_chunk.model_dump_json()}\n\n" + yield "data: [DONE]\n\n" + + except Exception as e: + logger.error( + f"Streaming error - request_id: {request_id}, error: {str(e)}" + ) + + # Send error chunk + error_chunk = { + "error": { + "message": str(e), + "type": type(e).__name__, + "code": "stream_error" + } + } + yield f"data: {json.dumps(error_chunk)}\n\n" + + return EventSourceResponse(event_generator()) + + async def get_health(self) -> Dict[str, Any]: + """Get health status. + + Returns: + Health status dictionary + """ + return { + "status": "healthy" if self.claude.is_healthy() else "unhealthy", + "claude_authenticated": self.claude.auth_manager.is_authenticated(), + "stats": self.stats, + "active_requests": len(self.active_requests), + } + + async def shutdown(self) -> None: + """Shutdown the proxy.""" + logger.info("Shutting down ClaudeAPIProxy") + + # Cancel active requests + for task in self.active_requests.values(): + task.cancel() + + # Shutdown Claude manager + await self.claude.shutdown() + + logger.info("ClaudeAPIProxy shutdown complete") + + +# ===== FastAPI Application ===== + + +# Global proxy instance +proxy: Optional[ClaudeAPIProxy] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage application lifespan.""" + global proxy + + # Startup + logger.info("Starting ClaudeAPIProxy server") + yield + + # Shutdown + if proxy: + await proxy.shutdown() + + +app = FastAPI( + title="Claude API Proxy", + description="OpenAI-compatible API proxy for Claude subscription access", + version="1.0.0", + lifespan=lifespan, +) + + +@app.post("/v1/chat/completions", response_model=None) +async def chat_completions( + request: ChatCompletionRequest, +): + """OpenAI-compatible chat completions endpoint. + + Args: + request: Chat completion request + + Returns: + Chat completion response or streaming response + """ + global proxy + + if not proxy: + raise HTTPException( + status_code=503, + detail="Proxy not initialized" + ) + + return await proxy.handle_chat_completion(request) + + +@app.get("/health") +async def health() -> Dict[str, Any]: + """Health check endpoint. + + Returns: + Health status + """ + global proxy + + if not proxy: + return { + "status": "unhealthy", + "error": "Proxy not initialized" + } + + return await proxy.get_health() + + +@app.get("/v1/models") +async def list_models() -> Dict[str, Any]: + """List available models (OpenAI-compatible). + + Returns: + List of models + """ + return { + "object": "list", + "data": [ + { + "id": "claude-sonnet-4", + "object": "model", + "created": int(time.time()), + "owned_by": "anthropic", + }, + { + "id": "claude-opus-4", + "object": "model", + "created": int(time.time()), + "owned_by": "anthropic", + }, + ] + } + + +# ===== CLI ===== + + +def main() -> None: + """Main entry point for API proxy CLI.""" + parser = argparse.ArgumentParser( + description="Claude API Proxy - OpenAI-compatible API using Claude subscription" + ) + + parser.add_argument( + "--host", + type=str, + default="127.0.0.1", + help="Host to bind to (default: 127.0.0.1)", + ) + + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port to bind to (default: 8000)", + ) + + parser.add_argument( + "--timeout", + type=int, + default=120, + help="Query timeout in seconds (default: 120)", + ) + + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging", + ) + + args = parser.parse_args() + + # Configure logging + log_level = logging.DEBUG if args.debug else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + # Create config + config = AuthConfig( + timeout_seconds=args.timeout, + ) + + # Initialize proxy + global proxy + proxy = ClaudeAPIProxy( + config=config, + host=args.host, + port=args.port, + ) + + logger.info( + f"Starting Claude API Proxy on {args.host}:{args.port}\n" + f"OpenAI-compatible endpoint: http://{args.host}:{args.port}/v1/chat/completions\n" + f"Health check: http://{args.host}:{args.port}/health\n" + f"API docs: http://{args.host}:{args.port}/docs" + ) + + # Run server + import uvicorn + uvicorn.run( + app, + host=args.host, + port=args.port, + log_level="info" if not args.debug else "debug", + ) + + +if __name__ == "__main__": + main() diff --git a/test_api_proxy.py b/test_api_proxy.py new file mode 100644 index 0000000..b0f69ae --- /dev/null +++ b/test_api_proxy.py @@ -0,0 +1,184 @@ +"""Quick test of API proxy functionality.""" + +import asyncio +import sys + + +def test_imports(): + """Test that all imports work.""" + print("Testing imports...") + + try: + from claude_cli_auth.api_proxy import ( + ClaudeAPIProxy, + ChatCompletionRequest, + ChatMessage, + FormatConverter, + ) + print("✓ API proxy imports successful") + return True + except ImportError as e: + print(f"✗ Import failed: {e}") + return False + + +def test_format_converter(): + """Test format conversion.""" + print("\nTesting format converter...") + + from claude_cli_auth.api_proxy import FormatConverter, ChatMessage + + # Test OpenAI to Claude conversion + messages = [ + ChatMessage(role="system", content="You are helpful"), + ChatMessage(role="user", content="Hello!"), + ChatMessage(role="assistant", content="Hi there!"), + ChatMessage(role="user", content="How are you?"), + ] + + prompt = FormatConverter.openai_to_claude_prompt(messages) + print(f"✓ Converted {len(messages)} messages to prompt") + print(f" Prompt length: {len(prompt)} chars") + print(f" Preview: {prompt[:100]}...") + + # Test Claude to OpenAI conversion + response = FormatConverter.claude_to_openai_response( + claude_content="I'm doing great!", + request_id="test-123", + model="claude-sonnet-4", + cost=0.001, + ) + + print(f"✓ Converted Claude response to OpenAI format") + print(f" Response ID: {response.id}") + print(f" Model: {response.model}") + print(f" Content: {response.choices[0].message.content}") + + return True + + +def test_request_models(): + """Test request/response models.""" + print("\nTesting request models...") + + from claude_cli_auth.api_proxy import ChatCompletionRequest, ChatMessage + + # Create request + request = ChatCompletionRequest( + model="claude-sonnet-4", + messages=[ + ChatMessage(role="user", content="Hello!") + ], + stream=False, + temperature=1.0, + ) + + print(f"✓ Created request with {len(request.messages)} message(s)") + print(f" Model: {request.model}") + print(f" Stream: {request.stream}") + print(f" Temperature: {request.temperature}") + + return True + + +async def test_proxy_initialization(): + """Test proxy initialization.""" + print("\nTesting proxy initialization...") + + try: + from claude_cli_auth.api_proxy import ClaudeAPIProxy + from claude_cli_auth import AuthConfig + + # Create config (this won't fail even if Claude isn't authenticated) + config = AuthConfig( + timeout_seconds=60, + ) + + print("✓ Created AuthConfig") + + # Try to initialize proxy (may fail if Claude CLI not authenticated) + try: + proxy = ClaudeAPIProxy( + config=config, + host="127.0.0.1", + port=8888, + ) + print("✓ Initialized ClaudeAPIProxy") + + # Check health (will show authentication status) + health = await proxy.get_health() + print(f"✓ Health check complete") + print(f" Status: {health['status']}") + print(f" Authenticated: {health['claude_authenticated']}") + + # Cleanup + await proxy.shutdown() + print("✓ Proxy shutdown successful") + + return True + + except Exception as e: + print(f"⚠ Proxy initialization failed (expected if not authenticated): {e}") + print(" Note: This is expected if you haven't run 'claude auth login'") + return True # Still pass the test + + except Exception as e: + print(f"✗ Unexpected error: {e}") + import traceback + traceback.print_exc() + return False + + +def main(): + """Run all tests.""" + print("=" * 60) + print("Claude API Proxy - Quick Test Suite") + print("=" * 60) + print() + + results = [] + + # Test imports + results.append(("Imports", test_imports())) + + # Test format converter + results.append(("Format Converter", test_format_converter())) + + # Test request models + results.append(("Request Models", test_request_models())) + + # Test proxy initialization + results.append(("Proxy Initialization", asyncio.run(test_proxy_initialization()))) + + # Summary + print() + print("=" * 60) + print("Test Results:") + print("=" * 60) + + passed = 0 + failed = 0 + + for name, result in results: + status = "✓ PASS" if result else "✗ FAIL" + print(f"{status:10} {name}") + + if result: + passed += 1 + else: + failed += 1 + + print() + print(f"Total: {passed + failed} tests, {passed} passed, {failed} failed") + print() + + if failed > 0: + print("Some tests failed!") + sys.exit(1) + else: + print("All tests passed! ✓") + sys.exit(0) + + +if __name__ == "__main__": + main()