9 Commits
Author SHA1 Message Date
Oliver Hofmann 70fd61608b Log actual tokens and elapsed time for all endpoints incl. streaming
For streaming /v1/chat/completions: inject stream_options.include_usage,
parse usage from SSE chunks, log actual ↑↓ tokens and wall time in the
generator's finally block. Add elapsed time to all second log entries.
2026-05-08 09:47:32 +02:00
Oliver Hofmann 07f6fec4bf Show app version in admin UI and /version endpoint
Embed APP_VERSION build arg in Docker image (default: dev).
build_push.sh passes the git tag as build arg. Proxy exposes
GET /version, admin UI shows it as read-only field in settings.
2026-05-08 09:30:23 +02:00
Oliver Hofmann 6761a73364 Add /api/ps example to Kurzanleitung 2026-05-08 09:25:44 +02:00
Oliver Hofmann 0d1ce96c99 Expose /api/ps to show currently loaded model 2026-05-08 09:22:17 +02:00
Oliver Hofmann b16b3af44d Mention VPN as alias for Intranet in Kurzanleitung 2026-05-08 09:17:08 +02:00
Oliver Hofmann fdbe0a74e8 Exclude .tex and .pdf from Docker build context 2026-05-08 08:41:49 +02:00
Oliver Hofmann 0154c89c6b Improve Python examples and opencode description in Kurzanleitung
Split second example into focused model listing only, remove repeated
prompt code. Replace TUI with 'interaktive Terminal-Oberfläche'.
2026-05-08 08:40:25 +02:00
Oliver Hofmann e3dbed9f5e Ignore generated KURZANLEITUNG.tex and .pdf 2026-05-08 08:15:07 +02:00
Oliver Hofmann 5b37718120 Fix list indentation in KURZANLEITUNG.md 2026-05-08 08:07:02 +02:00
8 changed files with 86 additions and 24 deletions

No files matched your search

+2
View File
@@ -42,6 +42,8 @@ docker-compose.yml
# Docs # Docs
*.md *.md
*.tex
*.pdf
# Dev & build scripts # Dev & build scripts
run_dev.py run_dev.py
+4
View File
@@ -27,3 +27,7 @@ frontend/dist/
# Misc # Misc
config.json config.json
# Generated documents
KURZANLEITUNG.tex
KURZANLEITUNG.pdf
+2
View File
@@ -6,6 +6,8 @@ COPY frontend/ frontend/
RUN npm run build --prefix frontend RUN npm run build --prefix frontend
FROM python:3.12-slim FROM python:3.12-slim
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
WORKDIR /app WORKDIR /app
COPY backend/requirements.txt . COPY backend/requirements.txt .
+22 -16
View File
@@ -7,14 +7,14 @@ Der Dienst stellt **große Sprachmodelle (LLMs)** über eine einfache HTTP-API b
Typische Anwendungsfälle: Typische Anwendungsfälle:
- Texte zusammenfassen, übersetzen oder umformulieren - 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 - Experimente mit Prompt-Engineering und LLM-Integration in eigene Projekte
--- ---
## Zugang ## Zugang
Der Dienst ist **nur im Intranet** erreichbar. Der Dienst ist **nur im Intranet (VPN)** erreichbar.
| | | | | |
|---|---| |---|---|
@@ -69,7 +69,7 @@ print(response.choices[0].message.content)
--- ---
## Python-Beispiel – Modell wählen und auflisten ## Python-Beispiel – Verfügbare Modelle abfragen
```python ```python
from openai import OpenAI from openai import OpenAI
@@ -79,25 +79,31 @@ BASE_URL = "http://141.75.33.244:8000/v1"
client = OpenAI(api_key=API_KEY, base_url=BASE_URL) client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
# Verfügbare Modelle abrufen
models = client.models.list() models = client.models.list()
for m in models.data: for m in models.data:
print(m.id) print(m.id)
# Prompt mit einem bestimmten Modell
response = client.chat.completions.create(
model="qwen3-coder-next:q8_0",
messages=[
{"role": "system", "content": "Du bist ein hilfreicher Coding-Assistent."},
{"role": "user", "content": "Schreibe eine Python-Funktion zum Berechnen der Fibonacci-Folge."}
]
)
print(response.choices[0].message.content)
``` ```
--- ---
## Aktuell geladenes Modell abfragen
Da immer nur ein Modell gleichzeitig im Speicher sein kann, lässt sich mit folgendem Aufruf prüfen, welches Modell gerade aktiv ist:
```python
import httpx
r = httpx.get(
"http://141.75.33.244:8000/api/ps",
headers={"Authorization": "Bearer sk-..."}
)
print(r.json())
```
Die Antwort enthält Modellname, Größe und wie lange das Modell noch im Speicher bleibt.
---
## Empfehlungen zur Nutzung ## Empfehlungen zur Nutzung
- **Kleines Modell zuerst** (`gemma4:31b` oder `gpt-oss:20b`) – viel schneller, für viele Aufgaben ausreichend. - **Kleines Modell zuerst** (`gemma4:31b` oder `gpt-oss:20b`) – viel schneller, für viele Aufgaben ausreichend.
@@ -155,7 +161,7 @@ Für Code-Aufgaben empfiehlt sich `qwen3-coder-next:q8_0`, für allgemeine Aufga
opencode opencode
``` ```
opencode öffnet eine interaktive TUI im Terminal und kann dann im Projektverzeichnis eingesetzt werden – Dateien lesen, Code generieren, Refactoring vorschlagen usw. opencode öffnet eine interaktive Terminal-Oberfläche und kann dann im Projektverzeichnis eingesetzt werden – Dateien lesen, Code generieren, Refactoring vorschlagen usw.
--- ---
+4 -1
View File
@@ -131,7 +131,10 @@ async def get_proxy_info(_ = Depends(require_admin_auth)):
host = os.getenv("PROXY_HOST", "0.0.0.0") host = os.getenv("PROXY_HOST", "0.0.0.0")
port = os.getenv("PROXY_PORT", "8000") port = os.getenv("PROXY_PORT", "8000")
display_host = "localhost" if host in ("0.0.0.0", "::") else host display_host = "localhost" if host in ("0.0.0.0", "::") else host
return {"endpoint": f"http://{display_host}:{port}"} return {
"endpoint": f"http://{display_host}:{port}",
"version": os.getenv("APP_VERSION", "dev"),
}
@app.get("/api/settings", response_model=schemas.Settings) @app.get("/api/settings", response_model=schemas.Settings)
async def read_settings(db: Session = Depends(get_db), _ = Depends(require_admin_auth)): async def read_settings(db: Session = Depends(get_db), _ = Depends(require_admin_auth)):
+45 -7
View File
@@ -1,5 +1,7 @@
import json
import logging import logging
import os import os
import time
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
@@ -102,12 +104,14 @@ async def generate(request: Request, db: Session = Depends(get_db)):
prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120] prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120]
usage_log.info('%s | /api/generate | %s | ~%d tokens | "%s"', usage_log.info('%s | /api/generate | %s | ~%d tokens | "%s"',
request.state.api_key_name, body.get("model", "?"), prompt_tokens, prompt_preview) request.state.api_key_name, body.get("model", "?"), prompt_tokens, prompt_preview)
start = time.monotonic()
try: try:
response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body) response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body)
resp_json = response.json() resp_json = response.json()
usage_log.info('%s | /api/generate | %s | actual ↑%d ↓%d tokens', usage_log.info('%s | /api/generate | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, body.get("model", "?"), request.state.api_key_name, body.get("model", "?"),
resp_json.get("prompt_eval_count", 0), resp_json.get("eval_count", 0)) resp_json.get("prompt_eval_count", 0), resp_json.get("eval_count", 0),
time.monotonic() - start)
return JSONResponse(content=resp_json, status_code=response.status_code) return JSONResponse(content=resp_json, status_code=response.status_code)
except Exception as exc: except Exception as exc:
error_log.error("Proxy error | %s | /api/generate | %s | %s: %s", error_log.error("Proxy error | %s | /api/generate | %s | %s: %s",
@@ -131,12 +135,14 @@ async def chat(request: Request, db: Session = Depends(get_db)):
usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"', usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"',
request.state.api_key_name, body.get("model", "?"), prompt_tokens, _last_user_msg(messages)) request.state.api_key_name, body.get("model", "?"), prompt_tokens, _last_user_msg(messages))
start = time.monotonic()
try: try:
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body) response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body)
resp_json = response.json() resp_json = response.json()
usage_log.info('%s | /api/chat | %s | actual ↑%d ↓%d tokens', usage_log.info('%s | /api/chat | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, body.get("model", "?"), request.state.api_key_name, body.get("model", "?"),
resp_json.get("prompt_eval_count", 0), resp_json.get("eval_count", 0)) resp_json.get("prompt_eval_count", 0), resp_json.get("eval_count", 0),
time.monotonic() - start)
return JSONResponse(content=resp_json, status_code=response.status_code) return JSONResponse(content=resp_json, status_code=response.status_code)
except Exception as exc: except Exception as exc:
error_log.error("Proxy error | %s | /api/chat | %s | %s: %s", error_log.error("Proxy error | %s | /api/chat | %s | %s: %s",
@@ -149,6 +155,16 @@ async def list_models(db: Session = Depends(get_db)):
response = await proxy_request(f"{ollama_url}/api/tags", method="GET") response = await proxy_request(f"{ollama_url}/api/tags", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code) return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/version")
async def version():
return {"version": os.getenv("APP_VERSION", "dev")}
@app.get("/api/ps")
async def running_models(db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/ps", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/api/versions") @app.get("/api/versions")
async def versions(db: Session = Depends(get_db)): async def versions(db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
@@ -185,29 +201,51 @@ async def openai_chat_completions(request: Request, db: Session = Depends(get_db
target = f"{ollama_url}/v1/chat/completions" target = f"{ollama_url}/v1/chat/completions"
if body.get("stream"): if body.get("stream"):
existing_opts = body.get("stream_options") or {}
stream_body = {**body, "stream_options": {**existing_opts, "include_usage": True}}
start = time.monotonic()
usage_tokens = {"prompt": 0, "completion": 0}
async def generate(): async def generate():
try: try:
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream("POST", target, json=body) as resp: async with client.stream("POST", target, json=stream_body) as resp:
async for chunk in resp.aiter_bytes(): async for chunk in resp.aiter_bytes():
try:
for line in chunk.decode("utf-8", errors="ignore").splitlines():
if line.startswith("data: ") and "[DONE]" not in line:
data = json.loads(line[6:])
if u := data.get("usage"):
usage_tokens["prompt"] = u.get("prompt_tokens", 0)
usage_tokens["completion"] = u.get("completion_tokens", 0)
except Exception:
pass
yield chunk yield chunk
except Exception as exc: except Exception as exc:
error_log.error("Stream error | %s | /v1/chat/completions | %s | %s: %s", error_log.error("Stream error | %s | /v1/chat/completions | %s | %s: %s",
request.state.api_key_name, model_name, type(exc).__name__, exc, exc_info=exc) request.state.api_key_name, model_name, type(exc).__name__, exc, exc_info=exc)
raise raise
finally:
usage_log.info('%s | /v1/chat/completions | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, model_name,
usage_tokens["prompt"], usage_tokens["completion"],
time.monotonic() - start)
return StreamingResponse( return StreamingResponse(
generate(), generate(),
media_type="text/event-stream", media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
) )
start = time.monotonic()
try: try:
response = await proxy_request(target, method="POST", json_data=body) response = await proxy_request(target, method="POST", json_data=body)
resp_json = response.json() resp_json = response.json()
usage = resp_json.get("usage", {}) usage = resp_json.get("usage", {})
usage_log.info('%s | /v1/chat/completions | %s | actual ↑%d ↓%d tokens', usage_log.info('%s | /v1/chat/completions | %s | actual ↑%d ↓%d tokens | %.1fs',
request.state.api_key_name, model_name, request.state.api_key_name, model_name,
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0)) usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
time.monotonic() - start)
return JSONResponse(content=resp_json, status_code=response.status_code) return JSONResponse(content=resp_json, status_code=response.status_code)
except Exception as exc: except Exception as exc:
error_log.error("Proxy error | %s | /v1/chat/completions | %s | %s: %s", error_log.error("Proxy error | %s | /v1/chat/completions | %s | %s: %s",
+1
View File
@@ -37,6 +37,7 @@ echo ""
docker buildx build \ docker buildx build \
--platform "$PLATFORM" \ --platform "$PLATFORM" \
--push \ --push \
--build-arg APP_VERSION="$VERSION" \
-t "$IMAGE:$VERSION" \ -t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \ -t "$IMAGE:latest" \
. .
+6
View File
@@ -82,6 +82,7 @@ function SettingsSection({ password }) {
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
const [ollamaReachable, setOllamaReachable] = useState(true); const [ollamaReachable, setOllamaReachable] = useState(true);
const [proxyEndpoint, setProxyEndpoint] = useState(null); const [proxyEndpoint, setProxyEndpoint] = useState(null);
const [appVersion, setAppVersion] = useState(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -115,6 +116,7 @@ function SettingsSection({ password }) {
const s = settingsRes.data; const s = settingsRes.data;
setSettings(s); setSettings(s);
setProxyEndpoint(proxyRes.data.endpoint); setProxyEndpoint(proxyRes.data.endpoint);
setAppVersion(proxyRes.data.version);
fetchModels(s.ollama_url, s.force_model); fetchModels(s.ollama_url, s.force_model);
}).catch(() => setError('Einstellungen konnten nicht geladen werden.')); }).catch(() => setError('Einstellungen konnten nicht geladen werden.'));
}, []); }, []);
@@ -145,6 +147,10 @@ function SettingsSection({ password }) {
<small> (Änderung erfordert Neustart)</small> <small> (Änderung erfordert Neustart)</small>
</span> </span>
</div> </div>
<div className="settings-row">
<label>Version</label>
<span className="settings-value">{appVersion ?? '…'}</span>
</div>
<div className="settings-row"> <div className="settings-row">
<label>Ollama-Endpunkt</label> <label>Ollama-Endpunkt</label>
<div className="settings-input-wrap"> <div className="settings-input-wrap">