feat: Cloud-Fundament - Auth, Persistenz und Mandanten-Trennung
- SQLite-Store (app/store.py) hinter Store-Interface: Nutzer + Sessions persistent - Bearer-Token-Auth (app/auth.py); Nutzerverwaltung via Admin-Key (POST /api/admin/users) - GET /api/me, PUT /api/me/prefs (dauerhafte Nutzer-Praeferenzen) - chat/speak/transcribe/sessions auth-geschuetzt; Mandanten-Trennung (fremde Session -> 403) - Route-Aufloesung: Defaults < Profil < ENV < Nutzer-Prefs < Session < Request - SessionManager (in-memory) durch Store ersetzt - AUTH_ENABLED-Schalter (prod an, dev/Tests aus); DB_PATH/ADMIN_API_KEY - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, deploy-env); data/ gitignored - Tests: 29 gruen (Auth, Mandanten, Persistenz, Routing) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
293ed257db
commit
e0e69fdf15
22 changed files with 625 additions and 57 deletions
|
|
@ -7,6 +7,13 @@ LOG_LEVEL=info
|
|||
# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# --- Authentifizierung -----------------------------------------------------
|
||||
# AUTH_ENABLED=true (Standard) schuetzt chat/speak/transcribe/sessions per Bearer-Token.
|
||||
# Fuer lokale Entwicklung/Tests auf false setzen (dann gilt ein anonymer Nutzer).
|
||||
AUTH_ENABLED=true
|
||||
# Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung.
|
||||
ADMIN_API_KEY=
|
||||
|
||||
# --- Zentrale Konfiguration / Profile -------------------------------------
|
||||
# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud
|
||||
# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE.
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,9 @@
|
|||
# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert)
|
||||
config/voice-assistant.toml
|
||||
|
||||
# Persistente Daten (SQLite-DB etc.)
|
||||
data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
|
|
@ -196,11 +196,66 @@ Start mit klarer Meldung ab.
|
|||
|
||||
---
|
||||
|
||||
## 10. Fehlerbehebung
|
||||
## 10. Authentifizierung & Mehrbenutzer
|
||||
|
||||
Im Produktivbetrieb ist `AUTH_ENABLED=true` (Standard). Dann brauchen
|
||||
`chat`/`speak`/`transcribe`/`sessions`/`me` ein **Bearer-Token pro Nutzer**.
|
||||
Nutzer und Sessions werden in einer SQLite-Datei gespeichert (`DB_PATH`, Standard
|
||||
`data/voice-assistant.db`).
|
||||
|
||||
**Schritt 1 — Admin-Schlüssel setzen** (nur über die Umgebung):
|
||||
|
||||
```bash
|
||||
export ADMIN_API_KEY=ein-langes-geheimnis
|
||||
```
|
||||
|
||||
**Schritt 2 — Nutzer anlegen** (Token erscheint **nur einmal**, sicher notieren):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/users \
|
||||
-H "X-Admin-Key: $ADMIN_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"display_name":"Oma Anna"}'
|
||||
```
|
||||
|
||||
**Schritt 3 — mit Token nutzen:**
|
||||
|
||||
```bash
|
||||
TOKEN=<das-token-von-oben>
|
||||
curl http://localhost:8080/api/me -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
curl -X POST http://localhost:8080/api/speak \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"text":"Guten Morgen!"}'
|
||||
```
|
||||
|
||||
**Dauerhafte Vorlieben** eines Nutzers (Gerät/Provider/Sprache) setzen:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8080/api/me/prefs \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"language":"de","llm_provider":"openrouter"}'
|
||||
```
|
||||
|
||||
Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen
|
||||
Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt.
|
||||
|
||||
> **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false`
|
||||
> gesetzt — dann ist kein Token nötig (anonymer Nutzer).
|
||||
|
||||
---
|
||||
|
||||
## 11. Fehlerbehebung
|
||||
|
||||
| Symptom | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| `OPENROUTER_API_KEY is empty` / 401 | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` |
|
||||
| `OPENROUTER_API_KEY is empty` | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` |
|
||||
| HTTP **401** „Bearer token required/Invalid token" | Auth an, Token fehlt/falsch | gültiges Token im Header `Authorization: Bearer …`, oder `AUTH_ENABLED=false` für dev |
|
||||
| HTTP **401** bei `/api/admin/users` | falscher/fehlender Admin-Key | `X-Admin-Key` mit `ADMIN_API_KEY` abgleichen |
|
||||
| HTTP **403** bei `?session_id=…` | Session gehört anderem Nutzer | eigene `session_id` verwenden |
|
||||
| HTTP **503** bei `/api/admin/users` | `ADMIN_API_KEY` nicht gesetzt | Admin-Key in der Umgebung setzen |
|
||||
| HTTP **422** „Unbekannter …-Provider/Endpunkt" | Tippfehler in `*_provider` / `*_endpoint` | gültige Werte via `GET /api/config` prüfen |
|
||||
| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt es | diese Zeilen in `.env` auskommentieren |
|
||||
| LLM-Timeout / Connection refused (lokal) | lokaler LLM-Server (Port 11434) läuft nicht | LLM-Server starten oder Profil `cloud` wählen |
|
||||
|
|
@ -213,7 +268,7 @@ Logs erscheinen im Terminal, in dem `make run` läuft. Für mehr Details
|
|||
|
||||
---
|
||||
|
||||
## 11. Tests ausführen
|
||||
## 12. Tests ausführen
|
||||
|
||||
```bash
|
||||
make test
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ einstellbar; höhere Ebene gewinnt:
|
|||
|
||||
```
|
||||
eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil)
|
||||
< ENV / .env < Session-Route < Request
|
||||
< ENV / .env < Nutzer-Prefs < Session-Route < Request
|
||||
```
|
||||
|
||||
### 3.1 Zentrale Config + Profile
|
||||
|
|
@ -64,9 +64,10 @@ werden aus echter Umgebung **oder** `.env` gelesen.
|
|||
### 3.2 Einheitliche Route-Auflösung
|
||||
|
||||
`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf
|
||||
(`resolve_route(session_id, overrides)`): Defaults < Session-Route < Request.
|
||||
Die Route umfasst `input_endpoint`, `output_endpoint`, `stt_provider`,
|
||||
`llm_provider`, `tts_provider`, `language`.
|
||||
(`resolve_route(user, session_id, overrides)`): Defaults < Nutzer-Prefs <
|
||||
Session-Route < Request. Die Route umfasst `input_endpoint`, `output_endpoint`,
|
||||
`stt_provider`, `llm_provider`, `tts_provider`, `language`. Gehört eine Session
|
||||
einem anderen Nutzer, wird `SessionOwnershipError` (→ 403) ausgelöst.
|
||||
|
||||
### 3.3 Registry-Pattern (Provider austauschbar)
|
||||
|
||||
|
|
@ -162,6 +163,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware).
|
|||
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
|
||||
| `POST /api/sessions/{id}/route` | bevorzugte Geräte/Provider/Sprache je Session |
|
||||
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
|
||||
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
|
||||
| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen |
|
||||
|
||||
Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
|
||||
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als
|
||||
|
|
@ -172,7 +175,9 @@ Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
|
|||
**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für
|
||||
STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte
|
||||
Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung;
|
||||
Device Router (strikt, Singleton); Output-Lifecycle; 22 automatisierte Tests.
|
||||
Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung
|
||||
(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung
|
||||
+ dauerhafte Nutzer-Präferenzen**; automatisierte Tests.
|
||||
|
||||
**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
|
||||
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
|
||||
|
|
@ -185,8 +190,8 @@ nicht aktiv (lokal/remote trägt vorerst der Provider-Name).
|
|||
Reihenfolge der Weiterentwicklung:
|
||||
|
||||
1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override.
|
||||
2. **Cloud-Fundament:** Authentifizierung + Mehrbenutzer + persistenter Session-/Profil-Store (heute ist `SessionManager` in-memory → nicht skalierend, ohne Auth).
|
||||
3. **Konversationsgedächtnis:** Verlauf + Langzeit-Präferenzen (heute ist `llm.complete` zustandslos).
|
||||
2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen.
|
||||
3. **Konversationsgedächtnis:** Verlauf + Langzeit-Erinnerungen (heute ist `llm.complete` zustandslos; Nutzer-Präferenzen sind als Basis vorhanden).
|
||||
4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager.
|
||||
5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing.
|
||||
6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext).
|
||||
|
|
@ -203,15 +208,18 @@ voice-assistant-scaffold/
|
|||
├── app/
|
||||
│ ├── main.py # FastAPI-App + Router-Registrierung
|
||||
│ ├── config.py # Settings, TOML-Profile, Präzedenz
|
||||
│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Singleton-Router
|
||||
│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton
|
||||
│ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions)
|
||||
│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz
|
||||
│ ├── errors.py # RoutingError -> HTTP 422
|
||||
│ ├── schemas.py # Pydantic-Modelle
|
||||
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config
|
||||
│ ├── core/ # orchestrator, session_manager
|
||||
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me
|
||||
│ ├── core/ # orchestrator
|
||||
│ ├── audio/ # router, transport_router, endpoints/input|output/*
|
||||
│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer
|
||||
│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs)
|
||||
├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored)
|
||||
├── data/ # SQLite-DB (gitignored)
|
||||
├── deploy/ # systemd unit + env-Beispiel
|
||||
├── tests/ # config-profile, routing, audio-router, e2e
|
||||
├── Docs/ # dieses Dokument
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -14,7 +14,8 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md).
|
|||
- **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS
|
||||
- **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs)
|
||||
- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`)
|
||||
- **Routing auf jeder Ebene:** Default → Profil → ENV → Session → Request
|
||||
- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
|
||||
- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
|
||||
- **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config
|
||||
- **Ohne Secrets im Code** — API-Keys nur über die Umgebung
|
||||
|
||||
|
|
@ -80,6 +81,9 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`.
|
|||
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
|
||||
| `POST /api/sessions/{id}/route` | Geräte/Provider/Sprache je Session setzen |
|
||||
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
|
||||
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
|
||||
| `GET /api/me` | aktueller Nutzer + Präferenzen |
|
||||
| `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen |
|
||||
|
||||
Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub):
|
||||
|
||||
|
|
@ -91,6 +95,31 @@ curl -X POST http://localhost:8080/api/speak \
|
|||
|
||||
Unbekannter Endpunkt/Provider → `HTTP 422` mit Klartext-Hinweis.
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me`
|
||||
durch ein **Bearer-Token pro Nutzer** geschützt. Nutzer/Sessions werden in SQLite
|
||||
persistiert (`DB_PATH`, Default `data/voice-assistant.db`).
|
||||
|
||||
```bash
|
||||
# 1) Nutzer anlegen (Admin-Key aus der Umgebung) — Token erscheint EINMALIG
|
||||
export ADMIN_API_KEY=ein-langes-geheimnis
|
||||
curl -X POST http://localhost:8080/api/admin/users \
|
||||
-H "X-Admin-Key: $ADMIN_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"display_name":"Oma Anna"}'
|
||||
# -> {"user_id":"…","display_name":"Oma Anna","token":"…"}
|
||||
|
||||
# 2) Mit dem Token aufrufen
|
||||
curl http://localhost:8080/api/me -H "Authorization: Bearer <TOKEN>"
|
||||
```
|
||||
|
||||
Dauerhafte Präferenzen pro Nutzer (`PUT /api/me/prefs`) fließen in die Route-Auflösung
|
||||
ein (Ebene zwischen Profil und Session). Fremde Sessions → `HTTP 403`.
|
||||
|
||||
> **Lokale Entwicklung:** `AUTH_ENABLED=false` setzen — dann gilt ein anonymer
|
||||
> Standardnutzer und es ist kein Token nötig.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
|
|
|||
14
app/api/admin.py
Normal file
14
app/api/admin.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth import require_admin
|
||||
from app.dependencies import get_store
|
||||
from app.schemas import UserCreate, UserCreated
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||
async def create_user(payload: UserCreate):
|
||||
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
|
||||
user, token = get_store().create_user(payload.display_name)
|
||||
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import RoutingError
|
||||
from app.auth import require_user
|
||||
from app.store import User, SessionOwnershipError
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
build_orchestrator,
|
||||
|
|
@ -36,6 +38,7 @@ async def chat(
|
|||
default=None,
|
||||
description="Optional session id to apply a stored route",
|
||||
),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
overrides = {
|
||||
"input_endpoint": payload.input_endpoint,
|
||||
|
|
@ -45,12 +48,14 @@ async def chat(
|
|||
"llm_provider": payload.llm_provider,
|
||||
"tts_provider": payload.tts_provider,
|
||||
}
|
||||
route = resolve_route(session_id, overrides)
|
||||
voice = payload.voice or settings.openrouter_tts_voice
|
||||
|
||||
try:
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
output = await resolve_output_endpoint(route)
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except RoutingError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
|
|
|||
21
app/api/me.py
Normal file
21
app/api/me.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth import require_user
|
||||
from app.dependencies import get_store
|
||||
from app.schemas import UserPrefs
|
||||
from app.store import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(user: User = Depends(require_user)):
|
||||
return {"user_id": user.id, "display_name": user.display_name, "prefs": user.prefs}
|
||||
|
||||
|
||||
@router.put("/me/prefs")
|
||||
async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)):
|
||||
"""Setzt die dauerhaften Routing-Praeferenzen des Nutzers (nur gesetzte Felder)."""
|
||||
prefs = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
updated = get_store().set_user_prefs(user.id, prefs)
|
||||
return {"user_id": updated.id, "prefs": updated.prefs}
|
||||
|
|
@ -1,10 +1,21 @@
|
|||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.schemas import SessionRouteRequest
|
||||
from app.dependencies import session_manager
|
||||
from app.auth import require_user
|
||||
from app.dependencies import get_store
|
||||
from app.store import User, SessionOwnershipError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/route")
|
||||
async def set_session_route(session_id: str, payload: SessionRouteRequest):
|
||||
session = session_manager.update(session_id, payload.model_dump())
|
||||
return {"session_id": session_id, "route": session}
|
||||
async def set_session_route(
|
||||
session_id: str,
|
||||
payload: SessionRouteRequest,
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
try:
|
||||
session = get_store().update_session(session_id, user.id, payload.model_dump())
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
return {"session_id": session_id, "route": session.data}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import RoutingError
|
||||
from app.auth import require_user
|
||||
from app.store import User, SessionOwnershipError
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
build_orchestrator,
|
||||
|
|
@ -22,18 +24,21 @@ async def speak(
|
|||
default=None,
|
||||
description="Optional session id to apply a stored route",
|
||||
),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
overrides = {
|
||||
"output_endpoint": payload.output_endpoint,
|
||||
"language": payload.language,
|
||||
"tts_provider": payload.tts_provider,
|
||||
}
|
||||
route = resolve_route(session_id, overrides)
|
||||
voice = payload.voice or settings.openrouter_tts_voice
|
||||
|
||||
try:
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
output = await resolve_output_endpoint(route)
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except RoutingError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
|
||||
from app.errors import RoutingError
|
||||
from app.auth import require_user
|
||||
from app.store import User, SessionOwnershipError
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
build_orchestrator,
|
||||
|
|
@ -20,17 +22,20 @@ async def transcribe(
|
|||
default=None,
|
||||
description="Optional session id to apply a stored route",
|
||||
),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
overrides = {
|
||||
"input_endpoint": input_endpoint,
|
||||
"language": language,
|
||||
"stt_provider": stt_provider,
|
||||
}
|
||||
route = resolve_route(session_id, overrides)
|
||||
|
||||
try:
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
source = await resolve_input_endpoint(route)
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except RoutingError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
|
|
|||
36
app/auth.py
Normal file
36
app/auth.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from fastapi import Header, HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_store
|
||||
from app.store import User
|
||||
|
||||
|
||||
def require_user(authorization: str | None = Header(default=None)) -> User:
|
||||
"""FastAPI-Dependency: liefert den authentifizierten Nutzer.
|
||||
|
||||
Bei AUTH_ENABLED=false gilt ein anonymer Standardnutzer (dev/Test). Sonst ist
|
||||
ein gueltiges Bearer-Token noetig (sonst 401).
|
||||
"""
|
||||
store = get_store()
|
||||
if not settings.auth_enabled:
|
||||
return store.ensure_anonymous_user()
|
||||
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="Bearer token required")
|
||||
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
user = store.get_user_by_token(token)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(x_admin_key: str | None = Header(default=None)) -> None:
|
||||
"""Schuetzt die Nutzerverwaltung ueber ADMIN_API_KEY (aus der Umgebung)."""
|
||||
expected = settings.admin_api_key.strip()
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Admin API not configured (ADMIN_API_KEY unset)"
|
||||
)
|
||||
if not x_admin_key or x_admin_key.strip() != expected:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
|
@ -120,6 +120,9 @@ class Settings(BaseSettings):
|
|||
local_llm_base_url: str = "http://127.0.0.1:11434/v1"
|
||||
local_llm_api_key: str = "dummy"
|
||||
local_llm_model: str = "llama3.1"
|
||||
db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
|
||||
admin_api_key: str = ""
|
||||
auth_enabled: bool = True
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
class SessionManager:
|
||||
def __init__(self):
|
||||
self._sessions = {}
|
||||
|
||||
def get(self, session_id: str) -> dict:
|
||||
return self._sessions.setdefault(session_id, {})
|
||||
|
||||
def update(self, session_id: str, values: dict) -> dict:
|
||||
session = self.get(session_id)
|
||||
session.update({k: v for k, v in values.items() if v is not None})
|
||||
return session
|
||||
|
|
@ -23,9 +23,20 @@ from app.pipeline.input_cleaner import InputCleaner
|
|||
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
|
||||
from app.pipeline.tts_normalizer import TTSNormalizer
|
||||
from app.core.orchestrator import Orchestrator
|
||||
from app.core.session_manager import SessionManager
|
||||
from app.store import SQLiteStore, Store, User
|
||||
|
||||
session_manager = SessionManager()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistenz-Store: Modul-Singleton (SQLite). Spaetere Backends implementieren
|
||||
# dasselbe Store-Interface, ohne die App zu aendern.
|
||||
# ---------------------------------------------------------------------------
|
||||
_store: Store | None = None
|
||||
|
||||
|
||||
def get_store() -> Store:
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = SQLiteStore(settings.db_path)
|
||||
return _store
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
|
||||
|
|
@ -136,17 +147,35 @@ class ResolvedRoute:
|
|||
}
|
||||
|
||||
|
||||
def get_session_route(session_id: str | None) -> dict:
|
||||
"""Liefert die gespeicherte Route einer Session (leeres dict ohne session_id)."""
|
||||
return session_manager.get(session_id) if session_id else {}
|
||||
def get_session_route(session_id: str | None, user: User | None = None) -> dict:
|
||||
"""Liefert die gespeicherte Route einer Session des Nutzers (leeres dict sonst).
|
||||
|
||||
Gehoert die Session einem anderen Nutzer, wird SessionOwnershipError ausgeloest.
|
||||
"""
|
||||
if not session_id:
|
||||
return {}
|
||||
session = get_store().get_session(session_id)
|
||||
if session is None:
|
||||
return {}
|
||||
if user is not None and session.user_id != user.id:
|
||||
from app.store import SessionOwnershipError
|
||||
|
||||
raise SessionOwnershipError(
|
||||
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
||||
)
|
||||
return session.data
|
||||
|
||||
|
||||
def resolve_route(
|
||||
user: User | None = None,
|
||||
session_id: str | None = None,
|
||||
overrides: dict | None = None,
|
||||
cfg: Settings = settings,
|
||||
) -> ResolvedRoute:
|
||||
"""Loest die effektive Route aus Defaults, Session und Request-Overrides auf."""
|
||||
"""Loest die effektive Route auf.
|
||||
|
||||
Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request.
|
||||
"""
|
||||
resolved = {
|
||||
"input_endpoint": cfg.default_input_endpoint,
|
||||
"output_endpoint": cfg.default_output_endpoint,
|
||||
|
|
@ -156,10 +185,11 @@ def resolve_route(
|
|||
"language": cfg.default_language,
|
||||
}
|
||||
|
||||
session_route = get_session_route(session_id)
|
||||
user_prefs = user.prefs if user is not None else {}
|
||||
session_route = get_session_route(session_id, user)
|
||||
request_overrides = overrides or {}
|
||||
|
||||
for layer in (session_route, request_overrides):
|
||||
for layer in (user_prefs, session_route, request_overrides):
|
||||
for key in ROUTE_KEYS:
|
||||
value = layer.get(key)
|
||||
if value is not None:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from app.api.speak import router as speak_router
|
|||
from app.api.devices import router as devices_router
|
||||
from app.api.sessions import router as sessions_router
|
||||
from app.api.config import router as config_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.me import router as me_router
|
||||
|
||||
app = FastAPI(title="Voice Assistant Gateway")
|
||||
app.include_router(health_router)
|
||||
|
|
@ -15,3 +17,5 @@ app.include_router(speak_router, prefix="/api")
|
|||
app.include_router(devices_router, prefix="/api")
|
||||
app.include_router(sessions_router, prefix="/api")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
app.include_router(admin_router, prefix="/api")
|
||||
app.include_router(me_router, prefix="/api")
|
||||
|
|
|
|||
|
|
@ -69,3 +69,22 @@ class RouteInfo(BaseModel):
|
|||
tts_provider: str
|
||||
language: str
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
display_name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class UserCreated(BaseModel):
|
||||
user_id: str
|
||||
display_name: str
|
||||
token: str # nur bei Erstellung sichtbar
|
||||
|
||||
|
||||
class UserPrefs(BaseModel):
|
||||
input_endpoint: str | None = None
|
||||
output_endpoint: str | None = None
|
||||
stt_provider: str | None = None
|
||||
llm_provider: str | None = None
|
||||
tts_provider: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
|
|
|||
197
app/store.py
Normal file
197
app/store.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Persistenzschicht: Nutzer und Sessions.
|
||||
|
||||
Ein abstraktes Store-Interface mit SQLite-Default (stdlib). Spaetere Backends
|
||||
(Postgres/Redis) koennen dasselbe Interface implementieren, ohne die App zu aendern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ANONYMOUS_USER_ID = "anonymous"
|
||||
|
||||
|
||||
def hash_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
id: str
|
||||
display_name: str
|
||||
prefs: dict = field(default_factory=dict)
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
id: str
|
||||
user_id: str
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class SessionOwnershipError(Exception):
|
||||
"""Eine Session gehoert einem anderen Nutzer (-> HTTP 403)."""
|
||||
|
||||
|
||||
class Store(ABC):
|
||||
@abstractmethod
|
||||
def create_user(self, display_name: str) -> tuple[User, str]:
|
||||
"""Legt einen Nutzer an und liefert (User, Klartext-Token). Token nur hier sichtbar."""
|
||||
|
||||
@abstractmethod
|
||||
def get_user_by_token(self, raw_token: str) -> User | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_user(self, user_id: str) -> User | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def set_user_prefs(self, user_id: str, prefs: dict) -> User: ...
|
||||
|
||||
@abstractmethod
|
||||
def ensure_anonymous_user(self) -> User: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_session(self, session_id: str) -> Session | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
||||
"""Erstellt/aktualisiert eine Session des Nutzers. Fremde Session -> SessionOwnershipError."""
|
||||
|
||||
|
||||
class SQLiteStore(Store):
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
prefs_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# ----- Nutzer -----------------------------------------------------------
|
||||
def _row_to_user(self, row: sqlite3.Row) -> User:
|
||||
return User(
|
||||
id=row["id"],
|
||||
display_name=row["display_name"],
|
||||
prefs=json.loads(row["prefs_json"] or "{}"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
def create_user(self, display_name: str) -> tuple[User, str]:
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
user = User(id=uuid.uuid4().hex, display_name=display_name, prefs={}, created_at=_now())
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(user.id, user.display_name, hash_token(raw_token), "{}", user.created_at),
|
||||
)
|
||||
return user, raw_token
|
||||
|
||||
def get_user_by_token(self, raw_token: str) -> User | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE token_hash = ?", (hash_token(raw_token),)
|
||||
).fetchone()
|
||||
return self._row_to_user(row) if row else None
|
||||
|
||||
def get_user(self, user_id: str) -> User | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return self._row_to_user(row) if row else None
|
||||
|
||||
def set_user_prefs(self, user_id: str, prefs: dict) -> User:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET prefs_json = ? WHERE id = ?",
|
||||
(json.dumps(prefs), user_id),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"Unbekannter Nutzer: {user_id}")
|
||||
return self._row_to_user(row)
|
||||
|
||||
def ensure_anonymous_user(self) -> User:
|
||||
existing = self.get_user(ANONYMOUS_USER_ID)
|
||||
if existing:
|
||||
return existing
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(ANONYMOUS_USER_ID, "Anonymous", f"anon-{ANONYMOUS_USER_ID}", "{}", _now()),
|
||||
)
|
||||
return self.get_user(ANONYMOUS_USER_ID)
|
||||
|
||||
# ----- Sessions ---------------------------------------------------------
|
||||
def get_session(self, session_id: str) -> Session | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Session(id=row["id"], user_id=row["user_id"], data=json.loads(row["data_json"] or "{}"))
|
||||
|
||||
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
||||
existing = self.get_session(session_id)
|
||||
if existing and existing.user_id != user_id:
|
||||
raise SessionOwnershipError(
|
||||
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
||||
)
|
||||
|
||||
data = dict(existing.data) if existing else {}
|
||||
data.update({k: v for k, v in values.items() if v is not None})
|
||||
payload = json.dumps(data)
|
||||
now = _now()
|
||||
|
||||
with self._connect() as conn:
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET data_json = ?, updated_at = ? WHERE id = ?",
|
||||
(payload, now, session_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, user_id, payload, now, now),
|
||||
)
|
||||
return Session(id=session_id, user_id=user_id, data=data)
|
||||
|
|
@ -1,3 +1,12 @@
|
|||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Profil und Konfiguration
|
||||
VA_PROFILE=cloud
|
||||
|
||||
# Authentifizierung (Produktion: an). ADMIN_API_KEY fuer die Nutzerverwaltung.
|
||||
AUTH_ENABLED=true
|
||||
ADMIN_API_KEY=
|
||||
# Persistente Datenbank (Pfad auf dem Host)
|
||||
DB_PATH=/var/lib/voice-assistant/voice-assistant.db
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import pytest
|
||||
|
||||
import app.dependencies as deps
|
||||
from app.config import settings
|
||||
from app.store import SQLiteStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
"""Isoliert den Singleton-Audio-Router (Loopback-Buffer) und Sessions je Test."""
|
||||
def reset_state(tmp_path, monkeypatch):
|
||||
"""Pro Test: frische SQLite-DB, frischer Singleton-Audio-Router, Auth aus.
|
||||
|
||||
Auth-Tests schalten `settings.auth_enabled` selbst wieder ein.
|
||||
"""
|
||||
deps._store = SQLiteStore(str(tmp_path / "test.db"))
|
||||
deps._audio_router = None
|
||||
deps.session_manager._sessions.clear()
|
||||
monkeypatch.setattr(settings, "auth_enabled", False)
|
||||
monkeypatch.setattr(settings, "admin_api_key", "")
|
||||
yield
|
||||
deps._store = None
|
||||
deps._audio_router = None
|
||||
deps.session_manager._sessions.clear()
|
||||
|
||||
|
||||
def loopback_output():
|
||||
|
|
|
|||
91
tests/test_auth.py
Normal file
91
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.config import settings
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
ADMIN = "admin-secret"
|
||||
|
||||
|
||||
def _enable_auth(monkeypatch):
|
||||
monkeypatch.setattr(settings, "auth_enabled", True)
|
||||
monkeypatch.setattr(settings, "admin_api_key", ADMIN)
|
||||
|
||||
|
||||
def _create_user(name: str) -> str:
|
||||
resp = client.post(
|
||||
"/api/admin/users", headers={"X-Admin-Key": ADMIN}, json={"display_name": name}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
def test_protected_endpoint_requires_token(monkeypatch):
|
||||
_enable_auth(monkeypatch)
|
||||
resp = client.post("/api/speak", json={"text": "x", "tts_provider": "piper"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_admin_requires_key(monkeypatch):
|
||||
_enable_auth(monkeypatch)
|
||||
resp = client.post("/api/admin/users", json={"display_name": "Anna"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_create_user_and_call_me(monkeypatch):
|
||||
_enable_auth(monkeypatch)
|
||||
token = _create_user("Anna")
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
me = client.get("/api/me", headers=auth)
|
||||
assert me.status_code == 200
|
||||
assert me.json()["display_name"] == "Anna"
|
||||
|
||||
bad = client.get("/api/me", headers={"Authorization": "Bearer nope"})
|
||||
assert bad.status_code == 401
|
||||
|
||||
|
||||
def test_tenant_isolation_returns_403(monkeypatch):
|
||||
_enable_auth(monkeypatch)
|
||||
token_a = _create_user("A")
|
||||
token_b = _create_user("B")
|
||||
|
||||
client.post(
|
||||
"/api/sessions/shared/route",
|
||||
headers={"Authorization": f"Bearer {token_a}"},
|
||||
json={"tts_provider": "piper"},
|
||||
)
|
||||
# B versucht die Session von A zu nutzen.
|
||||
resp = client.post(
|
||||
"/api/speak?session_id=shared",
|
||||
headers={"Authorization": f"Bearer {token_b}"},
|
||||
json={"text": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_user_prefs_applied_to_route(monkeypatch):
|
||||
_enable_auth(monkeypatch)
|
||||
token = _create_user("Pref")
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
client.put(
|
||||
"/api/me/prefs",
|
||||
headers=auth,
|
||||
json={"tts_provider": "piper", "output_endpoint": "loopback"},
|
||||
)
|
||||
resp = client.post("/api/speak", headers=auth, json={"text": "hallo"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["X-TTS-Provider"] == "piper"
|
||||
assert resp.headers["X-Output-Endpoint"] == "loopback"
|
||||
|
||||
|
||||
def test_admin_unconfigured_returns_503(monkeypatch):
|
||||
# Auth an, aber kein Admin-Key gesetzt.
|
||||
monkeypatch.setattr(settings, "auth_enabled", True)
|
||||
monkeypatch.setattr(settings, "admin_api_key", "")
|
||||
resp = client.post(
|
||||
"/api/admin/users", headers={"X-Admin-Key": "irgendwas"}, json={"display_name": "X"}
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
|
@ -2,11 +2,12 @@ import pytest
|
|||
|
||||
from app.config import Settings
|
||||
from app.errors import UnknownComponentError
|
||||
from app.store import SessionOwnershipError
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
get_llm_provider,
|
||||
get_tts_provider,
|
||||
session_manager,
|
||||
get_store,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -25,16 +26,35 @@ def test_request_overrides_win():
|
|||
assert route.output_endpoint == "loopback"
|
||||
|
||||
|
||||
def test_session_then_request_precedence():
|
||||
session_manager.update("s_test", {"tts_provider": "piper", "language": "en"})
|
||||
route = resolve_route("s_test")
|
||||
assert route.tts_provider == "piper"
|
||||
def test_user_prefs_session_request_precedence():
|
||||
store = get_store()
|
||||
user, _ = store.create_user("Tester")
|
||||
store.set_user_prefs(user.id, {"tts_provider": "chatterbox", "language": "en"})
|
||||
user = store.get_user(user.id)
|
||||
|
||||
# Nutzer-Prefs gelten.
|
||||
route = resolve_route(user)
|
||||
assert route.tts_provider == "chatterbox"
|
||||
assert route.language == "en"
|
||||
|
||||
# Request schlaegt Session.
|
||||
route2 = resolve_route("s_test", {"tts_provider": "chatterbox"})
|
||||
assert route2.tts_provider == "chatterbox"
|
||||
assert route2.language == "en"
|
||||
# Session schlägt Nutzer-Prefs.
|
||||
store.update_session("s1", user.id, {"tts_provider": "piper"})
|
||||
route2 = resolve_route(user, "s1")
|
||||
assert route2.tts_provider == "piper"
|
||||
assert route2.language == "en" # weiterhin aus den Nutzer-Prefs
|
||||
|
||||
# Request schlägt Session.
|
||||
route3 = resolve_route(user, "s1", {"tts_provider": "openrouter"})
|
||||
assert route3.tts_provider == "openrouter"
|
||||
|
||||
|
||||
def test_session_ownership_enforced_in_route():
|
||||
store = get_store()
|
||||
owner, _ = store.create_user("A")
|
||||
other, _ = store.create_user("B")
|
||||
store.update_session("shared", owner.id, {"tts_provider": "piper"})
|
||||
with pytest.raises(SessionOwnershipError):
|
||||
resolve_route(other, "shared")
|
||||
|
||||
|
||||
def test_registry_unknown_provider_raises():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue