diff --git a/DOCKERHUB.en.md b/DOCKERHUB.en.md index 2f58ec6..c3fb523 100644 --- a/DOCKERHUB.en.md +++ b/DOCKERHUB.en.md @@ -7,6 +7,7 @@ A lightweight reverse proxy for [Ollama](https://ollama.com) that manages API ke - OpenAI-compatible endpoint (`/v1/chat/completions`, `/v1/models`) - API key management with daily and monthly token/request limits - Web-based admin interface (port 8001) +- Model lock: enforces a specific model for all requests (useful for courses and lab sessions) - Streaming support (Server-Sent Events) - Tool use / function calling passthrough - Rotating usage logs @@ -27,7 +28,6 @@ All API endpoints require the `ADMIN_PASSWORD` — without a valid token, only t |----------|---------|-------------| | `ADMIN_PASSWORD` | – | **Required.** Password for the admin interface | | `OLLAMA_URL` | `http://localhost:11434` | URL of the Ollama server (without `/v1` suffix) | -| `DEFAULT_MODEL` | `llama3` | Model used when the client does not specify one | | `DATABASE_URL` | `sqlite:///./test.db` | Database connection string (SQLite or PostgreSQL) | | `PROXY_HOST` | `0.0.0.0` | Proxy bind address | | `PROXY_PORT` | `8000` | Proxy port | @@ -59,7 +59,6 @@ volumes: ```env ADMIN_PASSWORD=changeme OLLAMA_URL=http://localhost:11434 -DEFAULT_MODEL=llama3 APP_TZ=Europe/Berlin ``` @@ -78,7 +77,7 @@ services: environment: ADMIN_PASSWORD: changeme OLLAMA_URL: http://ollama:11434 - DEFAULT_MODEL: llama3 + APP_TZ: Europe/Berlin volumes: - llmproxy-data:/app/backend @@ -111,7 +110,7 @@ services: environment: ADMIN_PASSWORD: changeme OLLAMA_URL: http://ollama:11434 - DEFAULT_MODEL: llama3 + APP_TZ: Europe/Berlin DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy depends_on: diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 4a1ae20..44ada32 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -7,6 +7,7 @@ Ein schlanker Reverse-Proxy für [Ollama](https://ollama.com), der API-Keys mit - OpenAI-kompatibler Endpunkt (`/v1/chat/completions`, `/v1/models`) - API-Key-Verwaltung mit tages- und monatlichen Token-/Request-Limits - Web-basierte Admin-Oberfläche (Port 8001) +- Modell-Lock: erzwingt ein bestimmtes Modell für alle Requests (nützlich für Praktika/Kurse) - Streaming-Support (Server-Sent Events) - Tool-Use / Function Calling wird durchgereicht - Rotierende Nutzungs-Logs @@ -27,7 +28,6 @@ Alle API-Endpunkte erfordern das `ADMIN_PASSWORD` — ein Zugriff ohne gültiges |----------|----------|--------------| | `ADMIN_PASSWORD` | – | **Pflicht.** Passwort für die Admin-Oberfläche | | `OLLAMA_URL` | `http://localhost:11434` | URL des Ollama-Servers (ohne `/v1`-Suffix) | -| `DEFAULT_MODEL` | `llama3` | Modell, das verwendet wird wenn der Client keines angibt | | `DATABASE_URL` | `sqlite:///./test.db` | Datenbank-Verbindungsstring (SQLite oder PostgreSQL) | | `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxy | | `PROXY_PORT` | `8000` | Port des Proxy | @@ -59,7 +59,6 @@ volumes: ```env ADMIN_PASSWORD=changeme OLLAMA_URL=http://localhost:11434 -DEFAULT_MODEL=llama3 APP_TZ=Europe/Berlin ``` @@ -78,7 +77,7 @@ services: environment: ADMIN_PASSWORD: changeme OLLAMA_URL: http://ollama:11434 - DEFAULT_MODEL: llama3 + APP_TZ: Europe/Berlin volumes: - llmproxy-data:/app/backend @@ -111,7 +110,7 @@ services: environment: ADMIN_PASSWORD: changeme OLLAMA_URL: http://ollama:11434 - DEFAULT_MODEL: llama3 + APP_TZ: Europe/Berlin DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy depends_on: diff --git a/KURZANLEITUNG.md b/KURZANLEITUNG.md index de28b28..72cf827 100644 --- a/KURZANLEITUNG.md +++ b/KURZANLEITUNG.md @@ -7,7 +7,7 @@ Der Dienst stellt **große Sprachmodelle (LLMs)** über eine einfache HTTP-API b Typische Anwendungsfälle: - Texte zusammenfassen, übersetzen oder umformulieren - - KI-gestütztes Coding (z.B. mit **[opencode](https://opencode.ai)**) + - KI-gestütztes Coding (z.B. mit **[opencode](https://opencode.ai)**) - Experimente mit Prompt-Engineering und LLM-Integration in eigene Projekte --- @@ -166,3 +166,12 @@ Das Web-Interface zur Verwaltung von API-Keys und Quotas ist erreichbar unter: **`http://141.75.33.244:8001`** Dort können API-Keys angelegt, deaktiviert und mit Quotas versehen werden. + +### Modell-Lock für Praktika + +Unter **Einstellungen → Aktives Modell (Lock)** kann ein Modell fest vorgegeben werden. Ist ein Lock gesetzt, wird das `model`-Feld in jedem Request durch dieses Modell ersetzt – unabhängig davon, was der Client schickt. Das verhindert unkoordinierte Modellwechsel während einer Veranstaltung, die alle Teilnehmenden durch lange Ladezeiten ausbremsen würden. + +Typischer Ablauf für ein Praktikum: +1. Vor der Veranstaltung: passendes Modell in Ollama laden +2. Lock in der Admin-Oberfläche aktivieren +3. Nach der Veranstaltung: Lock wieder deaktivieren (Feld leeren) diff --git a/README.md b/README.md index c2ad8e1..f2e7269 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,6 @@ ADMIN_HOST=0.0.0.0 ADMIN_PORT=8001 DATABASE_URL=sqlite:///./test.db OLLAMA_URL=http://localhost:11434 -DEFAULT_MODEL=llama3 APP_TZ=Europe/Berlin LOG_FILE=logs/usage.log ``` @@ -47,7 +46,6 @@ LOG_FILE=logs/usage.log | `ADMIN_PORT` | `8001` | Port der Admin-API | | `DATABASE_URL` | `sqlite:///./test.db` | DB-Verbindungsstring (SQLite oder PostgreSQL) | | `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz (auch in der UI änderbar) | -| `DEFAULT_MODEL` | `llama3` | Standard-Modell für `/v1/chat/completions` (auch in der UI änderbar) | | `APP_TZ` | `Europe/Berlin` | Zeitzone für tägliche/monatliche Quota-Resets | | `LOG_FILE` | `logs/usage.log` | Pfad der rotierenden Nutzungs-Logdatei | | `ALLOWED_ORIGINS` | `http://localhost:5173` | CORS-Origins (nur für Entwicklung relevant) | diff --git a/backend/admin.py b/backend/admin.py index 27ba7ff..a7b0b21 100644 --- a/backend/admin.py +++ b/backend/admin.py @@ -137,7 +137,7 @@ async def get_proxy_info(_ = Depends(require_admin_auth)): async def read_settings(db: Session = Depends(get_db), _ = Depends(require_admin_auth)): return schemas.Settings( ollama_url=crud.get_setting(db, "ollama_url", "http://localhost:11434"), - default_model=crud.get_setting(db, "default_model", "llama3"), + force_model=crud.get_setting(db, "force_model") or None, ) @app.put("/api/settings", response_model=schemas.Settings) @@ -148,8 +148,8 @@ async def update_settings( ): ollama_url = settings.ollama_url.rstrip('/').removesuffix('/v1') crud.set_setting(db, "ollama_url", ollama_url) - crud.set_setting(db, "default_model", settings.default_model) - return schemas.Settings(ollama_url=ollama_url, default_model=settings.default_model) + crud.set_setting(db, "force_model", settings.force_model or "") + return schemas.Settings(ollama_url=ollama_url, force_model=settings.force_model or None) @app.get("/api/ollama-models") async def get_ollama_models( diff --git a/backend/init_db.py b/backend/init_db.py index a45e257..4563068 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -13,8 +13,6 @@ def init_db(): db = SessionLocal() if not get_setting(db, "ollama_url"): set_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) - if not get_setting(db, "default_model"): - set_setting(db, "default_model", os.getenv("DEFAULT_MODEL", "llama3")) db.close() print("Database initialized.") diff --git a/backend/main.py b/backend/main.py index fb87fbb..642d7ae 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,8 +70,6 @@ def apply_env_settings(): try: if url := os.getenv("OLLAMA_URL"): crud.set_setting(db, "ollama_url", url) - if model := os.getenv("DEFAULT_MODEL"): - crud.set_setting(db, "default_model", model) db.commit() finally: db.close() @@ -91,6 +89,9 @@ async def proxy_request(url: str, method: str = "GET", json_data: dict = None): async def generate(request: Request, db: Session = Depends(get_db)): ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) body = await request.json() + force_model = crud.get_setting(db, "force_model") or None + if force_model: + body = {**body, "model": force_model} prompt_tokens = crud.count_tokens(body.get("prompt", "")) if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1): @@ -115,6 +116,9 @@ async def generate(request: Request, db: Session = Depends(get_db)): async def chat(request: Request, db: Session = Depends(get_db)): ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) body = await request.json() + force_model = crud.get_setting(db, "force_model") or None + if force_model: + body = {**body, "model": force_model} messages = body.get("messages", []) prompt_tokens = sum(crud.count_tokens(_content_to_str(msg.get("content"))) for msg in messages) @@ -156,19 +160,19 @@ async def list_openai_models(db: Session = Depends(get_db)): @app.post("/v1/chat/completions") async def openai_chat_completions(request: Request, db: Session = Depends(get_db)): ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) - default_model = crud.get_setting(db, "default_model", os.getenv("DEFAULT_MODEL", "llama3")) body = await request.json() + force_model = crud.get_setting(db, "force_model") or None + if force_model: + body = {**body, "model": force_model} messages = body.get("messages", []) prompt_tokens = sum(crud.count_tokens(_content_to_str(msg.get("content"))) for msg in messages) if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1): raise HTTPException(status_code=429, detail="Quota exceeded") - if "model" not in body: - body = {**body, "model": default_model} + model_name = body.get("model", "?") - model_name = body["model"] usage_log.info('%s | /v1/chat/completions | %s | ~%d tokens | "%s"', request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(messages)) diff --git a/backend/schemas.py b/backend/schemas.py index 7fe788a..e021337 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -40,7 +40,7 @@ class QuotaUpdate(BaseModel): class Settings(BaseModel): ollama_url: str - default_model: str + force_model: Optional[str] = None class UsageStats(BaseModel): tokens_used_today: int = 0 diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 84be61e..dca6207 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -95,8 +95,8 @@ function SettingsSection({ password }) { const { models, reachable } = res.data; setOllamaReachable(reachable); setAvailableModels(models); - if (models.length > 0 && !models.includes(currentModel)) { - setSettings(s => ({ ...s, default_model: models[0] })); + if (models.length > 0 && currentModel && !models.includes(currentModel)) { + setSettings(s => ({ ...s, force_model: models[0] })); } } catch { setOllamaReachable(false); @@ -115,7 +115,7 @@ function SettingsSection({ password }) { const s = settingsRes.data; setSettings(s); setProxyEndpoint(proxyRes.data.endpoint); - fetchModels(s.ollama_url, s.default_model); + fetchModels(s.ollama_url, s.force_model); }).catch(() => setError('Einstellungen konnten nicht geladen werden.')); }, []); @@ -152,7 +152,7 @@ function SettingsSection({ password }) { type="url" value={settings.ollama_url} onChange={(e) => setSettings({ ...settings, ollama_url: e.target.value })} - onBlur={(e) => fetchModels(e.target.value, settings.default_model)} + onBlur={(e) => fetchModels(e.target.value, settings.force_model)} placeholder="http://localhost:11434" required /> @@ -162,23 +162,23 @@ function SettingsSection({ password }) {
- + {modelsLoading ? ( Lade Modelle… ) : availableModels.length > 0 ? ( ) : ( setSettings({ ...settings, default_model: e.target.value })} - placeholder="llama3" - required + value={settings.force_model || ""} + onChange={(e) => setSettings({ ...settings, force_model: e.target.value || null })} + placeholder="leer = kein Lock" /> )}