mirror of
https://github.com/nenadilic84/claudex.git
synced 2025-10-28 00:02:05 -07:00
Initial commit with functional proxy and tests
- Create claudex proxy for Anthropic-to-OpenAI API conversion - Fix package setup in pyproject.toml - Add environment variable mocking in tests - Include example configuration
This commit is contained in:
commit
12e39cf7c9
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
TARGET_API_BASE=https://api.openrouter.ai/v1
|
||||
TARGET_API_KEY=<your_provider_key>
|
||||
BIG_MODEL_TARGET=openai/gpt-4.1
|
||||
SMALL_MODEL_TARGET=openai/gpt-4.1-mini
|
||||
LOG_LEVEL=INFO
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
65
README.md
Normal file
65
README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# claudex
|
||||
|
||||
A CLI proxy to run Claude API requests (Anthropic-style) against OpenAI-compatible LLM providers (like OpenRouter), either for local development, automation, or as a bridge to OpenAI tooling.
|
||||
|
||||
## Features
|
||||
|
||||
- FastAPI-based proxy for low-latency, robust relaying.
|
||||
- Converts Anthropic Claude v3-style and Claude tool-calls API to OpenAI-compatible requests.
|
||||
- Flexible environment variable configuration for provider settings.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- [uvicorn](https://www.uvicorn.org/) for ASGI server
|
||||
- FastAPI, httpx, python-dotenv, pydantic (see `pyproject.toml`)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd claudex
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
cp .env.example .env # edit .env to fill in your API settings
|
||||
```
|
||||
|
||||
Fill in your `.env` like:
|
||||
```
|
||||
TARGET_API_BASE=https://api.openrouter.ai/v1
|
||||
TARGET_API_KEY=<your_provider_key>
|
||||
BIG_MODEL_TARGET=openai/gpt-4.1
|
||||
SMALL_MODEL_TARGET=openai/gpt-4.1-mini
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
After setup and installing dependencies, you can run the proxy in either of these ways:
|
||||
|
||||
### 1. Recommended: Run via the CLI/main entrypoint
|
||||
|
||||
```bash
|
||||
# Run as module:
|
||||
python -m claudex --host 0.0.0.0 --port 8082 --reload
|
||||
|
||||
# Or (if installed as a script):
|
||||
claudex --host 0.0.0.0 --port 8082 --reload
|
||||
```
|
||||
|
||||
### 2. Alternative: Run directly with Uvicorn
|
||||
|
||||
```bash
|
||||
uvicorn claudex.proxy:app --host 0.0.0.0 --port 8082 --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
In a second terminal, you can now use the Claude CLI tool with this:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=http://localhost:8082 DISABLE_PROMPT_CACHING=1 claude
|
||||
```
|
||||
|
||||
---
|
||||
0
claudex/__init__.py
Normal file
0
claudex/__init__.py
Normal file
15
claudex/main.py
Normal file
15
claudex/main.py
Normal file
@ -0,0 +1,15 @@
|
||||
import uvicorn
|
||||
from .proxy import app
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Run the claudex proxy (Anthropic-to-OpenAI or OpenRouter compatible proxy)")
|
||||
parser.add_argument('--host', default='0.0.0.0', help='Host to bind (default: 0.0.0.0)')
|
||||
parser.add_argument('--port', type=int, default=8082, help='Port to bind (default: 8082)')
|
||||
parser.add_argument('--reload', action='store_true', help='Enable live-reload (for development)')
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run("claudex.proxy:app", host=args.host, port=args.port, reload=args.reload)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
487
claudex/proxy.py
Normal file
487
claudex/proxy.py
Normal file
@ -0,0 +1,487 @@
|
||||
import uvicorn
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request, Response, HTTPException
|
||||
from fastapi.responses import StreamingResponse, JSONResponse # Added JSONResponse explicitly
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import time # <--- IMPORT ADDED
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Any, Optional, Union, Literal
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# --- Configuration ---
|
||||
load_dotenv() # Load variables from .env file
|
||||
|
||||
# Target OpenAI-compatible endpoint configuration
|
||||
TARGET_API_BASE = os.environ.get("TARGET_API_BASE")
|
||||
TARGET_API_KEY = os.environ.get("TARGET_API_KEY")
|
||||
BIG_MODEL_TARGET = os.environ.get("BIG_MODEL_TARGET")
|
||||
SMALL_MODEL_TARGET = os.environ.get("SMALL_MODEL_TARGET")
|
||||
|
||||
# Proxy configuration
|
||||
LISTEN_HOST = "0.0.0.0"
|
||||
LISTEN_PORT = 8082 # Port this proxy listens on (can be changed)
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
# Basic validation
|
||||
if not TARGET_API_BASE or not TARGET_API_KEY or not BIG_MODEL_TARGET or not SMALL_MODEL_TARGET:
|
||||
raise ValueError("Missing required environment variables: TARGET_API_BASE, TARGET_API_KEY, BIG_MODEL_TARGET, SMALL_MODEL_TARGET")
|
||||
|
||||
# --- Logging Setup ---
|
||||
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger("AnthropicProxy")
|
||||
# Silence overly verbose libraries if needed (optional)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
|
||||
# --- Pydantic Models (Simplified Anthropic Format) ---
|
||||
class ContentBlock(BaseModel):
|
||||
type: str
|
||||
text: Optional[str] = None
|
||||
source: Optional[Dict[str, Any]] = None # For image
|
||||
id: Optional[str] = None # For tool_use
|
||||
name: Optional[str] = None # For tool_use
|
||||
input: Optional[Dict[str, Any]] = None # For tool_use
|
||||
tool_use_id: Optional[str] = None # For tool_result
|
||||
content: Optional[Union[str, List[Dict], Dict, List[Any]]] = None # For tool_result
|
||||
|
||||
class AnthropicMessage(BaseModel):
|
||||
role: Literal["user", "assistant"]
|
||||
content: Union[str, List[ContentBlock]]
|
||||
|
||||
class AnthropicTool(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
input_schema: Dict[str, Any]
|
||||
|
||||
class AnthropicMessagesRequest(BaseModel):
|
||||
model: str # Original model name from client
|
||||
max_tokens: int
|
||||
messages: List[AnthropicMessage]
|
||||
# --- MODIFIED system type ---
|
||||
system: Optional[Union[str, List[ContentBlock]]] = None # Allow string or list of blocks
|
||||
# --- END MODIFICATION ---
|
||||
stream: Optional[bool] = False
|
||||
temperature: Optional[float] = None
|
||||
top_p: Optional[float] = None
|
||||
top_k: Optional[int] = None
|
||||
stop_sequences: Optional[List[str]] = None
|
||||
tools: Optional[List[AnthropicTool]] = None
|
||||
tool_choice: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None # Keep for potential passthrough
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def get_mapped_model(original_model: str) -> str:
|
||||
"""Maps incoming Claude model names to target names."""
|
||||
lower_model = original_model.lower()
|
||||
if "/" in lower_model:
|
||||
lower_model = lower_model.split("/")[-1]
|
||||
|
||||
if "sonnet" in lower_model:
|
||||
logger.info(f"Mapping '{original_model}' -> '{BIG_MODEL_TARGET}'")
|
||||
return BIG_MODEL_TARGET
|
||||
elif "haiku" in lower_model:
|
||||
logger.info(f"Mapping '{original_model}' -> '{SMALL_MODEL_TARGET}'")
|
||||
return SMALL_MODEL_TARGET
|
||||
else:
|
||||
logger.warning(f"No mapping rule for '{original_model}'. Using target: '{BIG_MODEL_TARGET}' as default.")
|
||||
return BIG_MODEL_TARGET
|
||||
|
||||
def convert_anthropic_to_openai_request(
|
||||
anthropic_req: AnthropicMessagesRequest, mapped_model: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Converts Anthropic request format to OpenAI format."""
|
||||
openai_messages = []
|
||||
|
||||
# --- MODIFIED system prompt handling ---
|
||||
system_content_str = None
|
||||
if isinstance(anthropic_req.system, str):
|
||||
system_content_str = anthropic_req.system
|
||||
elif isinstance(anthropic_req.system, list):
|
||||
# Concatenate text from all text blocks in the system list
|
||||
system_content_str = "\n".join(
|
||||
block.text for block in anthropic_req.system if block.type == "text" and block.text
|
||||
)
|
||||
if system_content_str and system_content_str.strip():
|
||||
openai_messages.append({"role": "system", "content": system_content_str.strip()})
|
||||
# --- END MODIFICATION ---
|
||||
|
||||
# Process conversation messages (same logic as before for tool results/calls)
|
||||
for msg in anthropic_req.messages:
|
||||
openai_msg = {"role": msg.role}
|
||||
if isinstance(msg.content, str):
|
||||
openai_msg["content"] = msg.content if msg.content else "..." # Ensure not empty
|
||||
openai_messages.append(openai_msg)
|
||||
elif isinstance(msg.content, list):
|
||||
combined_text = ""
|
||||
tool_results_for_openai = []
|
||||
|
||||
for block in msg.content:
|
||||
if block.type == "text" and block.text:
|
||||
combined_text += block.text + "\n"
|
||||
elif block.type == "tool_result" and block.tool_use_id:
|
||||
tool_content = ""
|
||||
if isinstance(block.content, str): tool_content = block.content
|
||||
elif isinstance(block.content, list):
|
||||
for item in block.content:
|
||||
if isinstance(item, dict) and item.get("type") == "text": tool_content += item.get("text", "") + "\n"
|
||||
else:
|
||||
try: tool_content += json.dumps(item) + "\n"
|
||||
except Exception: tool_content += str(item) + "\n"
|
||||
else:
|
||||
try: tool_content += json.dumps(block.content) + "\n"
|
||||
except Exception: tool_content += str(block.content) + "\n"
|
||||
tool_results_for_openai.append({"role": "tool", "tool_call_id": block.tool_use_id, "content": tool_content.strip()})
|
||||
elif block.type == "image": logger.warning("Ignoring image block for OpenAI conversion.")
|
||||
elif block.type == "tool_use" and msg.role == "user": logger.warning("Ignoring tool_use block found in user message during conversion.")
|
||||
|
||||
# Add text content for the user/assistant message itself
|
||||
if combined_text.strip():
|
||||
openai_msg["content"] = combined_text.strip()
|
||||
openai_messages.append(openai_msg)
|
||||
# If only tool results, don't add an empty user/assistant message block
|
||||
elif not combined_text.strip() and tool_results_for_openai and msg.role == "user":
|
||||
pass # Avoid adding user message if it *only* contained tool results
|
||||
|
||||
# Append tool results following the message they belong to
|
||||
openai_messages.extend(tool_results_for_openai)
|
||||
|
||||
# Handle tool calls from assistant messages
|
||||
if msg.role == "assistant":
|
||||
tool_calls_for_openai = []
|
||||
assistant_text_content = "" # Capture potential text alongside tool calls
|
||||
if isinstance(msg.content, list):
|
||||
for block in msg.content:
|
||||
if block.type == "text" and block.text:
|
||||
assistant_text_content += block.text + "\n" # Add text if assistant provided it
|
||||
elif block.type == "tool_use" and block.id and block.name and block.input is not None:
|
||||
tool_calls_for_openai.append({
|
||||
"id": block.id,
|
||||
"type": "function",
|
||||
"function": {"name": block.name, "arguments": json.dumps(block.input)} # Arguments must be JSON string
|
||||
})
|
||||
|
||||
# Ensure the assistant message exists before adding tool_calls or content
|
||||
assistant_msg_exists = any(m is openai_msg for m in openai_messages)
|
||||
if not assistant_msg_exists and (assistant_text_content.strip() or tool_calls_for_openai):
|
||||
openai_messages.append(openai_msg) # Add the base assistant message first
|
||||
|
||||
if assistant_text_content.strip():
|
||||
openai_msg["content"] = assistant_text_content.strip() # Add text content
|
||||
if tool_calls_for_openai:
|
||||
openai_msg["tool_calls"] = tool_calls_for_openai
|
||||
# If there was no text content, OpenAI requires content to be explicitly None
|
||||
if not assistant_text_content.strip():
|
||||
openai_msg["content"] = None
|
||||
|
||||
# --- Rest of the conversion logic (tools, tool_choice, optional params) ---
|
||||
# (Keep the existing logic for these parts)
|
||||
openai_request = {
|
||||
"model": mapped_model,
|
||||
"messages": openai_messages,
|
||||
"max_tokens": min(anthropic_req.max_tokens, 16384),
|
||||
"stream": anthropic_req.stream,
|
||||
}
|
||||
if anthropic_req.temperature is not None: openai_request["temperature"] = anthropic_req.temperature
|
||||
if anthropic_req.top_p is not None: openai_request["top_p"] = anthropic_req.top_p
|
||||
if anthropic_req.stop_sequences: openai_request["stop"] = anthropic_req.stop_sequences
|
||||
if anthropic_req.metadata and "user" in anthropic_req.metadata: openai_request["user"] = str(anthropic_req.metadata["user"])
|
||||
|
||||
if anthropic_req.tools:
|
||||
openai_request["tools"] = [
|
||||
{"type": "function", "function": {"name": t.name, "description": t.description, "parameters": t.input_schema}}
|
||||
for t in anthropic_req.tools
|
||||
]
|
||||
if anthropic_req.tool_choice:
|
||||
choice_type = anthropic_req.tool_choice.get("type")
|
||||
if choice_type == "auto" or choice_type == "any": openai_request["tool_choice"] = "auto"
|
||||
elif choice_type == "tool" and "name" in anthropic_req.tool_choice:
|
||||
openai_request["tool_choice"] = {"type": "function", "function": {"name": anthropic_req.tool_choice["name"]}}
|
||||
else: openai_request["tool_choice"] = "auto"
|
||||
|
||||
logger.debug(f"Converted OpenAI Request: {json.dumps(openai_request, indent=2)}")
|
||||
return openai_request
|
||||
|
||||
# --- Keep convert_openai_to_anthropic_response ---
|
||||
# --- Keep handle_openai_to_anthropic_streaming ---
|
||||
# (No changes needed in these response conversion functions based on the error)
|
||||
def convert_openai_to_anthropic_response(
|
||||
openai_dict: Dict[str, Any], original_model: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Converts OpenAI response format to Anthropic format."""
|
||||
anthropic_content = []
|
||||
stop_reason = "end_turn"
|
||||
usage = {"input_tokens": 0, "output_tokens": 0}
|
||||
|
||||
try:
|
||||
choice = openai_dict.get("choices", [{}])[0]
|
||||
message = choice.get("message", {})
|
||||
finish_reason = choice.get("finish_reason", "stop")
|
||||
|
||||
# Text content
|
||||
if message.get("content"):
|
||||
anthropic_content.append({"type": "text", "text": message["content"]})
|
||||
|
||||
# Tool calls
|
||||
if message.get("tool_calls"):
|
||||
for tc in message["tool_calls"]:
|
||||
if tc.get("type") == "function" and tc.get("function"):
|
||||
try:
|
||||
# Arguments from OpenAI are already JSON strings
|
||||
tool_input = json.loads(tc["function"].get("arguments", "{}"))
|
||||
except json.JSONDecodeError:
|
||||
tool_input = {"raw_string_args": tc["function"].get("arguments", "")} # Handle non-JSON args
|
||||
anthropic_content.append({
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id", f"toolu_{uuid.uuid4().hex[:10]}"),
|
||||
"name": tc["function"].get("name"),
|
||||
"input": tool_input
|
||||
})
|
||||
|
||||
# Map stop reason
|
||||
if finish_reason == "length": stop_reason = "max_tokens"
|
||||
elif finish_reason == "stop": stop_reason = "end_turn"
|
||||
elif finish_reason == "tool_calls": stop_reason = "tool_use"
|
||||
|
||||
# Usage
|
||||
if openai_dict.get("usage"):
|
||||
usage["input_tokens"] = openai_dict["usage"].get("prompt_tokens", 0)
|
||||
usage["output_tokens"] = openai_dict["usage"].get("completion_tokens", 0)
|
||||
|
||||
if not anthropic_content: anthropic_content.append({"type": "text", "text": ""})
|
||||
|
||||
anthropic_response = {
|
||||
"id": openai_dict.get("id", f"msg_{uuid.uuid4().hex[:10]}"),
|
||||
"type": "message", "role": "assistant", "model": original_model,
|
||||
"content": anthropic_content, "stop_reason": stop_reason,
|
||||
"stop_sequence": None, "usage": usage,
|
||||
}
|
||||
logger.debug(f"Converted Anthropic Response: {json.dumps(anthropic_response, indent=2)}")
|
||||
return anthropic_response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting OpenAI response: {e}", exc_info=True)
|
||||
return {
|
||||
"id": f"msg_error_{uuid.uuid4().hex[:10]}", "type": "message",
|
||||
"role": "assistant", "model": original_model,
|
||||
"content": [{"type": "text", "text": f"Error processing backend response: {e}"}],
|
||||
"stop_reason": "error", "usage": {"input_tokens": 0, "output_tokens": 0},
|
||||
}
|
||||
|
||||
async def handle_openai_to_anthropic_streaming(openai_stream, original_model: str):
|
||||
"""Converts OpenAI streaming chunks to Anthropic Server-Sent Events."""
|
||||
message_id = f"msg_{uuid.uuid4().hex[:24]}"
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
final_stop_reason = "end_turn"
|
||||
|
||||
try:
|
||||
# 1. Send message_start
|
||||
yield f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': {'id': message_id, 'type': 'message', 'role': 'assistant', 'model': original_model, 'content': [], 'stop_reason': None, 'stop_sequence': None, 'usage': {'input_tokens': input_tokens, 'output_tokens': output_tokens}}})}\n\n"
|
||||
# 2. Send ping
|
||||
yield f"event: ping\ndata: {json.dumps({'type': 'ping'})}\n\n"
|
||||
|
||||
content_block_index = -1
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
accumulated_tool_args = ""
|
||||
text_block_started = False
|
||||
tool_blocks = {} # Track tool blocks by index {anthropic_index: {id:.., name:.., args:...}}
|
||||
|
||||
async for chunk_bytes in openai_stream:
|
||||
chunk_str = chunk_bytes.decode('utf-8').strip()
|
||||
lines = chunk_str.splitlines() # Handle multiple SSE events in one chunk
|
||||
|
||||
for line in lines:
|
||||
if not line or line == "data: [DONE]":
|
||||
continue
|
||||
if not line.startswith("data:"): continue
|
||||
|
||||
chunk_str_data = line[len("data: "):]
|
||||
|
||||
try:
|
||||
chunk_data = json.loads(chunk_str_data)
|
||||
delta = chunk_data.get("choices", [{}])[0].get("delta", {})
|
||||
|
||||
# --- Handle Text Content ---
|
||||
if delta.get("content"):
|
||||
if not text_block_started:
|
||||
content_block_index = 0 # Text is always index 0
|
||||
text_block_started = True
|
||||
yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': content_block_index, 'content_block': {'type': 'text', 'text': ''}})}\n\n"
|
||||
yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': content_block_index, 'delta': {'type': 'text_delta', 'text': delta['content']}})}\n\n"
|
||||
output_tokens += 1
|
||||
|
||||
# --- Handle Tool Calls ---
|
||||
if delta.get("tool_calls"):
|
||||
# Stop previous text block if it was started
|
||||
if text_block_started and content_block_index == 0:
|
||||
yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': 0})}\n\n"
|
||||
content_block_index = -1 # Reset index for tools
|
||||
|
||||
for tc in delta["tool_calls"]:
|
||||
tc_index = tc.get("index", 0) # OpenAI provides index
|
||||
anthropic_index = tc_index + 1 # Anthropic index starts after text block (if any)
|
||||
|
||||
if tc.get("id"): # Start of a new tool call
|
||||
current_tool_id = tc["id"]
|
||||
current_tool_name = tc.get("function", {}).get("name", "")
|
||||
accumulated_tool_args = tc.get("function", {}).get("arguments", "")
|
||||
tool_blocks[anthropic_index] = {"id": current_tool_id, "name": current_tool_name, "args": accumulated_tool_args}
|
||||
|
||||
yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': anthropic_index, 'content_block': {'type': 'tool_use', 'id': current_tool_id, 'name': current_tool_name, 'input': {}}})}\n\n"
|
||||
if accumulated_tool_args:
|
||||
yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': anthropic_index, 'delta': {'type': 'input_json_delta', 'partial_json': accumulated_tool_args}})}\n\n"
|
||||
|
||||
elif tc.get("function", {}).get("arguments") and anthropic_index in tool_blocks: # Continuation
|
||||
args_delta = tc["function"]["arguments"]
|
||||
tool_blocks[anthropic_index]["args"] += args_delta
|
||||
yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': anthropic_index, 'delta': {'type': 'input_json_delta', 'partial_json': args_delta}})}\n\n"
|
||||
|
||||
# --- Handle Finish Reason ---
|
||||
finish_reason = chunk_data.get("choices", [{}])[0].get("finish_reason")
|
||||
if finish_reason:
|
||||
if finish_reason == "length": final_stop_reason = "max_tokens"
|
||||
elif finish_reason == "stop": final_stop_reason = "end_turn"
|
||||
elif finish_reason == "tool_calls": final_stop_reason = "tool_use"
|
||||
|
||||
# --- Handle Usage ---
|
||||
if chunk_data.get("usage"):
|
||||
# In streaming, usage might appear in chunks or only at the end
|
||||
if chunk_data["usage"].get("prompt_tokens"):
|
||||
input_tokens = chunk_data["usage"]["prompt_tokens"]
|
||||
if chunk_data["usage"].get("completion_tokens"):
|
||||
output_tokens = chunk_data["usage"]["completion_tokens"] # Update if total provided
|
||||
|
||||
|
||||
except json.JSONDecodeError: logger.warning(f"Could not decode stream chunk data: {chunk_str_data}")
|
||||
except Exception as e: logger.error(f"Error processing stream chunk data: {e}", exc_info=True)
|
||||
|
||||
# 3. Send content_block_stop for all blocks started
|
||||
if text_block_started:
|
||||
yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': 0})}\n\n"
|
||||
for index in tool_blocks:
|
||||
yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': index})}\n\n"
|
||||
|
||||
|
||||
# 4. Send message_delta with stop reason and final usage
|
||||
final_usage = {"output_tokens": output_tokens}
|
||||
# Try to add input tokens if we got them
|
||||
if input_tokens > 0:
|
||||
final_usage["input_tokens"] = input_tokens
|
||||
|
||||
yield f"event: message_delta\ndata: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': final_stop_reason, 'stop_sequence': None}, 'usage': final_usage})}\n\n"
|
||||
|
||||
# 5. Send message_stop
|
||||
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during streaming conversion: {e}", exc_info=True)
|
||||
try:
|
||||
error_payload = json.dumps({'type': 'error', 'error': {'type': 'internal_server_error', 'message': str(e)}})
|
||||
yield f"event: error\ndata: {error_payload}\n\n"
|
||||
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n"
|
||||
except Exception: logger.critical("Failed to send error event during streaming.")
|
||||
finally:
|
||||
logger.debug("Finished Anthropic stream conversion.")
|
||||
|
||||
# --- FastAPI Application ---
|
||||
app = FastAPI(title="Anthropic to OpenAI Proxy")
|
||||
http_client = httpx.AsyncClient()
|
||||
|
||||
@app.post("/v1/messages")
|
||||
async def proxy_anthropic_request(anthropic_request: AnthropicMessagesRequest, raw_request: Request):
|
||||
"""Receives Anthropic request, converts, proxies, converts back."""
|
||||
start_time = time.time() # Now time is defined
|
||||
original_model = anthropic_request.model
|
||||
mapped_model = get_mapped_model(original_model)
|
||||
|
||||
logger.info(f"--> Request for '{original_model}' mapped to '{mapped_model}' (Stream: {anthropic_request.stream})")
|
||||
|
||||
try:
|
||||
# 1. Convert Request
|
||||
openai_request = convert_anthropic_to_openai_request(anthropic_request, mapped_model)
|
||||
|
||||
# 2. Prepare headers for target
|
||||
target_headers = {
|
||||
"Authorization": f"Bearer {TARGET_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json" if not anthropic_request.stream else "text/event-stream",
|
||||
}
|
||||
if "user-agent" in raw_request.headers:
|
||||
target_headers["User-Agent"] = raw_request.headers["user-agent"]
|
||||
|
||||
|
||||
# 3. Forward Request
|
||||
target_url = f"{TARGET_API_BASE.rstrip('/')}/chat/completions"
|
||||
|
||||
logger.debug(f"Forwarding to URL: {target_url}")
|
||||
# logger.debug(f"Forwarding Headers: {target_headers}") # Can be noisy
|
||||
# logger.debug(f"Forwarding Body: {json.dumps(openai_request)}") # Very noisy, be careful
|
||||
|
||||
response = await http_client.post(
|
||||
target_url,
|
||||
json=openai_request,
|
||||
headers=target_headers,
|
||||
timeout=300.0
|
||||
)
|
||||
|
||||
# 4. Handle Response
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"<-- Response status from '{mapped_model}': {response.status_code} ({elapsed_time:.2f}s)")
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_content = await response.aread()
|
||||
logger.error(f"Target API Error ({response.status_code}): {error_content.decode()}")
|
||||
try: error_detail = response.json()
|
||||
except Exception: error_detail = error_content.decode()
|
||||
# Use status_code from target, detail from target error
|
||||
raise HTTPException(status_code=response.status_code, detail=error_detail)
|
||||
|
||||
|
||||
# Handle Streaming Response
|
||||
if anthropic_request.stream:
|
||||
if 'text/event-stream' not in response.headers.get('content-type', '').lower():
|
||||
error_body = await response.aread()
|
||||
logger.error(f"Backend did not stream as expected. Status: {response.status_code}. Body: {error_body.decode()}")
|
||||
raise HTTPException(status_code=500, detail="Backend did not return a stream.")
|
||||
|
||||
return StreamingResponse(
|
||||
handle_openai_to_anthropic_streaming(response.aiter_bytes(), original_model),
|
||||
media_type="text/event-stream" # Set correct content type for Anthropic client
|
||||
)
|
||||
# Handle Non-Streaming Response
|
||||
else:
|
||||
openai_response_dict = response.json()
|
||||
anthropic_response_dict = convert_openai_to_anthropic_response(openai_response_dict, original_model)
|
||||
return JSONResponse(content=anthropic_response_dict) # Use JSONResponse
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"HTTPX Request Error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Error connecting to target API: {e}")
|
||||
except ValueError as e:
|
||||
logger.error(f"Configuration or Value Error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
except HTTPException as e:
|
||||
raise e # Re-raise FastAPI/proxy errors correctly
|
||||
except Exception as e:
|
||||
logger.error(f"Unhandled Exception: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"An internal server error occurred: {e}")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def health_check():
|
||||
return {"status": "ok", "message": "Anthropic-OpenAI Proxy is running"}
|
||||
|
||||
# --- Main Execution ---
|
||||
if __name__ == "__main__":
|
||||
logger.info(f"Starting Anthropic-OpenAI Proxy on {LISTEN_HOST}:{LISTEN_PORT}")
|
||||
logger.info(f"Target API Base: {TARGET_API_BASE}")
|
||||
logger.info(f"Mapping Sonnet -> {BIG_MODEL_TARGET}")
|
||||
logger.info(f"Mapping Haiku -> {SMALL_MODEL_TARGET}")
|
||||
uvicorn.run(app, host=LISTEN_HOST, port=LISTEN_PORT, log_config=None)
|
||||
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[project]
|
||||
name = "claudex"
|
||||
version = "0.1.0"
|
||||
description = "Anthropic Claude/OpenRouter proxy toolkit for the CLI"
|
||||
authors = [{ name="Your Name" }]
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.95.0",
|
||||
"uvicorn[standard]>=0.23.0",
|
||||
"httpx>=0.24.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.0.0"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
|
||||
[project.scripts]
|
||||
claudex = "claudex.proxy:app"
|
||||
20
tests/test_health.py
Normal file
20
tests/test_health.py
Normal file
@ -0,0 +1,20 @@
|
||||
from fastapi.testclient import TestClient
|
||||
import unittest.mock
|
||||
import os
|
||||
|
||||
# Mock environment variables before importing app
|
||||
with unittest.mock.patch.dict(os.environ, {
|
||||
'TARGET_API_BASE': 'https://api.example.com',
|
||||
'TARGET_API_KEY': 'mock-api-key',
|
||||
'BIG_MODEL_TARGET': 'model-large',
|
||||
'SMALL_MODEL_TARGET': 'model-small'
|
||||
}):
|
||||
from claudex.proxy import app
|
||||
|
||||
def test_health_check():
|
||||
client = TestClient(app)
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "message" in data
|
||||
4
tests/test_proxy_smoke.py
Normal file
4
tests/test_proxy_smoke.py
Normal file
@ -0,0 +1,4 @@
|
||||
# Placeholder: A real test would mock the environment or downstream HTTP calls.
|
||||
def test_proxy_importable():
|
||||
import claudex.proxy
|
||||
assert hasattr(claudex.proxy, "app")
|
||||
Loading…
x
Reference in New Issue
Block a user