feat: implement AI model API proxy service
This commit is contained in:
71
CLAUDE.md
Normal file
71
CLAUDE.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
zzrouter is an AI model API proxy service with OpenAI-compatible and Anthropic support, API key rotation, and request logging.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
zzrouter/
|
||||
├── backend/ # Python FastAPI proxy server
|
||||
└── frontend/ # (placeholder)
|
||||
```
|
||||
|
||||
## Backend Commands
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run development server
|
||||
uv run uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# Run directly
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (auto-routes by model) |
|
||||
| `POST /v1/openai/chat/completions` | OpenAI native passthrough |
|
||||
| `POST /v1/anthropic/messages` | Anthropic native passthrough |
|
||||
| `GET /health` | Health check |
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints require Bearer token authentication:
|
||||
```
|
||||
Authorization: Bearer <client_key>
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
SQLite database stored at `backend/data/zzrouter.db`.
|
||||
|
||||
Key tables:
|
||||
- `client_keys` - Client API keys
|
||||
- `providers` - Model provider configs
|
||||
- `provider_keys` - Provider API keys (supports multiple per provider)
|
||||
- `request_logs` - Request statistics
|
||||
|
||||
## Adding Configuration
|
||||
|
||||
```sql
|
||||
-- Add a client key
|
||||
INSERT INTO client_keys (key, name) VALUES ('my-secret-key', 'my-app');
|
||||
|
||||
-- Add provider API keys
|
||||
INSERT INTO provider_keys (provider_id, key) VALUES (1, 'sk-openai-key');
|
||||
INSERT INTO provider_keys (provider_id, key) VALUES (2, 'sk-ant-anthropic-key');
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Frontend directory is currently empty and pending implementation.
|
||||
10
backend/.gitignore
vendored
Normal file
10
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
0
backend/README.md
Normal file
0
backend/README.md
Normal file
4
backend/app/__init__.py
Normal file
4
backend/app/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""zzrouter backend application."""
|
||||
from .main import app
|
||||
|
||||
__all__ = ["app"]
|
||||
21
backend/app/config.py
Normal file
21
backend/app/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Application configuration."""
|
||||
from pathlib import Path
|
||||
|
||||
# Database
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "zzrouter.db"
|
||||
|
||||
# Server
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 8000
|
||||
|
||||
# Logging
|
||||
LOG_BATCH_SIZE = 100
|
||||
LOG_FLUSH_INTERVAL = 1.0 # seconds
|
||||
|
||||
# Model routing
|
||||
MODEL_PROVIDER_MAP = {
|
||||
"gpt": "openai",
|
||||
"o1": "openai",
|
||||
"o3": "openai",
|
||||
"claude": "anthropic",
|
||||
}
|
||||
84
backend/app/database.py
Normal file
84
backend/app/database.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Database connection and initialization."""
|
||||
import aiosqlite
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "zzrouter.db"
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
"""Get database connection."""
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = await aiosqlite.connect(DB_PATH)
|
||||
conn.row_factory = aiosqlite.Row
|
||||
return conn
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def db_connection():
|
||||
"""Context manager for database connection."""
|
||||
conn = await get_db()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database schema."""
|
||||
async with db_connection() as db:
|
||||
await db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS client_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
api_type TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL REFERENCES providers(id),
|
||||
key TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_key_id INTEGER REFERENCES client_keys(id),
|
||||
provider_id INTEGER REFERENCES providers(id),
|
||||
model TEXT,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
latency_ms INTEGER,
|
||||
success BOOLEAN,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_client_key_id ON request_logs(client_key_id);
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def seed_default_providers():
|
||||
"""Seed default providers if not exist."""
|
||||
async with db_connection() as db:
|
||||
await db.executemany(
|
||||
"""INSERT OR IGNORE INTO providers (name, base_url, api_type) VALUES (?, ?, ?)""",
|
||||
[
|
||||
("openai", "https://api.openai.com/v1", "openai"),
|
||||
("anthropic", "https://api.anthropic.com/v1", "anthropic"),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
46
backend/app/main.py
Normal file
46
backend/app/main.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""FastAPI application entry point."""
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .database import init_db, seed_default_providers
|
||||
from .services.logger import async_logger
|
||||
from .routes import health_router, chat_router, openai_router, anthropic_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
# Startup
|
||||
await init_db()
|
||||
await seed_default_providers()
|
||||
await async_logger.start()
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await async_logger.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="zzrouter",
|
||||
description="AI Model API Proxy Service",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Register routers
|
||||
app.include_router(health_router)
|
||||
app.include_router(chat_router)
|
||||
app.include_router(openai_router)
|
||||
app.include_router(anthropic_router)
|
||||
|
||||
|
||||
def run_server():
|
||||
"""Run the server (entry point for script)."""
|
||||
import uvicorn
|
||||
from .config import HOST, PORT
|
||||
uvicorn.run(app, host=HOST, port=PORT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server()
|
||||
4
backend/app/middleware/__init__.py
Normal file
4
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Middleware components."""
|
||||
from .auth import get_current_client
|
||||
|
||||
__all__ = ["get_current_client"]
|
||||
53
backend/app/middleware/auth.py
Normal file
53
backend/app/middleware/auth.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Authentication middleware."""
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from ..models import ClientKey
|
||||
from ..services.auth import AuthService
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_client(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
) -> ClientKey:
|
||||
"""Dependency to get and validate the current client.
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
|
||||
Returns:
|
||||
The validated ClientKey
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": "Missing API key"}
|
||||
)
|
||||
|
||||
client_key = await AuthService.validate_key(credentials.credentials)
|
||||
|
||||
if not client_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": "Invalid API key"}
|
||||
)
|
||||
|
||||
if not client_key.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": "API key disabled"}
|
||||
)
|
||||
|
||||
return client_key
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""Authentication middleware for request processing."""
|
||||
|
||||
def __init__(self, exclude_paths: list[str] = None):
|
||||
self.exclude_paths = exclude_paths or ["/health", "/docs", "/openapi.json"]
|
||||
|
||||
async def __call__(self, request, call_next):
|
||||
# Middleware is handled via Depends in routes
|
||||
return await call_next(request)
|
||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Database models."""
|
||||
from .client_key import ClientKey
|
||||
from .provider import Provider
|
||||
from .provider_key import ProviderKey
|
||||
from .request_log import RequestLog
|
||||
|
||||
__all__ = ["ClientKey", "Provider", "ProviderKey", "RequestLog"]
|
||||
13
backend/app/models/client_key.py
Normal file
13
backend/app/models/client_key.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Client API Key model."""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientKey:
|
||||
"""Client API key for authentication."""
|
||||
id: int
|
||||
key: str
|
||||
name: Optional[str]
|
||||
is_active: bool
|
||||
created_at: str
|
||||
13
backend/app/models/provider.py
Normal file
13
backend/app/models/provider.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Provider model."""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Provider:
|
||||
"""Model provider configuration."""
|
||||
id: int
|
||||
name: str
|
||||
base_url: str
|
||||
api_type: str
|
||||
is_active: bool
|
||||
13
backend/app/models/provider_key.py
Normal file
13
backend/app/models/provider_key.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Provider API Key model."""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderKey:
|
||||
"""API key for a provider."""
|
||||
id: int
|
||||
provider_id: int
|
||||
key: str
|
||||
is_active: bool
|
||||
created_at: str
|
||||
18
backend/app/models/request_log.py
Normal file
18
backend/app/models/request_log.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Request log model."""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestLog:
|
||||
"""Request log entry."""
|
||||
id: Optional[int]
|
||||
client_key_id: int
|
||||
provider_id: int
|
||||
model: str
|
||||
prompt_tokens: Optional[int]
|
||||
completion_tokens: Optional[int]
|
||||
latency_ms: Optional[int]
|
||||
success: bool
|
||||
error_message: Optional[str]
|
||||
created_at: Optional[str]
|
||||
4
backend/app/providers/__init__.py
Normal file
4
backend/app/providers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Provider adapters."""
|
||||
from .litellm_wrapper import LiteLLMWrapper
|
||||
|
||||
__all__ = ["LiteLLMWrapper"]
|
||||
151
backend/app/providers/litellm_wrapper.py
Normal file
151
backend/app/providers/litellm_wrapper.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""LiteLLM wrapper for unified model access."""
|
||||
import time
|
||||
import json
|
||||
from typing import AsyncIterator, Optional, Dict, Any, List
|
||||
from litellm import acompletion
|
||||
|
||||
from ..models import Provider
|
||||
from ..services.logger import async_logger, LogEntry
|
||||
|
||||
|
||||
class LiteLLMWrapper:
|
||||
"""Wrapper around LiteLLM for proxy functionality."""
|
||||
|
||||
@staticmethod
|
||||
def _build_model_string(provider: Provider, model: str) -> str:
|
||||
"""Build LiteLLM model string.
|
||||
|
||||
Args:
|
||||
provider: The provider configuration
|
||||
model: The model name
|
||||
|
||||
Returns:
|
||||
LiteLLM-compatible model string
|
||||
"""
|
||||
if provider.api_type == "anthropic":
|
||||
return f"anthropic/{model}"
|
||||
else:
|
||||
return f"openai/{model}"
|
||||
|
||||
@staticmethod
|
||||
async def chat_completion(
|
||||
provider: Provider,
|
||||
api_key: str,
|
||||
model: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
stream: bool = False,
|
||||
**kwargs
|
||||
) -> tuple[Any, int, int]:
|
||||
"""Execute a chat completion request.
|
||||
|
||||
Args:
|
||||
provider: The provider configuration
|
||||
api_key: The API key to use
|
||||
model: The model name
|
||||
messages: The chat messages
|
||||
stream: Whether to stream the response
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Tuple of (response, prompt_tokens, completion_tokens)
|
||||
"""
|
||||
model_str = LiteLLMWrapper._build_model_string(provider, model)
|
||||
|
||||
# Set API key for this request
|
||||
api_base = provider.base_url if provider.api_type == "openai" else None
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = await acompletion(
|
||||
model=model_str,
|
||||
messages=messages,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
stream=stream,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if stream:
|
||||
# For streaming, return the async iterator
|
||||
# Token counts will be collected during iteration
|
||||
return response, 0, 0
|
||||
else:
|
||||
# Extract token usage from response
|
||||
usage = getattr(response, "usage", None) or {}
|
||||
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
||||
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
|
||||
return response, prompt_tokens, completion_tokens
|
||||
|
||||
except Exception as e:
|
||||
# Re-raise with context
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def stream_response(
|
||||
stream_iterator: AsyncIterator,
|
||||
client_key_id: int,
|
||||
provider_id: int,
|
||||
model: str,
|
||||
start_time: float
|
||||
) -> AsyncIterator[str]:
|
||||
"""Process streaming response and yield SSE-formatted data.
|
||||
|
||||
Args:
|
||||
stream_iterator: The LiteLLM stream iterator
|
||||
client_key_id: Client key ID for logging
|
||||
provider_id: Provider ID for logging
|
||||
model: The model name
|
||||
start_time: Request start time
|
||||
|
||||
Yields:
|
||||
SSE-formatted strings
|
||||
"""
|
||||
prompt_tokens = 0
|
||||
completion_tokens = 0
|
||||
|
||||
try:
|
||||
async for chunk in stream_iterator:
|
||||
# Extract token counts if available
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
prompt_tokens = getattr(chunk.usage, "prompt_tokens", 0) or 0
|
||||
completion_tokens = getattr(chunk.usage, "completion_tokens", 0) or 0
|
||||
|
||||
# Convert to SSE format
|
||||
if hasattr(chunk, "model_dump"):
|
||||
chunk_dict = chunk.model_dump()
|
||||
else:
|
||||
chunk_dict = chunk
|
||||
|
||||
yield f"data: {json.dumps(chunk_dict)}\n\n"
|
||||
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
# Log successful request
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client_key_id,
|
||||
provider_id=provider_id,
|
||||
model=model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
success=True
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
# Log failed request
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client_key_id,
|
||||
provider_id=provider_id,
|
||||
model=model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
))
|
||||
|
||||
# Send error as SSE
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
8
backend/app/routes/__init__.py
Normal file
8
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""API routes."""
|
||||
from fastapi import APIRouter
|
||||
from .health import router as health_router
|
||||
from .chat import router as chat_router
|
||||
from .openai import router as openai_router
|
||||
from .anthropic import router as anthropic_router
|
||||
|
||||
__all__ = ["health_router", "chat_router", "openai_router", "anthropic_router"]
|
||||
108
backend/app/routes/anthropic.py
Normal file
108
backend/app/routes/anthropic.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Anthropic native endpoint (passthrough)."""
|
||||
import time
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..models import ClientKey
|
||||
from ..middleware.auth import get_current_client
|
||||
from ..services.router import ModelRouter
|
||||
from ..services.key_selector import key_selector
|
||||
from ..services.logger import async_logger, LogEntry
|
||||
from ..providers.litellm_wrapper import LiteLLMWrapper
|
||||
|
||||
router = APIRouter(prefix="/v1/anthropic", tags=["anthropic"])
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class AnthropicRequest(BaseModel):
|
||||
model: str
|
||||
messages: List[Message]
|
||||
max_tokens: int = 1024
|
||||
stream: bool = False
|
||||
temperature: Optional[float] = None
|
||||
|
||||
|
||||
@router.post("/messages")
|
||||
async def anthropic_messages(
|
||||
request: AnthropicRequest,
|
||||
client: ClientKey = Depends(get_current_client)
|
||||
):
|
||||
"""Anthropic native messages endpoint."""
|
||||
start_time = time.time()
|
||||
|
||||
# Get Anthropic provider
|
||||
provider = await ModelRouter.get_provider("anthropic")
|
||||
if not provider:
|
||||
raise HTTPException(status_code=503, detail={"error": "Anthropic provider not configured"})
|
||||
|
||||
# Get next API key
|
||||
provider_key = await key_selector.get_next_key(provider.id)
|
||||
if not provider_key:
|
||||
raise HTTPException(status_code=503, detail={"error": "No available Anthropic API key"})
|
||||
|
||||
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
||||
|
||||
kwargs = {"max_tokens": request.max_tokens}
|
||||
if request.temperature is not None:
|
||||
kwargs["temperature"] = request.temperature
|
||||
|
||||
try:
|
||||
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||
provider=provider,
|
||||
api_key=provider_key.key,
|
||||
model=request.model,
|
||||
messages=messages,
|
||||
stream=request.stream,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if request.stream:
|
||||
return StreamingResponse(
|
||||
LiteLLMWrapper.stream_response(
|
||||
response, client.id, provider.id, request.model, start_time
|
||||
),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
else:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client.id,
|
||||
provider_id=provider.id,
|
||||
model=request.model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
success=True
|
||||
))
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client.id,
|
||||
provider_id=provider.id,
|
||||
model=request.model,
|
||||
latency_ms=latency_ms,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
))
|
||||
raise HTTPException(status_code=500, detail={"error": str(e)})
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def anthropic_models(client: ClientKey = Depends(get_current_client)):
|
||||
"""List Anthropic models."""
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "claude-3-opus-20240229", "object": "model", "owned_by": "anthropic"},
|
||||
{"id": "claude-3-sonnet-20240229", "object": "model", "owned_by": "anthropic"},
|
||||
{"id": "claude-3-haiku-20240307", "object": "model", "owned_by": "anthropic"},
|
||||
]
|
||||
}
|
||||
135
backend/app/routes/chat.py
Normal file
135
backend/app/routes/chat.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""OpenAI-compatible chat completion endpoint."""
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..models import ClientKey
|
||||
from ..middleware.auth import get_current_client
|
||||
from ..services.router import model_router
|
||||
from ..services.key_selector import key_selector
|
||||
from ..services.logger import async_logger, LogEntry
|
||||
from ..providers.litellm_wrapper import LiteLLMWrapper
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["chat"])
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""Chat message."""
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
"""Chat completion request."""
|
||||
model: str
|
||||
messages: List[ChatMessage]
|
||||
stream: bool = False
|
||||
temperature: Optional[float] = None
|
||||
max_tokens: Optional[int] = None
|
||||
top_p: Optional[float] = None
|
||||
|
||||
|
||||
@router.post("/chat/completions")
|
||||
async def chat_completions(
|
||||
request: ChatCompletionRequest,
|
||||
client: ClientKey = Depends(get_current_client)
|
||||
):
|
||||
"""OpenAI-compatible chat completions endpoint."""
|
||||
start_time = time.time()
|
||||
|
||||
# Route model to provider
|
||||
provider, error = await model_router.route_model(request.model)
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail={"error": error})
|
||||
|
||||
# Get next API key
|
||||
provider_key = await key_selector.get_next_key(provider.id)
|
||||
if not provider_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": f"No available API key for provider: {provider.name}"}
|
||||
)
|
||||
|
||||
# Build messages
|
||||
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
||||
|
||||
# Build kwargs
|
||||
kwargs = {}
|
||||
if request.temperature is not None:
|
||||
kwargs["temperature"] = request.temperature
|
||||
if request.max_tokens is not None:
|
||||
kwargs["max_tokens"] = request.max_tokens
|
||||
if request.top_p is not None:
|
||||
kwargs["top_p"] = request.top_p
|
||||
|
||||
try:
|
||||
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||
provider=provider,
|
||||
api_key=provider_key.key,
|
||||
model=request.model,
|
||||
messages=messages,
|
||||
stream=request.stream,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if request.stream:
|
||||
return StreamingResponse(
|
||||
LiteLLMWrapper.stream_response(
|
||||
response, client.id, provider.id, request.model, start_time
|
||||
),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
else:
|
||||
# Log successful request
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client.id,
|
||||
provider_id=provider.id,
|
||||
model=request.model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
success=True
|
||||
))
|
||||
|
||||
# Return response
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log failed request
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client.id,
|
||||
provider_id=provider.id,
|
||||
model=request.model,
|
||||
latency_ms=latency_ms,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
))
|
||||
|
||||
# Handle specific errors
|
||||
error_str = str(e).lower()
|
||||
if "rate" in error_str or "limit" in error_str:
|
||||
raise HTTPException(status_code=429, detail={"error": str(e)})
|
||||
raise HTTPException(status_code=500, detail={"error": str(e)})
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models(client: ClientKey = Depends(get_current_client)):
|
||||
"""List available models."""
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "gpt-4o", "object": "model", "owned_by": "openai"},
|
||||
{"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"},
|
||||
{"id": "o1", "object": "model", "owned_by": "openai"},
|
||||
{"id": "o1-mini", "object": "model", "owned_by": "openai"},
|
||||
{"id": "claude-3-opus", "object": "model", "owned_by": "anthropic"},
|
||||
{"id": "claude-3-sonnet", "object": "model", "owned_by": "anthropic"},
|
||||
{"id": "claude-3-haiku", "object": "model", "owned_by": "anthropic"},
|
||||
]
|
||||
}
|
||||
10
backend/app/routes/health.py
Normal file
10
backend/app/routes/health.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Health check endpoint."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
111
backend/app/routes/openai.py
Normal file
111
backend/app/routes/openai.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""OpenAI native endpoint (passthrough)."""
|
||||
import time
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..models import ClientKey
|
||||
from ..middleware.auth import get_current_client
|
||||
from ..services.router import ModelRouter
|
||||
from ..services.key_selector import key_selector
|
||||
from ..services.logger import async_logger, LogEntry
|
||||
from ..providers.litellm_wrapper import LiteLLMWrapper
|
||||
|
||||
router = APIRouter(prefix="/v1/openai", tags=["openai"])
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
model: str
|
||||
messages: List[ChatMessage]
|
||||
stream: bool = False
|
||||
temperature: Optional[float] = None
|
||||
max_tokens: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/chat/completions")
|
||||
async def openai_chat(
|
||||
request: ChatRequest,
|
||||
client: ClientKey = Depends(get_current_client)
|
||||
):
|
||||
"""OpenAI native chat completions (passthrough)."""
|
||||
start_time = time.time()
|
||||
|
||||
# Get OpenAI provider
|
||||
provider = await ModelRouter.get_provider("openai")
|
||||
if not provider:
|
||||
raise HTTPException(status_code=503, detail={"error": "OpenAI provider not configured"})
|
||||
|
||||
# Get next API key
|
||||
provider_key = await key_selector.get_next_key(provider.id)
|
||||
if not provider_key:
|
||||
raise HTTPException(status_code=503, detail={"error": "No available OpenAI API key"})
|
||||
|
||||
messages = [{"role": m.role, "content": m.content} for m in request.messages]
|
||||
|
||||
kwargs = {}
|
||||
if request.temperature is not None:
|
||||
kwargs["temperature"] = request.temperature
|
||||
if request.max_tokens is not None:
|
||||
kwargs["max_tokens"] = request.max_tokens
|
||||
|
||||
try:
|
||||
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||
provider=provider,
|
||||
api_key=provider_key.key,
|
||||
model=request.model,
|
||||
messages=messages,
|
||||
stream=request.stream,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if request.stream:
|
||||
return StreamingResponse(
|
||||
LiteLLMWrapper.stream_response(
|
||||
response, client.id, provider.id, request.model, start_time
|
||||
),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
else:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client.id,
|
||||
provider_id=provider.id,
|
||||
model=request.model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
success=True
|
||||
))
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
await async_logger.log(LogEntry(
|
||||
client_key_id=client.id,
|
||||
provider_id=provider.id,
|
||||
model=request.model,
|
||||
latency_ms=latency_ms,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
))
|
||||
raise HTTPException(status_code=500, detail={"error": str(e)})
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def openai_models(client: ClientKey = Depends(get_current_client)):
|
||||
"""List OpenAI models."""
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "gpt-4o", "object": "model", "owned_by": "openai"},
|
||||
{"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"},
|
||||
{"id": "o1", "object": "model", "owned_by": "openai"},
|
||||
{"id": "o1-mini", "object": "model", "owned_by": "openai"},
|
||||
]
|
||||
}
|
||||
7
backend/app/services/__init__.py
Normal file
7
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Business logic services."""
|
||||
from .auth import AuthService
|
||||
from .key_selector import KeySelector
|
||||
from .router import ModelRouter
|
||||
from .logger import AsyncLogger
|
||||
|
||||
__all__ = ["AuthService", "KeySelector", "ModelRouter", "AsyncLogger"]
|
||||
36
backend/app/services/auth.py
Normal file
36
backend/app/services/auth.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Authentication service."""
|
||||
import aiosqlite
|
||||
from typing import Optional
|
||||
from ..database import db_connection
|
||||
from ..models import ClientKey
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for client key authentication."""
|
||||
|
||||
@staticmethod
|
||||
async def validate_key(api_key: str) -> Optional[ClientKey]:
|
||||
"""Validate a client API key.
|
||||
|
||||
Args:
|
||||
api_key: The API key to validate
|
||||
|
||||
Returns:
|
||||
ClientKey if valid and active, None otherwise
|
||||
"""
|
||||
async with db_connection() as db:
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, key, name, is_active, created_at
|
||||
FROM client_keys WHERE key = ?""",
|
||||
(api_key,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row and row["is_active"]:
|
||||
return ClientKey(
|
||||
id=row["id"],
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"]
|
||||
)
|
||||
return None
|
||||
60
backend/app/services/key_selector.py
Normal file
60
backend/app/services/key_selector.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Provider key selector with round-robin strategy."""
|
||||
import asyncio
|
||||
from typing import Optional, Dict
|
||||
from ..database import db_connection
|
||||
from ..models import ProviderKey
|
||||
|
||||
|
||||
class KeySelector:
|
||||
"""Round-robin key selector for provider API keys."""
|
||||
|
||||
def __init__(self):
|
||||
self._indices: Dict[int, int] = {} # provider_id -> current index
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_next_key(self, provider_id: int) -> Optional[ProviderKey]:
|
||||
"""Get the next available API key for a provider.
|
||||
|
||||
Args:
|
||||
provider_id: The provider ID
|
||||
|
||||
Returns:
|
||||
ProviderKey if available, None if no keys configured
|
||||
"""
|
||||
async with self._lock:
|
||||
async with db_connection() as db:
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, provider_id, key, is_active, created_at
|
||||
FROM provider_keys
|
||||
WHERE provider_id = ? AND is_active = TRUE""",
|
||||
(provider_id,)
|
||||
)
|
||||
keys = await cursor.fetchall()
|
||||
|
||||
if not keys:
|
||||
return None
|
||||
|
||||
# Get or initialize index
|
||||
if provider_id not in self._indices:
|
||||
self._indices[provider_id] = 0
|
||||
|
||||
# Round-robin selection
|
||||
index = self._indices[provider_id] % len(keys)
|
||||
self._indices[provider_id] = index + 1
|
||||
|
||||
row = keys[index]
|
||||
return ProviderKey(
|
||||
id=row["id"],
|
||||
provider_id=row["provider_id"],
|
||||
key=row["key"],
|
||||
is_active=row["is_active"],
|
||||
created_at=row["created_at"]
|
||||
)
|
||||
|
||||
def reset_index(self, provider_id: int):
|
||||
"""Reset the round-robin index for a provider."""
|
||||
self._indices.pop(provider_id, None)
|
||||
|
||||
|
||||
# Global instance
|
||||
key_selector = KeySelector()
|
||||
99
backend/app/services/logger.py
Normal file
99
backend/app/services/logger.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Async batch logger for request logs."""
|
||||
import asyncio
|
||||
from typing import Optional, List
|
||||
from dataclasses import dataclass
|
||||
from ..database import db_connection
|
||||
from ..config import LOG_BATCH_SIZE, LOG_FLUSH_INTERVAL
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEntry:
|
||||
"""A single log entry."""
|
||||
client_key_id: int
|
||||
provider_id: int
|
||||
model: str
|
||||
prompt_tokens: Optional[int] = None
|
||||
completion_tokens: Optional[int] = None
|
||||
latency_ms: Optional[int] = None
|
||||
success: bool = True
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class AsyncLogger:
|
||||
"""Asynchronous batch logger for request logs."""
|
||||
|
||||
def __init__(self):
|
||||
self._queue: asyncio.Queue[LogEntry] = asyncio.Queue()
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
||||
async def start(self):
|
||||
"""Start the background flush task."""
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._flush_loop())
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the background flush task."""
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
# Flush remaining logs
|
||||
await self._flush_all()
|
||||
|
||||
async def log(self, entry: LogEntry):
|
||||
"""Queue a log entry."""
|
||||
await self._queue.put(entry)
|
||||
|
||||
async def _flush_loop(self):
|
||||
"""Background loop to flush logs periodically."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(LOG_FLUSH_INTERVAL)
|
||||
await self._flush_batch()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async def _flush_batch(self):
|
||||
"""Flush a batch of logs to database."""
|
||||
entries: List[LogEntry] = []
|
||||
|
||||
# Collect up to batch_size entries
|
||||
while len(entries) < LOG_BATCH_SIZE:
|
||||
try:
|
||||
entry = self._queue.get_nowait()
|
||||
entries.append(entry)
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
if not entries:
|
||||
return
|
||||
|
||||
# Insert batch
|
||||
async with db_connection() as db:
|
||||
await db.executemany(
|
||||
"""INSERT INTO request_logs
|
||||
(client_key_id, provider_id, model, prompt_tokens,
|
||||
completion_tokens, latency_ms, success, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
(
|
||||
e.client_key_id, e.provider_id, e.model,
|
||||
e.prompt_tokens, e.completion_tokens,
|
||||
e.latency_ms, e.success, e.error_message
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def _flush_all(self):
|
||||
"""Flush all remaining logs."""
|
||||
while not self._queue.empty():
|
||||
await self._flush_batch()
|
||||
|
||||
|
||||
# Global instance
|
||||
async_logger = AsyncLogger()
|
||||
76
backend/app/services/router.py
Normal file
76
backend/app/services/router.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Model to provider router."""
|
||||
from typing import Optional, Tuple
|
||||
from ..database import db_connection
|
||||
from ..models import Provider
|
||||
from ..config import MODEL_PROVIDER_MAP
|
||||
|
||||
|
||||
class ModelRouter:
|
||||
"""Routes model names to providers."""
|
||||
|
||||
@staticmethod
|
||||
def get_provider_name(model: str) -> Optional[str]:
|
||||
"""Determine provider name from model name.
|
||||
|
||||
Args:
|
||||
model: The model name (e.g., 'gpt-4o', 'claude-3-opus')
|
||||
|
||||
Returns:
|
||||
Provider name or None if unknown
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
for prefix, provider in MODEL_PROVIDER_MAP.items():
|
||||
if model_lower.startswith(prefix):
|
||||
return provider
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_provider(provider_name: str) -> Optional[Provider]:
|
||||
"""Get provider configuration by name.
|
||||
|
||||
Args:
|
||||
provider_name: The provider name (e.g., 'openai', 'anthropic')
|
||||
|
||||
Returns:
|
||||
Provider if found and active, None otherwise
|
||||
"""
|
||||
async with db_connection() as db:
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, name, base_url, api_type, is_active
|
||||
FROM providers WHERE name = ? AND is_active = TRUE""",
|
||||
(provider_name,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return Provider(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
base_url=row["base_url"],
|
||||
api_type=row["api_type"],
|
||||
is_active=row["is_active"]
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def route_model(model: str) -> Tuple[Optional[Provider], Optional[str]]:
|
||||
"""Route a model to its provider.
|
||||
|
||||
Args:
|
||||
model: The model name
|
||||
|
||||
Returns:
|
||||
Tuple of (Provider, error_message)
|
||||
"""
|
||||
provider_name = ModelRouter.get_provider_name(model)
|
||||
if not provider_name:
|
||||
return None, f"Unsupported model: {model}"
|
||||
|
||||
provider = await ModelRouter.get_provider(provider_name)
|
||||
if not provider:
|
||||
return None, f"Provider not found: {provider_name}"
|
||||
|
||||
return provider, None
|
||||
|
||||
|
||||
# Global instance
|
||||
model_router = ModelRouter()
|
||||
BIN
backend/data/zzrouter.db
Normal file
BIN
backend/data/zzrouter.db
Normal file
Binary file not shown.
5
backend/main.py
Normal file
5
backend/main.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Server entry point."""
|
||||
from app.main import run_server
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server()
|
||||
16
backend/pyproject.toml
Normal file
16
backend/pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "zzrouter-backend"
|
||||
version = "0.1.0"
|
||||
description = "AI Model API Proxy Service"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.135.1",
|
||||
"uvicorn[standard]>=0.41.0",
|
||||
"litellm>=1.50.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
zzrouter = "app.main:run_server"
|
||||
1566
backend/uv.lock
generated
Normal file
1566
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
1617
docs/superpowers/plans/2026-03-11-model-proxy.md
Normal file
1617
docs/superpowers/plans/2026-03-11-model-proxy.md
Normal file
File diff suppressed because it is too large
Load Diff
240
docs/superpowers/specs/2026-03-11-model-proxy-design.md
Normal file
240
docs/superpowers/specs/2026-03-11-model-proxy-design.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# zzrouter - 模型 API 代理服务设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
zzrouter 是一个 AI 模型 API 代理服务,支持多个模型提供商的统一访问,并提供 API Key 轮换、请求日志等功能。
|
||||
|
||||
## 核心需求
|
||||
|
||||
| 项目 | 决策 |
|
||||
|------|------|
|
||||
| 模型提供商 | OpenAI 兼容格式 + Anthropic |
|
||||
| API Key 轮换策略 | 轮询 (Round-robin) |
|
||||
| 客户端 Key 存储 | SQLite 数据库 |
|
||||
| 提供商 Key 存储 | SQLite 数据库 |
|
||||
| 对外 API 格式 | OpenAI 兼容 + 原生格式双支持 |
|
||||
| 流式响应 | 支持 (SSE) |
|
||||
| 管理方式 | 直接操作数据库 |
|
||||
| 日志统计 | 完整版 (token、延迟、错误) |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Client │
|
||||
└──────┬──────┘
|
||||
│ API Key 认证
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ FastAPI 服务 │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 认证中间件 │ │
|
||||
│ └───────────┬─────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 路由层 │ │
|
||||
│ │ /v1/chat/* (OpenAI) │ │
|
||||
│ │ /v1/anthropic/* │ │
|
||||
│ └───────────┬─────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ LiteLLM + Key 轮询 │ │
|
||||
│ └───────────┬─────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 日志记录 (异步批量) │ │
|
||||
│ └─────────────────────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ SQLite │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## 数据库结构
|
||||
|
||||
```sql
|
||||
-- 客户端 API Key
|
||||
CREATE TABLE client_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 模型提供商
|
||||
CREATE TABLE providers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
api_type TEXT NOT NULL, -- 'openai' 或 'anthropic'
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- 提供商 API Key (一对多)
|
||||
CREATE TABLE provider_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
provider_id INTEGER REFERENCES providers(id),
|
||||
key TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 请求日志
|
||||
CREATE TABLE request_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
client_key_id INTEGER REFERENCES client_keys(id),
|
||||
provider_id INTEGER REFERENCES providers(id),
|
||||
model TEXT,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
latency_ms INTEGER,
|
||||
success BOOLEAN,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## API 路由
|
||||
|
||||
```
|
||||
认证方式: Bearer Token (Authorization: Bearer <client_key>)
|
||||
|
||||
路由结构:
|
||||
├── /v1/chat/completions # OpenAI 兼容格式 (自动路由)
|
||||
├── /v1/models # 列出可用模型
|
||||
│
|
||||
├── /v1/openai/chat/completations # OpenAI 原生格式 (透传)
|
||||
├── /v1/openai/models
|
||||
│
|
||||
├── /v1/anthropic/messages # Anthropic 原生格式 (透传)
|
||||
├── /v1/anthropic/models
|
||||
│
|
||||
└── /health # 健康检查
|
||||
```
|
||||
|
||||
### 模型映射逻辑
|
||||
|
||||
用于 `/v1/chat/completions` 根据模型名自动路由:
|
||||
- `gpt-*`, `o1-*` → OpenAI
|
||||
- `claude-*` → Anthropic
|
||||
- 其他可配置映射
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── main.py # 入口
|
||||
├── pyproject.toml
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI 应用
|
||||
│ ├── config.py # 配置
|
||||
│ ├── database.py # 数据库连接
|
||||
│ │
|
||||
│ ├── models/ # 数据库模型
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── client_key.py
|
||||
│ │ ├── provider.py
|
||||
│ │ ├── provider_key.py
|
||||
│ │ └── request_log.py
|
||||
│ │
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── auth.py # 客户端 Key 验证
|
||||
│ │ ├── key_selector.py # Key 轮询选择
|
||||
│ │ ├── logger.py # 异步日志写入
|
||||
│ │ └── router.py # 模型路由逻辑
|
||||
│ │
|
||||
│ ├── providers/ # 提供商适配器
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── litellm_wrapper.py # LiteLLM 封装
|
||||
│ │
|
||||
│ └── routes/ # API 路由
|
||||
│ ├── __init__.py
|
||||
│ ├── chat.py
|
||||
│ ├── openai.py
|
||||
│ ├── anthropic.py
|
||||
│ └── health.py
|
||||
│
|
||||
frontend/ # 暂时空置
|
||||
```
|
||||
|
||||
## 请求流程
|
||||
|
||||
```
|
||||
Client Request: POST /v1/chat/completions
|
||||
Headers: Authorization: Bearer <client_key>
|
||||
Body: { "model": "claude-3-opus", "messages": [...], "stream": true }
|
||||
│
|
||||
▼
|
||||
1. Auth Middleware - 验证 client_key
|
||||
│
|
||||
▼
|
||||
2. Router Service - model → provider 映射
|
||||
│
|
||||
▼
|
||||
3. Key Selector - 轮询选择 provider_key
|
||||
│
|
||||
▼
|
||||
4. LiteLLM Wrapper - 执行请求
|
||||
│
|
||||
▼
|
||||
5. Response Handler - 流式/非流式响应
|
||||
│
|
||||
▼
|
||||
6. Async Logger - 批量写入日志
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 认证错误
|
||||
| 场景 | 响应 |
|
||||
|------|------|
|
||||
| 缺少 Authorization | 401 `{"error": "Missing API key"}` |
|
||||
| 无效的 client_key | 401 `{"error": "Invalid API key"}` |
|
||||
| client_key 已禁用 | 403 `{"error": "API key disabled"}` |
|
||||
|
||||
### 提供商错误
|
||||
| 场景 | 响应 |
|
||||
|------|------|
|
||||
| 模型不存在/不支持 | 400 `{"error": "Unsupported model: xxx"}` |
|
||||
| 提供商无可用 Key | 503 `{"error": "No available API key for provider"}` |
|
||||
| 上游 API 限流 | 429 转发上游错误 |
|
||||
| 上游 API 错误 | 500 转发上游错误 |
|
||||
|
||||
### 流式响应错误
|
||||
- 中途失败 → SSE `data: {"error": "..."}\n\n` 后关闭连接
|
||||
- 记录部分成功的 token 使用量
|
||||
|
||||
## 依赖
|
||||
|
||||
```toml
|
||||
dependencies = [
|
||||
"fastapi>=0.135.1",
|
||||
"uvicorn[standard]>=0.41.0",
|
||||
"litellm>=1.50.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
]
|
||||
```
|
||||
|
||||
## 初始化示例
|
||||
|
||||
```sql
|
||||
-- 预置提供商
|
||||
INSERT INTO providers (name, base_url, api_type) VALUES
|
||||
('openai', 'https://api.openai.com/v1', 'openai'),
|
||||
('anthropic', 'https://api.anthropic.com/v1', 'anthropic');
|
||||
|
||||
-- 添加提供商 API Key
|
||||
INSERT INTO provider_keys (provider_id, key) VALUES
|
||||
(1, 'sk-xxx...'), -- OpenAI key 1
|
||||
(1, 'sk-yyy...'), -- OpenAI key 2
|
||||
(2, 'sk-ant-xxx...'); -- Anthropic key
|
||||
|
||||
-- 添加客户端 Key
|
||||
INSERT INTO client_keys (key, name) VALUES
|
||||
('zzrouter-client-xxx', 'my-laptop');
|
||||
```
|
||||
Reference in New Issue
Block a user