From 66d6708f595386d1b49a06649a34e7a0a6f35731 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 13:17:54 +0200 Subject: [PATCH] feat(costs): $-Tagesbudget pro Nutzer (Stufe 3) cost_daily_limit_usd (Pref > global; 0 = unbegrenzt) in quota.py (cost_within_budget, cost_limit). ws.py-Gate: Web-Such- ODER Kostenbudget aufgebraucht -> Web-Suche fuer den Turn aus + freundlicher Hinweis. Admin: Schema-Feld + list_users + UI-Eingabe ($/Tag) + globales Live-Setting. Co-Authored-By: Claude Opus 4.8 --- app/api/admin.py | 1 + app/api/ws.py | 11 +++++++---- app/config.py | 1 + app/quota.py | 22 ++++++++++++++++++++++ app/runtime_config.py | 1 + app/schemas.py | 3 ++- app/web/app.js | 23 +++++++++++++++++++++++ 7 files changed, 57 insertions(+), 5 deletions(-) diff --git a/app/api/admin.py b/app/api/admin.py index 8f4f944..95a47f6 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -239,6 +239,7 @@ async def list_users(): "location_consent": bool((u.prefs or {}).get("location_consent", False)), "web_search_enabled": bool((u.prefs or {}).get("web_search_enabled", runtime_settings.web_search_enabled)), "web_search_daily_limit": (u.prefs or {}).get("web_search_daily_limit", ""), + "cost_daily_limit_usd": (u.prefs or {}).get("cost_daily_limit_usd", ""), "is_admin": is_admin_user(u), } for u in get_store().list_users() diff --git a/app/api/ws.py b/app/api/ws.py index 9aa3338..d98f6d5 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -33,7 +33,7 @@ from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction from app.quota import (enforce_quota, record_usage, QuotaExceededError, - web_search_within_budget, record_web_search) + web_search_within_budget, record_web_search, cost_within_budget) from app.core.costs import start_meter, persist_costs from app.pipeline.search_backstop import should_force_search @@ -93,8 +93,9 @@ def _authenticate(websocket: WebSocket, token: str | None): async def _resolve(user, session_id, options, store=None): """Loest Route + Orchestrator + Output-Endpunkt auf (kann RoutingError/Ownership werfen).""" overrides = {key: options.get(key) for key in _OVERRIDE_KEYS} - # Web-Such-Tagesbudget aufgebraucht -> Web-Suche fuer diesen Turn aus. - if store is not None and not web_search_within_budget(user, store): + # Web-Such- oder Kosten-Tagesbudget aufgebraucht -> Web-Suche fuer den Turn aus. + if store is not None and (not web_search_within_budget(user, store) + or not cost_within_budget(user, store)): overrides["web_search_enabled"] = False route = resolve_route(user, session_id, overrides) orchestrator = build_orchestrator(route) @@ -215,7 +216,9 @@ async def _run_turn( # Freundlicher Hinweis, wenn die Web-Suche bei einer offensichtlich aktuellen # Frage nicht verfuegbar war (abgeschaltet oder Tagesbudget aufgebraucht). if not route.web_search_enabled and should_force_search(text): - kind = "budget" if not web_search_within_budget(user, store) else "off" + over_budget = (not web_search_within_budget(user, store) + or not cost_within_budget(user, store)) + kind = "budget" if over_budget else "off" await websocket.send_json({"type": "notice", "text": _ws_notice(route.language, kind)}) # Im text_only-Modus kommt kein Audio (Gerät spricht selbst). if not audio_stream and not text_only: diff --git a/app/config.py b/app/config.py index c38cedc..32a9121 100644 --- a/app/config.py +++ b/app/config.py @@ -190,6 +190,7 @@ class Settings(BaseSettings): audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) web_search_enabled: bool = True # Web-Suche via Tool-Calling (Weg 2); global an, pro Nutzer abschaltbar web_search_daily_limit: int = 0 # max. Web-Suchen pro Nutzer/Tag (0 = unbegrenzt); pro Nutzer überschreibbar + cost_daily_limit_usd: float = 0 # max. Kosten pro Nutzer/Tag in USD (0 = unbegrenzt); pro Nutzer überschreibbar # TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light. tts_normalize_level: str = "auto" stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) diff --git a/app/quota.py b/app/quota.py index 5733e84..4b0cc2c 100644 --- a/app/quota.py +++ b/app/quota.py @@ -68,3 +68,25 @@ def record_web_search(user, store, n: int = 1) -> None: """Zaehlt n tatsaechlich ausgefuehrte Web-Suchen fuer den Nutzer.""" if n and n > 0 and user is not None: store.add_web_search_usage(user.id, n) + + +def cost_limit(user, cfg=None) -> float: + """Max. Kosten/Tag (USD) fuer diesen Nutzer (Pref > global; 0 = unbegrenzt).""" + if cfg is None: + from app.runtime_config import runtime_settings + cfg = runtime_settings + pref = user.prefs.get("cost_daily_limit_usd") if user and user.prefs else None + if pref is not None: + try: + return float(pref) + except (TypeError, ValueError): + pass + return cfg.cost_daily_limit_usd + + +def cost_within_budget(user, store, cfg=None) -> bool: + """Darf der Nutzer heute noch Kosten verursachen? (Limit 0 = unbegrenzt).""" + limit = cost_limit(user, cfg) + if not limit or limit <= 0: + return True + return store.cost_for_day(user.id) < limit diff --git a/app/runtime_config.py b/app/runtime_config.py index ef99a53..4e76e74 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -34,6 +34,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), + "cost_daily_limit_usd": ("Kostenbudget/Tag (global, $)", "float", "0 = unbegrenzt — pro Nutzer überschreibbar"), "emergency_cooldown_minutes": ("Notruf-Sperre (Minuten)", "int", "Erneuter Alarm für N Minuten blockiert (Standard 5, 0 = aus)"), } diff --git a/app/schemas.py b/app/schemas.py index 6a9b0f8..34a0a4b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -165,8 +165,9 @@ class AdminUserPrefsUpdate(BaseModel): Format geprüft (serverseitige Härtung zusätzlich zur Frontend-Validierung). """ allowed_languages: str | None = None - web_search_enabled: bool | None = None # Web-Suche (Weg 2) pro Nutzer an/aus + web_search_enabled: bool | None = None # Web-Suche (Weg 2) pro Nutzer an/aus web_search_daily_limit: int | None = None # max. Web-Suchen/Tag (0 = unbegrenzt) + cost_daily_limit_usd: float | None = None # max. Kosten/Tag in USD (0 = unbegrenzt) # Notfall-Profil (in user.prefs gespeichert, fließt in die Notruf-Meldungen ein) full_name: str | None = None street: str | None = None diff --git a/app/web/app.js b/app/web/app.js index 3ff4cd6..202a531 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1235,6 +1235,11 @@ function buildUserCard(u) { +
+ max. Kosten pro Tag ($): + + +
@@ -1358,6 +1363,24 @@ function buildUserCard(u) { } }); } + + // Kosten-Tagesbudget in $ (leer/0 = unbegrenzt) — sofort speichern + const costLimit = card.querySelector(".cost-limit"); + const costLimitStatus = card.querySelector(".cost-limit-status"); + if (costLimit) { + const lim = Number(u.cost_daily_limit_usd); + costLimit.value = Number.isFinite(lim) && lim > 0 ? lim : ""; + costLimit.addEventListener("change", async () => { + const n = parseFloat(costLimit.value); + const val = Number.isFinite(n) && n > 0 ? n : 0; // leer/0 = unbegrenzt + if (costLimitStatus) costLimitStatus.textContent = "speichere …"; + const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { cost_daily_limit_usd: val }); + if (costLimitStatus) { + costLimitStatus.textContent = res ? "✓" : "Fehler"; + setTimeout(() => { costLimitStatus.textContent = ""; }, 2000); + } + }); + } const profBtn = card.querySelector(".btn-prof"); const profSection = card.querySelector(".prof-section"); const profArrow = card.querySelector(".prof-arrow");