Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Required: Your OpenAI API key
# Optional: Your OpenAI API key (for proxy mode)
# If set, all requests will use this key to call OpenAI API
# If not set, the proxy will use client-provided keys directly (passthrough mode)
OPENAI_API_KEY="sk-your-openai-api-key-here"

# Optional: Expected Anthropic API key for client validation
# If set, clients must provide this exact API key to access the proxy
# If set: clients must provide this exact key (proxy mode)
# If not set: client keys are used directly as OpenAI keys (passthrough mode)
ANTHROPIC_API_KEY="your-expected-anthropic-api-key"

# Optional: OpenAI API base URL (default: https://api.openai.com/v1)
Expand Down
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A proxy server that enables **Claude Code** to work with OpenAI-compatible API p

- **Full Claude API Compatibility**: Complete `/v1/messages` endpoint support
- **Multiple Provider Support**: OpenAI, Azure OpenAI, local models (Ollama), and any OpenAI-compatible API
- **Multi-tenant Passthrough Mode**: Support for per-user OpenAI API keys
- **Smart Model Mapping**: Configure BIG and SMALL models via environment variables
- **Function Calling**: Complete tool use support with proper conversion
- **Streaming Responses**: Real-time SSE streaming support
Expand Down Expand Up @@ -50,27 +51,36 @@ docker compose up -d

### 4. Use with Claude Code

**Proxy Mode (OPENAI_API_KEY configured):**
```bash
# If ANTHROPIC_API_KEY is not set in the proxy:
# With client validation enabled:
ANTHROPIC_BASE_URL=http://localhost:8082 ANTHROPIC_API_KEY="exact-matching-key" claude

# With client validation disabled:
ANTHROPIC_BASE_URL=http://localhost:8082 ANTHROPIC_API_KEY="any-value" claude
```

# If ANTHROPIC_API_KEY is set in the proxy:
ANTHROPIC_BASE_URL=http://localhost:8082 ANTHROPIC_API_KEY="exact-matching-key" claude
**Passthrough Mode (OPENAI_API_KEY not configured):**
```bash
# Users provide their own OpenAI API key:
ANTHROPIC_BASE_URL=http://localhost:8082 ANTHROPIC_API_KEY="sk-your-openai-api-key-here" claude
```

## Configuration

### Environment Variables

**Required:**
**Core Configuration:**

- `OPENAI_API_KEY` - Your API key for the target provider
- `OPENAI_API_KEY` - Your API key for the target provider (optional)
- If set: Proxy mode - proxy uses this key for all requests
- If not set: Passthrough mode - users provide their own OpenAI API keys

**Security:**

- `ANTHROPIC_API_KEY` - Expected Anthropic API key for client validation
- `ANTHROPIC_API_KEY` - Expected Anthropic API key for client validation (optional)
- If set, clients must provide this exact API key to access the proxy
- If not set, any API key will be accepted
- If not set, client-provided API keys are used directly as OpenAI API keys (passthrough mode)

**Model Configuration:**

Expand Down
103 changes: 81 additions & 22 deletions src/api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,50 @@

router = APIRouter()

openai_client = OpenAIClient(
config.openai_api_key,
config.openai_base_url,
config.request_timeout,
api_version=config.azure_api_version,
)
# Create OpenAI client - use env key if available, otherwise create a dummy one
if config.openai_api_key:
openai_client = OpenAIClient(
config.openai_api_key,
config.openai_base_url,
config.request_timeout,
api_version=config.azure_api_version,
)
else:
# In passthrough mode, create a client with a dummy key for compatibility
# The actual client will be created per-request with user's API key
openai_client = OpenAIClient(
"sk-dummy", # This won't be used for actual requests
config.openai_base_url,
config.request_timeout,
api_version=config.azure_api_version,
)

async def validate_api_key(x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None)):
"""Validate the client's API key from either x-api-key header or Authorization header."""
def get_openai_client(user_api_key: Optional[str] = None) -> OpenAIClient:
"""Get OpenAI client - use env key if available, otherwise create with user-provided key."""
if config.openai_api_key:
# Proxy mode: use pre-configured client
return openai_client
elif user_api_key:
# Passthrough mode: create client with user-provided key
if not config.validate_api_key(user_api_key):
raise HTTPException(
status_code=401,
detail="Invalid OpenAI API key format. Please provide a valid API key starting with 'sk-'."
)
return OpenAIClient(
user_api_key,
config.openai_base_url,
config.request_timeout,
api_version=config.azure_api_version,
)
else:
raise HTTPException(
status_code=401,
detail="No OpenAI API key available. Please set OPENAI_API_KEY environment variable or provide your OpenAI API key in Authorization header."
)

async def validate_api_key_and_extract(x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None)):
"""Extract API key and handle validation based on configuration."""
client_api_key = None

# Extract API key from headers
Expand All @@ -34,27 +69,40 @@ async def validate_api_key(x_api_key: Optional[str] = Header(None), authorizatio
elif authorization and authorization.startswith("Bearer "):
client_api_key = authorization.replace("Bearer ", "")

# Skip validation if ANTHROPIC_API_KEY is not set in the environment
if not config.anthropic_api_key:
return

# Validate the client API key
if not client_api_key or not config.validate_client_api_key(client_api_key):
logger.warning(f"Invalid API key provided by client")
raise HTTPException(
status_code=401,
detail="Invalid API key. Please provide a valid Anthropic API key."
)
# If OPENAI_API_KEY is configured in environment, use proxy validation
if config.openai_api_key:
# Proxy mode: validate against ANTHROPIC_API_KEY if configured
if config.anthropic_api_key:
if not client_api_key or not config.validate_client_api_key(client_api_key):
logger.warning(f"Invalid API key provided by client")
raise HTTPException(
status_code=401,
detail="Invalid API key. Please provide a valid Anthropic API key."
)
# Return None since we'll use env OPENAI_API_KEY
return None
else:
# Passthrough mode: no ANTHROPIC validation, use client key as OpenAI key
if not client_api_key:
raise HTTPException(
status_code=401,
detail="No API key provided. Please provide your OpenAI API key in Authorization header."
)
# Return the client API key to be used as OpenAI key
return client_api_key

@router.post("/v1/messages")
async def create_message(request: ClaudeMessagesRequest, http_request: Request, _: None = Depends(validate_api_key)):
async def create_message(request: ClaudeMessagesRequest, http_request: Request, user_api_key: str = Depends(validate_api_key_and_extract)):
try:
logger.debug(
f"Processing Claude request: model={request.model}, stream={request.stream}"
)

# Generate unique request ID for cancellation tracking
request_id = str(uuid.uuid4())

# Get OpenAI client (either default or created with user's key)
openai_client = get_openai_client(user_api_key)

# Convert Claude request to OpenAI format
openai_request = convert_claude_to_openai(request, model_manager)
Expand Down Expand Up @@ -119,7 +167,7 @@ async def create_message(request: ClaudeMessagesRequest, http_request: Request,


@router.post("/v1/messages/count_tokens")
async def count_tokens(request: ClaudeTokenCountRequest, _: None = Depends(validate_api_key)):
async def count_tokens(request: ClaudeTokenCountRequest, user_api_key: str = Depends(validate_api_key_and_extract)):
try:
# For token counting, we'll use a simple estimation
# In a real implementation, you might want to use tiktoken or similar
Expand Down Expand Up @@ -163,7 +211,7 @@ async def health_check():
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"openai_api_configured": bool(config.openai_api_key),
"api_key_valid": config.validate_api_key(),
"api_key_valid": config.validate_api_key() if config.openai_api_key else "per_request",
"client_api_key_validation": bool(config.anthropic_api_key),
}

Expand All @@ -172,6 +220,17 @@ async def health_check():
async def test_connection():
"""Test API connectivity to OpenAI"""
try:
# Use default client if available, otherwise require user API key
if not config.openai_api_key:
return JSONResponse(
status_code=400,
content={
"status": "failed",
"message": "No default OpenAI API key configured. Test connection requires OPENAI_API_KEY environment variable.",
"timestamp": datetime.now().isoformat(),
}
)

# Simple test request to verify API connectivity
test_response = await openai_client.create_chat_completion(
{
Expand Down
12 changes: 6 additions & 6 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
class Config:
def __init__(self):
self.openai_api_key = os.environ.get("OPENAI_API_KEY")
if not self.openai_api_key:
raise ValueError("OPENAI_API_KEY not found in environment variables")
# Note: openai_api_key is now optional - can be provided per request

# Add Anthropic API key for client validation
self.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY")
Expand All @@ -30,12 +29,13 @@ def __init__(self):
self.middle_model = os.environ.get("MIDDLE_MODEL", self.big_model)
self.small_model = os.environ.get("SMALL_MODEL", "gpt-4o-mini")

def validate_api_key(self):
"""Basic API key validation"""
if not self.openai_api_key:
def validate_api_key(self, api_key: str = None):
"""Basic API key validation - use provided key or fallback to env key"""
key_to_validate = api_key if api_key else self.openai_api_key
if not key_to_validate:
return False
# Basic format check for OpenAI API keys
if not self.openai_api_key.startswith('sk-'):
if not key_to_validate.startswith('sk-'):
return False
return True

Expand Down
12 changes: 7 additions & 5 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ def main():
print("")
print("Usage: python src/main.py")
print("")
print("Required environment variables:")
print(" OPENAI_API_KEY - Your OpenAI API key")
print("")
print("Optional environment variables:")
print(" ANTHROPIC_API_KEY - Expected Anthropic API key for client validation")
print("Environment variables:")
print(" OPENAI_API_KEY - Your OpenAI API key (optional)")
print(" If not set, users must provide their own OpenAI API key")
print(" ANTHROPIC_API_KEY - Expected Anthropic API key for client validation (optional)")
print(" If set, clients must provide this exact API key")
print(" If not set, client API keys are used directly as OpenAI keys")
print("")
print("Additional optional environment variables:")
print(
f" OPENAI_BASE_URL - OpenAI API base URL (default: https://api.openai.com/v1)"
)
Expand Down