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:
parent
d87f0ce424
commit
66d6708f59
7 changed files with 57 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
22
app/quota.py
22
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
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue