Init
This commit is contained in:
commit
562f6ecd9c
137
README.md
Normal file
137
README.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Ollama Proxy mit API-Keys und Quotas
|
||||||
|
|
||||||
|
Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung und Quota-Management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔑 API-Key-Authentifizierung (Bearer Token oder `sk-` Prefix)
|
||||||
|
- 📊 Quota-Management (Tokens & Requests pro Tag/Monat)
|
||||||
|
- 📈 Usage-Tracking in Echtzeit
|
||||||
|
- 🖥️ Web-Admin-Oberfläche für User & Quotas
|
||||||
|
- 🔒 Benutzer-Management
|
||||||
|
|
||||||
|
## Installation & Start
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- PostgreSQL 16+
|
||||||
|
- Node.js 18+ (für Frontend)
|
||||||
|
|
||||||
|
### lokal mit SQLite (Entwicklung)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
python init_db.py
|
||||||
|
python setup_admin.py
|
||||||
|
uvicorn main:app --reload
|
||||||
|
|
||||||
|
# Frontend (in neuem Terminal)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### mit PostgreSQL & Docker (Produktion)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose exec backend python init_db.py
|
||||||
|
docker compose exec backend python setup_admin.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Mit API-Key authentifizieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/generate \
|
||||||
|
-H "Authorization: sk-xxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"llama3","prompt":"Say hello"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder mit Bearer Token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/generate \
|
||||||
|
-H "Authorization: Bearer sk-xxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"llama3","prompt":"Say hello"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/users` | GET | Liste aller User |
|
||||||
|
| `/api/users` | POST | Neuen User erstellen |
|
||||||
|
| `/api/api-keys` | GET | Liste aller API-Keys |
|
||||||
|
| `/api/api-keys` | POST | Neuen API-Key erstellen |
|
||||||
|
| `/api/api-keys/{id}/deactivate` | PUT | API-Key deaktivieren |
|
||||||
|
| `/api/quotas/{user_id}` | PUT | Quota für User setzen |
|
||||||
|
|
||||||
|
### Quota-Beispiele
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Quota setzen (per PUT /api/quotas/{user_id})
|
||||||
|
{
|
||||||
|
"daily_tokens": 1000000,
|
||||||
|
"monthly_tokens": 10000000,
|
||||||
|
"daily_requests": 1000,
|
||||||
|
"monthly_requests": 10000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Umgebungsvariablen
|
||||||
|
|
||||||
|
Im `backend/` Verzeichnis `.env` Datei erstellen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OLLAMA_URL=http://ollama:11434
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||||
|
SECRET_KEY=your-secret-key-min-32-chars
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
`docker-compose.yml` anpassen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://ollama:password@db:5432/ollama_proxy
|
||||||
|
- OLLAMA_URL=http://ollama:11434
|
||||||
|
- SECRET_KEY=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
llm_quota/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # Proxy-Server (Ollama API forwarden)
|
||||||
|
│ ├── admin.py # Admin API (User/Quota Management)
|
||||||
|
│ ├── database.py # DB-Verbindung & Session
|
||||||
|
│ ├── models.py # SQLAlchemy Models
|
||||||
|
│ ├── schemas.py # Pydantic Schemas
|
||||||
|
│ ├── crud.py # Database Operations
|
||||||
|
│ ├── types.py # Response Types
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── setup_admin.py # Admin User erstellen
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.jsx
|
||||||
|
│ │ └── styles.css
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.js
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT
|
||||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Ollama Proxy Backend."""
|
||||||
112
backend/admin.py
Normal file
112
backend/admin.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from database import get_db
|
||||||
|
import crud, schemas
|
||||||
|
from models import User, APIKey, Quota
|
||||||
|
|
||||||
|
app = FastAPI(title="Ollama Proxy Admin API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def require_admin_auth(request: Request):
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
api_key = auth_header.replace("Bearer ", "")
|
||||||
|
elif auth_header.startswith("sk-"):
|
||||||
|
api_key = auth_header
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
db_key = crud.verify_api_key(db, api_key)
|
||||||
|
|
||||||
|
if not db_key:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
|
||||||
|
db_user = db.query(User).filter(User.id == db_key.user_id).first()
|
||||||
|
if not db_user or db_user.username != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
|
||||||
|
request.state.user = db_user
|
||||||
|
request.state.db = db
|
||||||
|
|
||||||
|
@app.get("/api/users", response_model=list[schemas.User])
|
||||||
|
async def read_users(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(require_admin_auth)
|
||||||
|
):
|
||||||
|
users = db.query(User).offset(skip).limit(limit).all()
|
||||||
|
return users
|
||||||
|
|
||||||
|
@app.post("/api/users", response_model=schemas.User)
|
||||||
|
async def create_user(
|
||||||
|
user: schemas.UserCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(require_admin_auth)
|
||||||
|
):
|
||||||
|
db_user = crud.get_user_by_username(db, username=user.username)
|
||||||
|
if db_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already registered")
|
||||||
|
db_user = crud.get_user_by_email(db, email=user.email)
|
||||||
|
if db_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
return crud.create_user(db=db, username=user.username, email=user.email, password=user.password)
|
||||||
|
|
||||||
|
@app.post("/api/api-keys", response_model=schemas.APIKey)
|
||||||
|
async def create_api_key(
|
||||||
|
api_key: schemas.APIKeyCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(require_admin_auth)
|
||||||
|
):
|
||||||
|
db_user = db.query(User).filter(User.id == api_key.user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return crud.create_api_key(db=db, user_id=api_key.user_id, name=api_key.name)
|
||||||
|
|
||||||
|
@app.get("/api/api-keys", response_model=list[schemas.APIKey])
|
||||||
|
async def read_api_keys(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(require_admin_auth)
|
||||||
|
):
|
||||||
|
api_keys = db.query(APIKey).offset(skip).limit(limit).all()
|
||||||
|
return api_keys
|
||||||
|
|
||||||
|
@app.put("/api/api-keys/{api_key_id}/deactivate")
|
||||||
|
async def deactivate_api_key(
|
||||||
|
api_key_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(require_admin_auth)
|
||||||
|
):
|
||||||
|
db_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
|
||||||
|
if not db_key:
|
||||||
|
raise HTTPException(status_code=404, detail="API key not found")
|
||||||
|
db_key.is_active = False
|
||||||
|
db.commit()
|
||||||
|
return {"message": "API key deactivated"}
|
||||||
|
|
||||||
|
@app.put("/api/quotas/{user_id}", response_model=schemas.Quota)
|
||||||
|
async def update_quota(
|
||||||
|
user_id: int,
|
||||||
|
quota: schemas.QuotaCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(require_admin_auth)
|
||||||
|
):
|
||||||
|
db_quota = db.query(Quota).filter(Quota.user_id == user_id).first()
|
||||||
|
if not db_quota:
|
||||||
|
raise HTTPException(status_code=404, detail="Quota not found")
|
||||||
|
for key, value in quota.dict(exclude_unset=True).items():
|
||||||
|
setattr(db_quota, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_quota)
|
||||||
|
return db_quota
|
||||||
95
backend/crud.py
Normal file
95
backend/crud.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from database import get_db
|
||||||
|
from models import APIKey, User, Quota, Usage
|
||||||
|
|
||||||
|
def get_user_by_username(db: Session, username: str):
|
||||||
|
return db.query(User).filter(User.username == username).first()
|
||||||
|
|
||||||
|
def get_user_by_email(db: Session, email: str):
|
||||||
|
return db.query(User).filter(User.email == email).first()
|
||||||
|
|
||||||
|
def generate_api_key():
|
||||||
|
return "sk-" + secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
def hash_password(password: str):
|
||||||
|
return hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
def create_user(db: Session, username: str, email: str, password: str):
|
||||||
|
db_user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
hashed_password=hash_password(password)
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
|
||||||
|
default_quota = Quota(
|
||||||
|
user_id=db_user.id,
|
||||||
|
daily_tokens=1000000,
|
||||||
|
monthly_tokens=10000000,
|
||||||
|
daily_requests=1000,
|
||||||
|
monthly_requests=10000
|
||||||
|
)
|
||||||
|
db.add(default_quota)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
def create_api_key(db: Session, user_id: int, name: str):
|
||||||
|
key = generate_api_key()
|
||||||
|
db_key = APIKey(
|
||||||
|
name=name,
|
||||||
|
key=key,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
db.add(db_key)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_key)
|
||||||
|
return db_key
|
||||||
|
|
||||||
|
def verify_api_key(db: Session, api_key: str):
|
||||||
|
return db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first()
|
||||||
|
|
||||||
|
def get_quota(db: Session, user_id: int):
|
||||||
|
return db.query(Quota).filter(Quota.user_id == user_id).first()
|
||||||
|
|
||||||
|
def check_quota(db: Session, user_id: int, tokens: int = 0, requests: int = 1):
|
||||||
|
quota = get_quota(db, user_id)
|
||||||
|
usage = get_usage(db, user_id)
|
||||||
|
|
||||||
|
if quota.daily_tokens and (usage.tokens_used + tokens) > quota.daily_tokens:
|
||||||
|
return False
|
||||||
|
if quota.monthly_tokens and (usage.tokens_used + tokens) > quota.monthly_tokens:
|
||||||
|
return False
|
||||||
|
if quota.daily_requests and (usage.requests_count + requests) > quota.daily_requests:
|
||||||
|
return False
|
||||||
|
if quota.monthly_requests and (usage.requests_count + requests) > quota.monthly_requests:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def increment_usage(db: Session, user_id: int, tokens: int = 0, requests: int = 1):
|
||||||
|
usage = get_or_create_usage(db, user_id)
|
||||||
|
usage.tokens_used += tokens
|
||||||
|
usage.requests_count += requests
|
||||||
|
db.commit()
|
||||||
|
db.refresh(usage)
|
||||||
|
|
||||||
|
def get_usage(db: Session, user_id: int):
|
||||||
|
return db.query(Usage).filter(Usage.user_id == user_id).first()
|
||||||
|
|
||||||
|
def get_quota_by_user_id(db: Session, user_id: int):
|
||||||
|
return db.query(Quota).filter(Quota.user_id == user_id).first()
|
||||||
|
|
||||||
|
def get_or_create_usage(db: Session, user_id: int):
|
||||||
|
usage = get_usage(db, user_id)
|
||||||
|
if not usage:
|
||||||
|
usage = Usage(user_id=user_id)
|
||||||
|
db.add(usage)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(usage)
|
||||||
|
return usage
|
||||||
24
backend/database.py
Normal file
24
backend/database.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://ollama:password@localhost:5432/ollama_proxy")
|
||||||
|
if "sqlite" in DATABASE_URL:
|
||||||
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
else:
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
except:
|
||||||
|
DATABASE_URL = "sqlite:///./test.db"
|
||||||
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
24
backend/init_db.py
Normal file
24
backend/init_db.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from database import Base
|
||||||
|
import models
|
||||||
|
import os
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "sqlite" in SQLALCHEMY_DATABASE_URL:
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
else:
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL)
|
||||||
|
except:
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
print("Database tables created successfully!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
148
backend/main.py
Normal file
148
backend/main.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Depends, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from database import get_db
|
||||||
|
import crud
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
|
||||||
|
app = FastAPI(title="Ollama Proxy")
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
|
||||||
|
async def proxy_request(url: str, method: str = "GET", json_data: dict = None, headers: dict = None):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.request(method=method, url=url, json=json_data, headers=headers)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def authenticate_and_quota(request: Request, call_next):
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
api_key = auth_header.replace("Bearer ", "")
|
||||||
|
elif auth_header.startswith("sk-"):
|
||||||
|
api_key = auth_header
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
db_key = crud.verify_api_key(db, api_key)
|
||||||
|
|
||||||
|
if not db_key:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
|
||||||
|
if not db_key.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="API key deactivated")
|
||||||
|
|
||||||
|
request.state.user_id = db_key.user_id
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.post("/api/generate")
|
||||||
|
async def generate(request: Request):
|
||||||
|
db = next(get_db())
|
||||||
|
user_id = request.state.user_id
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
prompt_tokens = len(body.get("prompt", "").split())
|
||||||
|
if not crud.check_quota(db, user_id, tokens=prompt_tokens, requests=1):
|
||||||
|
raise HTTPException(status_code=429, detail="Quota exceeded")
|
||||||
|
|
||||||
|
response = await proxy_request(f"{OLLAMA_URL}/api/generate", method="POST", json_data=body, headers=dict(request.headers))
|
||||||
|
|
||||||
|
crud.increment_usage(db, user_id, tokens=prompt_tokens, requests=1)
|
||||||
|
|
||||||
|
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
|
||||||
|
|
||||||
|
@app.post("/api/chat")
|
||||||
|
async def chat(request: Request):
|
||||||
|
db = next(get_db())
|
||||||
|
user_id = request.state.user_id
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
prompt_tokens = sum(len(msg.get("content", "").split()) for msg in body.get("messages", []))
|
||||||
|
if not crud.check_quota(db, user_id, tokens=prompt_tokens, requests=1):
|
||||||
|
raise HTTPException(status_code=429, detail="Quota exceeded")
|
||||||
|
|
||||||
|
response = await proxy_request(f"{OLLAMA_URL}/api/chat", method="POST", json_data=body, headers=dict(request.headers))
|
||||||
|
|
||||||
|
crud.increment_usage(db, user_id, tokens=prompt_tokens, requests=1)
|
||||||
|
|
||||||
|
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
|
||||||
|
|
||||||
|
@app.get("/api/tags")
|
||||||
|
async def list_models(request: Request):
|
||||||
|
response = await proxy_request(f"{OLLAMA_URL}/api/tags", method="GET", headers=dict(request.headers))
|
||||||
|
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
|
||||||
|
|
||||||
|
@app.get("/api/versions")
|
||||||
|
async def versions(request: Request):
|
||||||
|
response = await proxy_request(f"{OLLAMA_URL}/api/versions", method="GET", headers=dict(request.headers))
|
||||||
|
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_openai_models(request: Request):
|
||||||
|
response = await proxy_request(f"{OLLAMA_URL}/api/tags", method="GET", headers=dict(request.headers))
|
||||||
|
ollama_models = response.json()
|
||||||
|
openai_models = {
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": model["name"],
|
||||||
|
"object": "model",
|
||||||
|
"created": int(model["modified_at"][:10].replace("-", "")) * 1000 if "modified_at" in model else 0,
|
||||||
|
"owned_by": "ollama"
|
||||||
|
}
|
||||||
|
for model in ollama_models.get("models", [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return JSONResponse(content=openai_models, status_code=200, headers=dict(response.headers))
|
||||||
|
|
||||||
|
@app.post("/v1/chat/completions")
|
||||||
|
async def openai_chat_completions(request: Request):
|
||||||
|
db = next(get_db())
|
||||||
|
user_id = request.state.user_id
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
messages = body.get("messages", [])
|
||||||
|
prompt_tokens = sum(len(msg.get("content", "").split()) for msg in messages)
|
||||||
|
|
||||||
|
if not crud.check_quota(db, user_id, tokens=prompt_tokens, requests=1):
|
||||||
|
raise HTTPException(status_code=429, detail="Quota exceeded")
|
||||||
|
|
||||||
|
ollama_body = {
|
||||||
|
"model": body.get("model", "llama3"),
|
||||||
|
"messages": messages,
|
||||||
|
"stream": body.get("stream", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await proxy_request(f"{OLLAMA_URL}/api/chat", method="POST", json_data=ollama_body, headers=dict(request.headers))
|
||||||
|
|
||||||
|
crud.increment_usage(db, user_id, tokens=prompt_tokens, requests=1)
|
||||||
|
|
||||||
|
openai_response = {
|
||||||
|
"id": f"chatcmpl-{hash(msg.get('content', ''))}",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": int(__import__('time').time()),
|
||||||
|
"model": body.get("model", "llama3"),
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": response.json().get("message", {}).get("content", "")
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": prompt_tokens,
|
||||||
|
"completion_tokens": len(response.json().get("message", {}).get("content", "").split()),
|
||||||
|
"total_tokens": prompt_tokens + len(response.json().get("message", {}).get("content", "").split())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSONResponse(content=openai_response, status_code=200, headers={"Content-Type": "application/json"})
|
||||||
43
backend/models.py
Normal file
43
backend/models.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, BigInteger
|
||||||
|
from datetime import datetime
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, index=True)
|
||||||
|
email = Column(String, unique=True, index=True)
|
||||||
|
hashed_password = Column(String)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
class APIKey(Base):
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String)
|
||||||
|
key = Column(String, unique=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
class Quota(Base):
|
||||||
|
__tablename__ = "quotas"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
daily_tokens = Column(BigInteger, nullable=True)
|
||||||
|
monthly_tokens = Column(BigInteger, nullable=True)
|
||||||
|
daily_requests = Column(Integer, nullable=True)
|
||||||
|
monthly_requests = Column(Integer, nullable=True)
|
||||||
|
reset_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
class Usage(Base):
|
||||||
|
__tablename__ = "usage"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), unique=True)
|
||||||
|
tokens_used = Column(BigInteger, default=0)
|
||||||
|
requests_count = Column(Integer, default=0)
|
||||||
|
reset_at = Column(DateTime, default=datetime.utcnow)
|
||||||
5
backend/models_base.py
Normal file
5
backend/models_base.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, BigInteger
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Base:
|
||||||
|
pass
|
||||||
4
backend/pytest.ini
Normal file
4
backend/pytest.ini
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
python_files = "test_*.py"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-v --cov=. --cov-report=term-missing --cov-report=html"
|
||||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
httpx==0.28.1
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
alembic==1.14.0
|
||||||
|
pydantic==2.10.3
|
||||||
|
python-multipart==0.0.20
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-asyncio==0.25.1
|
||||||
|
pytest-cov==6.0.0
|
||||||
59
backend/schemas.py
Normal file
59
backend/schemas.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class User(UserBase):
|
||||||
|
id: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class APIKeyBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class APIKeyCreate(APIKeyBase):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
class APIKey(APIKeyBase):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
user_id: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class QuotaBase(BaseModel):
|
||||||
|
daily_tokens: Optional[int] = None
|
||||||
|
monthly_tokens: Optional[int] = None
|
||||||
|
daily_requests: Optional[int] = None
|
||||||
|
monthly_requests: Optional[int] = None
|
||||||
|
|
||||||
|
class QuotaCreate(QuotaBase):
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
class Quota(QuotaBase):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
reset_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class UsageStats(BaseModel):
|
||||||
|
tokens_used: int = 0
|
||||||
|
requests_count: int = 0
|
||||||
|
last_reset: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
52
backend/schemas_types.py
Normal file
52
backend/schemas_types.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class TokenUsage(BaseModel):
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
|
||||||
|
class GenerateResponse(BaseModel):
|
||||||
|
model: str
|
||||||
|
created_at: str
|
||||||
|
response: str
|
||||||
|
done: bool
|
||||||
|
done_reason: Optional[str] = None
|
||||||
|
total_duration: Optional[int] = None
|
||||||
|
load_duration: Optional[int] = None
|
||||||
|
prompt_eval_count: Optional[int] = None
|
||||||
|
prompt_eval_duration: Optional[int] = None
|
||||||
|
eval_count: Optional[int] = None
|
||||||
|
eval_duration: Optional[int] = None
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
model: str
|
||||||
|
messages: list[ChatMessage]
|
||||||
|
stream: Optional[bool] = False
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
model: str
|
||||||
|
created_at: str
|
||||||
|
message: ChatMessage
|
||||||
|
done: bool
|
||||||
|
done_reason: Optional[str] = None
|
||||||
|
total_duration: Optional[int] = None
|
||||||
|
load_duration: Optional[int] = None
|
||||||
|
prompt_eval_count: Optional[int] = None
|
||||||
|
prompt_eval_duration: Optional[int] = None
|
||||||
|
eval_count: Optional[int] = None
|
||||||
|
eval_duration: Optional[int] = None
|
||||||
|
|
||||||
|
class ModelInfo(BaseModel):
|
||||||
|
name: str
|
||||||
|
modified_at: str
|
||||||
|
size: int
|
||||||
|
digest: str
|
||||||
|
details: dict
|
||||||
|
|
||||||
|
class TagListResponse(BaseModel):
|
||||||
|
models: list[ModelInfo]
|
||||||
41
backend/setup_admin.py
Normal file
41
backend/setup_admin.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from database import Base, engine, SessionLocal
|
||||||
|
from models import User, APIKey, Quota, Usage
|
||||||
|
from crud import create_user, create_api_key, hash_password
|
||||||
|
|
||||||
|
def setup_admin():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
admin_user = db.query(User).filter(User.username == "admin").first()
|
||||||
|
if not admin_user:
|
||||||
|
admin_user = User(
|
||||||
|
username="admin",
|
||||||
|
email="admin@ollama.local",
|
||||||
|
hashed_password=hash_password("admin123"),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(admin_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin_user)
|
||||||
|
print("✓ Admin user created")
|
||||||
|
|
||||||
|
default_quota = Quota(
|
||||||
|
user_id=admin_user.id,
|
||||||
|
daily_tokens=10000000,
|
||||||
|
monthly_tokens=100000000,
|
||||||
|
daily_requests=10000,
|
||||||
|
monthly_requests=100000
|
||||||
|
)
|
||||||
|
db.add(default_quota)
|
||||||
|
db.commit()
|
||||||
|
print("✓ Admin quota created")
|
||||||
|
|
||||||
|
api_key = create_api_key(db, admin_user.id, "admin-api-key")
|
||||||
|
print(f"✓ Admin API Key: {api_key.key}")
|
||||||
|
else:
|
||||||
|
print("✗ Admin user already exists")
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
setup_admin()
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://ollama:password@db:5432/ollama_proxy
|
||||||
|
- OLLAMA_URL=http://ollama:11434
|
||||||
|
- SECRET_KEY=your-secret-key-change-me
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=ollama
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=ollama_proxy
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ollama Proxy Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "ollama-proxy",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
85
frontend/src/main.jsx
Normal file
85
frontend/src/main.jsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [apiKeys, setApiKeys] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
fetchApiKeys();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
const res = await axios.get('/api/users');
|
||||||
|
setUsers(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchApiKeys = async () => {
|
||||||
|
const res = await axios.get('/api/api-keys');
|
||||||
|
setApiKeys(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>Ollama Proxy Admin</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Users</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(user => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>{user.id}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>{user.is_active ? 'Active' : 'Inactive'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>API Keys</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{apiKeys.map(key => (
|
||||||
|
<tr key={key.id}>
|
||||||
|
<td>{key.id}</td>
|
||||||
|
<td>{key.name}</td>
|
||||||
|
<td>{key.key}</td>
|
||||||
|
<td>{key.user_id}</td>
|
||||||
|
<td>{key.is_active ? 'Active' : 'Inactive'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
55
frontend/src/styles.css
Normal file
55
frontend/src/styles.css
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #ecf0f1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
color: #27ae60;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
11
frontend/vite.config.js
Normal file
11
frontend/vite.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
8
run_tests.py
Normal file
8
run_tests.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pytest runner for Ollama Proxy tests."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = subprocess.run([sys.executable, "-m", "pytest"] + sys.argv[1:], cwd="backend")
|
||||||
|
sys.exit(result.returncode)
|
||||||
7
test_api.sh
Normal file
7
test_api.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
curl -X POST http://localhost:8000/api/generate \
|
||||||
|
-H "Authorization: sk-admin-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "llama3",
|
||||||
|
"prompt": "Test"
|
||||||
|
}'
|
||||||
Loading…
x
Reference in New Issue
Block a user