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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 13:17:54 +02:00
commit 66d6708f59
7 changed files with 57 additions and 5 deletions

View file

@ -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()

View file

@ -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: