feat: implement AI model API proxy service

This commit is contained in:
2026-03-11 18:22:16 +08:00
commit 26738973bd
33 changed files with 4607 additions and 0 deletions

71
CLAUDE.md Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
3.13

0
backend/README.md Normal file
View File

4
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""zzrouter backend application."""
from .main import app
__all__ = ["app"]

21
backend/app/config.py Normal file
View 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
View 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
View 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()

View File

@@ -0,0 +1,4 @@
"""Middleware components."""
from .auth import get_current_client
__all__ = ["get_current_client"]

View 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)

View 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"]

View 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

View 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

View 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

View 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]

View File

@@ -0,0 +1,4 @@
"""Provider adapters."""
from .litellm_wrapper import LiteLLMWrapper
__all__ = ["LiteLLMWrapper"]

View 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"

View 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"]

View 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
View 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"},
]
}

View 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"}

View 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"},
]
}

View 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"]

View 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

View 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()

View 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()

View 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

Binary file not shown.

5
backend/main.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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');
```