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:

View file

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

View file

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

View file

@ -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)"),
}

View file

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

View file

@ -1235,6 +1235,11 @@ function buildUserCard(u) {
<input type="number" min="0" class="ws-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
<span class="ws-limit-status text-xs text-slate-400"></span>
</div>
<div class="mt-1.5 flex items-center gap-2">
<span class="text-xs text-slate-500 dark:text-slate-400">max. Kosten pro Tag ($):</span>
<input type="number" min="0" step="0.01" class="cost-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
<span class="cost-limit-status text-xs text-slate-400"></span>
</div>
</div>
<!-- Notfall-Profil -->
@ -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");