commit 562f6ecd9c27932bbb4b834b7357aaf8e0755c05 Author: Oliver Hofmann Date: Mon Apr 27 18:54:27 2026 +0200 Init diff --git a/README.md b/README.md new file mode 100644 index 0000000..de6701b --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..617e0d7 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..c041cfc --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Ollama Proxy Backend.""" diff --git a/backend/admin.py b/backend/admin.py new file mode 100644 index 0000000..100e5c7 --- /dev/null +++ b/backend/admin.py @@ -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 diff --git a/backend/crud.py b/backend/crud.py new file mode 100644 index 0000000..68650f8 --- /dev/null +++ b/backend/crud.py @@ -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 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..9cec505 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..a946e3d --- /dev/null +++ b/backend/init_db.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..ab36f1e --- /dev/null +++ b/backend/main.py @@ -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"}) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..a9fd804 --- /dev/null +++ b/backend/models.py @@ -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) diff --git a/backend/models_base.py b/backend/models_base.py new file mode 100644 index 0000000..653de08 --- /dev/null +++ b/backend/models_base.py @@ -0,0 +1,5 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, BigInteger +from datetime import datetime + +class Base: + pass diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..33fe60f --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,4 @@ +[tool.pytest.ini_options] +python_files = "test_*.py" +testpaths = ["tests"] +addopts = "-v --cov=. --cov-report=term-missing --cov-report=html" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..22ffceb --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..55cdaec --- /dev/null +++ b/backend/schemas.py @@ -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 diff --git a/backend/schemas_types.py b/backend/schemas_types.py new file mode 100644 index 0000000..ac52ec5 --- /dev/null +++ b/backend/schemas_types.py @@ -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] diff --git a/backend/setup_admin.py b/backend/setup_admin.py new file mode 100644 index 0000000..daedf2c --- /dev/null +++ b/backend/setup_admin.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e55e87f --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ce8a09d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Ollama Proxy Admin + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fa83e8e --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..6e58eb5 --- /dev/null +++ b/frontend/src/main.jsx @@ -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
Loading...
; + + return ( +
+

Ollama Proxy Admin

+ +
+

Users

+ + + + + + + + + + + {users.map(user => ( + + + + + + + ))} + +
IDUsernameEmailStatus
{user.id}{user.username}{user.email}{user.is_active ? 'Active' : 'Inactive'}
+
+ +
+

API Keys

+ + + + + + + + + + + + {apiKeys.map(key => ( + + + + + + + + ))} + +
IDNameKeyUserStatus
{key.id}{key.name}{key.key}{key.user_id}{key.is_active ? 'Active' : 'Inactive'}
+
+
+ ); +} + +export default App; diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..6e32e06 --- /dev/null +++ b/frontend/src/styles.css @@ -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; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..69163ad --- /dev/null +++ b/frontend/vite.config.js @@ -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', + }, + }, +}) diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..26a07bb --- /dev/null +++ b/run_tests.py @@ -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) diff --git a/test_api.sh b/test_api.sh new file mode 100644 index 0000000..19d852d --- /dev/null +++ b/test_api.sh @@ -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" + }'