diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dcb5ce..b0aa844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,25 +3,6 @@ Alle nennenswerten Änderungen an **Alexis**. Format lose nach [Keep a Changelog](https://keepachangelog.com/de/); Versionierung nach [SemVer](https://semver.org/lang/de/) (prä-1.0: `0.x`). -## [0.3.2] — 2026-06-30 - -### Hinzugefügt -- **Pro-Nutzer-Kostenerfassung (USD).** Zentraler `CostMeter` (ContextVars, - multitool-ready, `app/core/costs.py`); Konzept in `Docs/kosten-erfassung.md`. - - *Exakt:* Alle OpenRouter-Calls (LLM, Web-Suche/Sonar, Koreferenz, Fallback) - senden `usage:{include:true}` und buchen die Ist-Kosten je Kategorie - (`llm`/`web_search`) — inkl. finalem Streaming-`usage`-Chunk. - - *Geschätzt:* lokales LLM (Call-Dauer × Strom-Formel, marginaler GPU-Verbrauch, - `llm_local`), Cartesia-TTS (Zeichen × Preis, `tts`), Notruf (pro Webhook-Alarm, - `alert`, nur Doku — nie Sperre). Sätze konfigurierbar (GPU-Watt/Last, Strompreis, - `usd_per_eur`, Cartesia-Preis, Alarm-Preis). - - `cost_usage`-Tabelle (Nutzer/Tag/Kategorie); Statistik-Spalten **„Kosten (Monat)"** - + **„Ø/Anfrage"**, Tooltip „davon geschätzt" und Summenzeile **„exakt vs. geschätzt"**. -- **$-Tagesbudget pro Nutzer** (`cost_daily_limit_usd`, Pref > global, 0 = unbegrenzt): - ist das Web-Such- oder Kostenbudget aufgebraucht, wird die Web-Suche für den Turn - ausgesetzt (+ freundlicher Hinweis). Admin-Eingabe + globales Live-Setting. -- **Nutzungsstatistik: Spalte „mit Websuche"** je Nutzer (`total_web_searches`). - ## [0.3.1] — 2026-06-30 ### Hinzugefügt diff --git a/Docs/kosten-erfassung.md b/Docs/kosten-erfassung.md deleted file mode 100644 index 9a69a25..0000000 --- a/Docs/kosten-erfassung.md +++ /dev/null @@ -1,208 +0,0 @@ -# Konzept: Pro-Nutzer-Kostenerfassung (USD) - -> Stand: Entwurf. Dokumentiert, wie die tatsächlichen Kosten je Runde erfasst, -> pro Nutzer/Tag akkumuliert und im Admin angezeigt werden. Bewusst so geschnitten, -> dass der kommende **Multitool-Ausbau** ([[plan-multitool-ausbau]], `MULTITOOL_AUSBAU_PLAN.md`) -> weitere Kostenverursacher ohne Umbau anhängen kann. - -## 1. Ziel & Leitidee - -Dokumentieren, **wie viele Dollar jeder Nutzer verbraucht** — zur Aktivitäts- *und* -Ressourcendokumentation. Zwei Klassen von Kosten: - -- **Exakt gemessen:** Alles, was über **OpenRouter** läuft (LLM cloud, Web-Suche - via perplexity/sonar, OpenRouter-TTS). OpenRouter liefert mit `usage:{include:true}` - die **Ist-Kosten** jeder Generierung inline (geprüft: non-stream, **Streaming im - finalen `usage`-Chunk**, Sonar — inkl. `cost_details` und gecachten Tokens). -- **Geschätzt:** Kosten außerhalb OpenRouter (Cartesia-TTS, Notruf-SMS/-Anruf, - künftige externe Tool-APIs) — aus Menge × konfiguriertem Preis. - -Währung: **USD** (nativer OpenRouter-Wert; geschätzte Quellen ebenfalls in USD -konfiguriert). €-Umrechnung später optional. - -## 2. Kostenquellen - -| Quelle | Erfassung | Genauigkeit | -|---|---|---| -| LLM cloud (OpenRouter) | inline `usage.cost` | **exakt** | -| Web-Suche (Sonar via OpenRouter) | inline `usage.cost` (~0,005 $/Suche!) | **exakt** | -| OpenRouter-TTS (gpt-audio) | inline `usage.cost` | **exakt** | -| **Lokales LLM** (Ollama/llama.cpp, 2× RTX 3090) | Call-Dauer n × Strom-Formel (Kategorie `llm_local`) | geschätzt | -| **Cartesia-TTS** | synth. Zeichen × Preis (100k Credits ≈ 100.000 Zeichen → **0,00005 $/Zeichen**) | geschätzt | -| piper / Gerät-TTS | — | **0 $** | -| **Notruf** (SMS+Voice) | pro Webhook-Alarm × Preis (0,20 € × `usd_per_eur`) | geschätzt | - -**Lokaler-Strom-Formel** (marginaler Verbrauch über Leerlauf, je aktivem GPU-Job): -`Kosten = (gpu_max_w − gpu_idle_w) × gpu_avg_load × n_s / 3.600.000 kWh × Preis(EUR/kWh) × usd_per_eur`. -Default: (390−40) × 0,85 × n / 3,6e6 × 0,33 × 1,08. Der Leerlauf (40 W/GPU) bleibt -Infrastruktur; nur der Delta-Verbrauch eines Jobs zählt. Dauer n wird im lokalen -Provider gemessen. -| **Multitool (künftig)** | je nach Tool: OpenRouter-geroutet → exakt; externe API → Tool meldet eigene Kosten; lokal/DB → 0 $ | gemischt | - -Die Web-Suche ist der mit Abstand größte Pro-Turn-Treiber (~0,005 $ gegen -Bruchteile eines Cents fürs LLM). - -## 3. Keystone: request-weiter Kosten-Zähler über ContextVars - -Kosten entstehen in **vielen, nicht zusammenhängenden** Schichten (LLM-Provider, -Tool, TTS-Provider, Koreferenz-Vorstufe, Notruf-Modul). Callbacks durch alle -Signaturen zu fädeln wäre invasiv und brüchig. Stattdessen ein **request-scoped -`CostMeter`** (`app/core/costs.py`) via `contextvars`: - -```python -# app/core/costs.py -import contextvars -_meter = contextvars.ContextVar("cost_meter", default=None) - -class CostMeter: - def __init__(self): self.total = 0.0; self.by_cat: dict[str, float] = {} - def add(self, usd: float, category: str): - if not usd: return - self.total += usd - self.by_cat[category] = self.by_cat.get(category, 0.0) + usd - -def start_meter() -> CostMeter: - m = CostMeter(); _meter.set(m); return m - -def add_cost(usd: float, category: str): - m = _meter.get() - if m is not None: m.add(usd, category) - -def current_meter() -> "CostMeter | None": - return _meter.get() -``` - -- **Jede kostenverursachende Stelle ruft `add_cost(betrag, kategorie)`** — egal in - welcher Schicht, ohne Signatur-Änderung. -- Die **API-Schicht** (ws.py / chat.py) öffnet pro Turn `start_meter()`, fährt den - Turn, liest danach `current_meter()` und persistiert. -- `asyncio.create_task` **kopiert den Kontext** und referenziert dasselbe (mutable) - `CostMeter`-Objekt → auch Hintergrund-Tasks (Filler-/Geduld-Schleife) tragen - korrekt ein. Voraussetzung: das Objekt **einmal** pro Turn setzen, nicht neu setzen. - -Das ist die multitool-sichere Grundlage: ein neues Tool muss nur `add_cost(...)` -rufen (oder seine Kosten im `ToolResult` melden, s. §5). - -## 4. Erfassung je Quelle - -- **OpenRouter-Calls** (LLM in `tool_calling.py`/`openrouter.py`/`local_*`-Cloud, - Sonar in `web_search.py`, Koreferenz in `decontextualizer.py`, OpenRouter-TTS): - in jeden Payload `"usage": {"include": True}`; nach dem Parsen - `add_cost(data["usage"]["cost"], kategorie)`. Beim **Streaming** den finalen - `usage`-Chunk auslesen (unser `_stream_chat` ignoriert ihn heute). Ein gemeinsamer - Helfer `record_openrouter_cost(usage, category)` vermeidet Duplikate. -- **Cartesia-TTS** (`cartesia.py`): `add_cost(len(text) * cfg.cartesia_usd_per_char, "tts")`. -- **Lokales LLM:** kein `cost`-Feld → trägt 0 bei (fällt automatisch heraus). -- **Notruf** (`safety/emergency.py`): pro tatsächlich gesendetem Alarm - `add_cost(cfg.emergency_alert_usd, "alert")` — **nur Dokumentation, nie Sperre** - (sicherheitskritisch). Notrufe laufen über einen eigenen Endpunkt → dort eigenes - `start_meter()`/Persist, da nicht Teil eines Chat-Turns. - -**Doppelzählung vermeiden:** Jede Kostenquelle meldet **genau einmal**, an der -Schicht, an der sie anfällt. Sonars Kosten kommen aus seinem OpenRouter-Call (§4, -Kategorie `web_search`) — `SonarTool` meldet **keine** zusätzlichen Kosten im Result. - -## 5. Multitool-Vorsorge - -Künftige Tools (`MULTITOOL_AUSBAU_PLAN.md`) fügen sich ohne Umbau ein: - -- **OpenRouter-geroutetes Tool** (z. B. eine LLM-Klassifikation, Medizin-Safety): - Kosten werden am OpenRouter-Call automatisch erfasst (Kategorie z. B. `tool:medsafety`). -- **Externe-API-Tool** (z. B. Wetter, Karten, Kalender mit Bezahl-API): Das Tool - kennt seine Kosten selbst → es meldet sie. Dafür bekommt `ToolResult` ein optionales - Feld `cost_usd: float | None`; `ToolCallingLLM._run_tool` ruft bei `cost_usd` - `add_cost(result.cost_usd, f"tool:{name}")`. (OpenRouter-Tools lassen `cost_usd=None`.) -- **Lokales/DB-Tool** (Erinnerungen, Profil): 0 $. - -→ **Kategorien sind frei** (Strings), die Speicherung (§6) ist zeilenbasiert → -neue Kostenarten erscheinen automatisch als neue Kategorie, ohne Schema-Änderung. - -## 6. Speicherung - -Eine **kategorie-zeilenbasierte** Tages-Tabelle (extensibel, kein Spalten-ALTER bei -neuen Kostenarten): - -```sql -CREATE TABLE IF NOT EXISTS cost_usage ( - user_id TEXT NOT NULL, - day TEXT NOT NULL, -- UTC, wie usage/web_search_usage - category TEXT NOT NULL, -- llm | web_search | tts | alert | tool: - amount_usd REAL NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, day, category) -); -``` - -Store-Methoden: `add_cost(user_id, category, usd, day=None)` (UPSERT), -`get_cost(user_id, day=None)` / Aggregation über Fenster; kaskadiert in `delete_user` -und wird von `delete_user_history` **nicht** angetastet (Kosten bleiben). In der -API-Schicht nach dem Turn: `meter.by_cat` zeilenweise in `cost_usage` buchen. -Aggregation für die Statistik = SUM über Kategorien/Fenster. - -## 7. Konfiguration (neue Settings) - -- `cost_tracking_enabled: bool = True` -- `cartesia_usd_per_char: float = 0.00005` (100k Credits ≈ 100.000 Zeichen) -- `emergency_alert_usd: float = 0.22` (SMS+Voice ≈ 20 €-Cent) -- (später) `usd_per_eur` für optionale €-Anzeige. - -`usage:{include:true}` wird in allen OpenRouter-Payloads gesetzt (minimaler -Mehraufwand, nur größerer Response). - -## 8. Darstellung (Admin → Nutzungsstatistik) - -Pro Nutzer: -- **Kosten (laufender Monat)** — passt zur OpenRouter-Abrechnung; umschaltbar - *Heute · 30 Tage · Monat · Gesamt (seit Erfassungsbeginn)*. -- **Ø Kosten/Anfrage** = Monatskosten / Anfragen — Verhaltens-Indikator. -- optional **Aufschlüsselung** je Kategorie (LLM / Web-Suche / TTS / Notruf / Tools). -- **Gesamt-Zeile** (alle Nutzer) = geschätzte Monatsrechnung; davon **„exakt - (OpenRouter): $X"** zum Abgleich mit dem OpenRouter-Dashboard. - -**Exakt vs. geschätzt sichtbar** kennzeichnen (OpenRouter-Kategorien = exakt; -cartesia/alert/externe Tools = Schätzung). „Kosten erst ab Einführung der Erfassung; -lokale Modelle = 0 $." - -## 9. Optional: €-/$-Budget statt nur Web-Such-Budget - -Das vorhandene Pro-Nutzer-Tagesbudget (`web_search_daily_limit`) lässt sich zu einem -**`cost_daily_limit_usd`** erweitern: ist das Tagesbudget aufgebraucht, greift dieselbe -Gate-/Hinweis-Mechanik (Web-Suche aus + freundliche Systemnachricht). Deckt dann alle -Kostenarten statt nur Suchen. Notrufe bleiben ausgenommen. - -## 10. Harte Stellen (bewusst benannt) - -- **Streaming-`usage`-Chunk** auslesen (heute ignoriert) — sonst werden gestreamte - Antworten (der Normalfall!) mit 0 $ gezählt. -- **Doppelzählung** vermeiden (§4): genau eine Meldung pro Quelle. -- **ContextVar + Tasks:** Meter einmal pro Turn setzen; Tasks kopieren den Kontext - und mutieren dasselbe Objekt. -- **Cartesia ist ein Abo** ($5/100k): der Pro-Zeichen-Preis ist der *implizite* - Stückpreis (Anteil am Credit-Pool), nicht streng marginal — als „anteilig" framen. -- **Notruf nie blocken**, nur dokumentieren. -- **Reconciliation:** nur der OpenRouter-Anteil ist 1:1 gegen die OpenRouter-Rechnung - abgleichbar; Cartesia/Notruf gegen deren eigene Rechnungen. - -## 11. Phasenplan - -- **Stufe 1 (80 %, exakt):** `CostMeter` + OpenRouter-`usage.cost` (LLM, Sonar, - OpenRouter-TTS) inkl. Streaming-Chunk; `cost_usage`-Tabelle; Statistik-Spalte - „Kosten (Monat)" + Ø/Anfrage + Gesamt. -- **Stufe 2 (Schätzungen):** Cartesia (Zeichen×Preis) + Notruf (pro Event); - exakt/geschätzt-Kennzeichnung; Aufschlüsselung. -- **Stufe 3 (optional):** `cost_daily_limit_usd`-Budget + Hinweis; €-Anzeige. -- **Multitool:** `ToolResult.cost_usd` + `tool:`-Kategorien — greift dann - automatisch (keine weitere Statistik-Änderung nötig). - -## 12. Berührte Dateien (Landkarte) - -- neu: `app/core/costs.py` (`CostMeter`, `add_cost`, `start_meter`) -- `app/providers/llm/{tool_calling,openrouter,local_openai_compatible}.py`, - `app/tools/web_search.py`, `app/pipeline/decontextualizer.py` — `usage:{include:true}` - + `record_openrouter_cost(...)`; Streaming-`usage`-Chunk lesen -- `app/providers/tts/{cartesia,openrouter}.py` — TTS-Kosten -- `app/safety/emergency.py` — Notruf-Kosten (pro Event) -- `app/api/ws.py` / `app/api/chat.py` — `start_meter()` pro Turn + Persist -- `app/store.py` — `cost_usage`-Tabelle + `add_cost`/`get_cost`; `delete_user`-Kaskade -- `app/config.py` — neue Settings -- `app/api/admin.py` (`get_all_usage` um Kosten/Kategorien) + `app/web/app.js` - (Spalten/Aufschlüsselung) diff --git a/app/api/admin.py b/app/api/admin.py index 95a47f6..8f4f944 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -239,7 +239,6 @@ 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/chat.py b/app/api/chat.py index 84a2d56..a83fd61 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -16,8 +16,7 @@ from app.dependencies import ( voice_for_route, ) from app.core.memory_extractor import maybe_schedule_extraction -from app.core.costs import start_meter, persist_costs -from app.quota import enforce_quota, record_usage, QuotaExceededError, record_web_search +from app.quota import enforce_quota, record_usage, QuotaExceededError from app.runtime_config import runtime_settings from app.safety.emergency import ( record_manual_emergency, @@ -98,7 +97,6 @@ async def chat( # Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache. voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender) - start_meter() # Kosten dieses Turns sammeln try: trace, audio = await orchestrator.chat_text( payload.text, @@ -112,8 +110,6 @@ async def chat( raise HTTPException(status_code=502, detail=str(exc)) record_usage(user, store, len(payload.text) + len(trace.semantic_response or "")) - record_web_search(user, store, trace.web_searches) - persist_costs(user, store) # Turn persistieren (User-Eingabe + semantische Antwort) fuer das Gedaechtnis. if session_id: diff --git a/app/api/ws.py b/app/api/ws.py index d98f6d5..20a8720 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -33,8 +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, cost_within_budget) -from app.core.costs import start_meter, persist_costs + web_search_within_budget, record_web_search) from app.pipeline.search_backstop import should_force_search # Freundliche Hinweise (Senioren), wenn die Web-Suche bei einer offensichtlich @@ -93,9 +92,8 @@ 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- 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)): + # Web-Such-Tagesbudget aufgebraucht -> Web-Suche fuer diesen Turn aus. + if store is not None and not web_search_within_budget(user, store): overrides["web_search_enabled"] = False route = resolve_route(user, session_id, overrides) orchestrator = build_orchestrator(route) @@ -108,7 +106,6 @@ async def _run_turn( effective_voice: str | None = None, ): """Faehrt einen Antwort-Turn und streamt die Events an den Client.""" - start_meter() # Kosten dieses Turns sammeln (LLM/Sonar via OpenRouter usage.cost) conversation = ( store.get_recent_messages(session_id, settings.history_max_messages) if session_id @@ -202,7 +199,6 @@ async def _run_turn( record_usage(user, store, len(text) + len(trace.semantic_response or "")) record_web_search(user, store, trace.web_searches) - persist_costs(user, store) if session_id: store.append_message(session_id, user.id, "user", text) @@ -216,9 +212,7 @@ 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): - over_budget = (not web_search_within_budget(user, store) - or not cost_within_budget(user, store)) - kind = "budget" if over_budget else "off" + kind = "budget" if not web_search_within_budget(user, store) 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 3abea11..c38cedc 100644 --- a/app/config.py +++ b/app/config.py @@ -190,16 +190,6 @@ 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 - # Kostenerfassung Stufe 2 (geschätzte Quellen). Siehe Docs/kosten-erfassung.md. - usd_per_eur: float = 1.08 # Umrechnung EUR-naher Schätzungen in die USD-Anzeige - cartesia_usd_per_char: float = 0.00005 # Cartesia: 100k Credits ≈ 100.000 Zeichen → $5 - emergency_alert_eur: float = 0.20 # SMS+Voice pro Alarm (Schätzung) - # Lokales LLM: Stromkosten pro Anfrage (marginal über Leerlauf), 2× RTX 3090. - gpu_idle_watts: float = 40.0 # Leerlauf je GPU = Infrastruktur (nicht pro Anfrage) - gpu_max_watts: float = 390.0 # Volllast je GPU - gpu_avg_load: float = 0.85 # mittlere Auslastung während einer Anfrage - electricity_price_eur_per_kwh: float = 0.33 # 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/core/costs.py b/app/core/costs.py deleted file mode 100644 index 350d1ba..0000000 --- a/app/core/costs.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Request-weiter Kosten-Zähler (USD) über ContextVars. - -Kosten entstehen in vielen unverbundenen Schichten (LLM-Provider, Tool, TTS, -Koreferenz-Vorstufe, Notruf). Statt einen Callback durch alle Signaturen zu -fädeln, hält ein contextvar-basierter `CostMeter` die Kosten eines Requests -zusammen: jede Stelle ruft `add_cost(betrag_usd, kategorie)`. Die API-Schicht -öffnet pro Turn `start_meter()` und persistiert danach `current_meter()`. - -Multitool-ready: neue Kostenarten = neue (freie) Kategorie-Strings. -Siehe Docs/kosten-erfassung.md. -""" -import contextvars -import json - -_meter: contextvars.ContextVar = contextvars.ContextVar("cost_meter", default=None) - - -class CostMeter: - __slots__ = ("total", "by_cat") - - def __init__(self): - self.total: float = 0.0 - self.by_cat: dict[str, float] = {} - - def add(self, usd, category: str) -> None: - try: - usd = float(usd or 0.0) - except (TypeError, ValueError): - return - if usd <= 0: - return - self.total += usd - self.by_cat[category] = self.by_cat.get(category, 0.0) + usd - - -def start_meter() -> CostMeter: - """Setzt einen frischen Zähler für den aktuellen (Request-)Kontext.""" - meter = CostMeter() - _meter.set(meter) - return meter - - -def current_meter() -> "CostMeter | None": - return _meter.get() - - -def add_cost(usd, category: str) -> None: - """Bucht Kosten auf den aktuellen Zähler (no-op, wenn keiner aktiv ist).""" - meter = _meter.get() - if meter is not None: - meter.add(usd, category) - - -def record_openrouter_cost(usage, category: str) -> None: - """Liest `cost` aus einem OpenRouter-`usage`-Objekt und bucht es (USD).""" - if isinstance(usage, dict): - add_cost(usage.get("cost"), category) - - -def persist_costs(user, store) -> None: - """Schreibt die im aktuellen Meter gesammelten Kosten pro Kategorie in den Store.""" - meter = _meter.get() - if meter is None or user is None: - return - for category, amount in meter.by_cat.items(): - store.add_cost_usage(user.id, category, amount) - - -def _cfg(cfg): - if cfg is None: - from app.runtime_config import runtime_settings - return runtime_settings - return cfg - - -def electricity_usd(seconds, cfg=None) -> float: - """Stromkosten (USD) eines lokalen GPU-Jobs der Dauer `seconds`. - - (max_w − idle_w) × avg_load × s / 3.600.000 kWh × Preis(EUR/kWh) × usd_per_eur. - Der Leerlauf-Verbrauch zählt als Infrastruktur (nicht pro Anfrage); nur der - marginale Delta-Verbrauch eines aktiven Jobs wird berechnet. - """ - cfg = _cfg(cfg) - try: - s = float(seconds) - except (TypeError, ValueError): - return 0.0 - if s <= 0: - return 0.0 - delta_w = max(0.0, cfg.gpu_max_watts - cfg.gpu_idle_watts) * cfg.gpu_avg_load - kwh = delta_w * s / 3_600_000.0 - return kwh * cfg.electricity_price_eur_per_kwh * cfg.usd_per_eur - - -def cartesia_usd(n_chars, cfg=None) -> float: - """Cartesia-TTS-Kosten (USD) für `n_chars` synthetisierte Zeichen (Schätzung).""" - cfg = _cfg(cfg) - try: - n = int(n_chars or 0) - except (TypeError, ValueError): - return 0.0 - return max(0, n) * cfg.cartesia_usd_per_char - - -def eur_to_usd(eur, cfg=None) -> float: - cfg = _cfg(cfg) - try: - return float(eur or 0.0) * cfg.usd_per_eur - except (TypeError, ValueError): - return 0.0 - - -# Kategorien, die geschätzt (nicht von OpenRouter exakt gemeldet) sind. -ESTIMATED_CATEGORIES = ("tts", "alert", "llm_local") - - -def record_sse_usage(line: str, category: str) -> None: - """Bucht die Kosten aus einer OpenRouter-SSE-Zeile (finaler `usage`-Chunk).""" - if not line.startswith("data:"): - return - body = line[len("data:"):].strip() - if not body or body == "[DONE]": - return - try: - obj = json.loads(body) - except ValueError: - return - record_openrouter_cost(obj.get("usage"), category) diff --git a/app/pipeline/decontextualizer.py b/app/pipeline/decontextualizer.py index 948e999..e507f1f 100644 --- a/app/pipeline/decontextualizer.py +++ b/app/pipeline/decontextualizer.py @@ -9,8 +9,6 @@ import logging import httpx -from app.core.costs import record_openrouter_cost - logger = logging.getLogger(__name__) ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" @@ -68,7 +66,7 @@ class Decontextualizer: return text user = (f"Conversation:\n{self._render(history)}\n\n" f"Latest message: {text}\n\nRewritten self-contained message:") - payload = {"model": self.model, "temperature": 0.0, "usage": {"include": True}, + payload = {"model": self.model, "temperature": 0.0, "messages": [{"role": "system", "content": _SYSTEM}, {"role": "user", "content": user}]} headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} @@ -76,9 +74,7 @@ class Decontextualizer: async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client: resp = await client.post(ENDPOINT, json=payload, headers=headers) resp.raise_for_status() - data = resp.json() - record_openrouter_cost(data.get("usage"), "llm") - out = (data["choices"][0]["message"]["content"] or "").strip() + out = (resp.json()["choices"][0]["message"]["content"] or "").strip() except Exception as exc: # noqa: BLE001 — best effort, bei Fehler Original behalten logger.warning("Decontextualizer fehlgeschlagen: %s", exc) return text diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index 22ab079..af0e9db 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -1,10 +1,8 @@ -import time from collections.abc import AsyncIterator import httpx from app.core import clock -from app.core.costs import add_cost, electricity_usd from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -90,7 +88,6 @@ class LocalOpenAICompatibleLLM(LLMProvider): session_id: str | None = None, language: str | None = None, ) -> str: - t0 = time.monotonic() async with httpx.AsyncClient(timeout=120) as client: response = await client.post( f"{self.base_url}/chat/completions", @@ -99,8 +96,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): ) response.raise_for_status() data = response.json() - add_cost(electricity_usd(time.monotonic() - t0), "llm_local") - return data["choices"][0]["message"]["content"] + return data["choices"][0]["message"]["content"] async def stream( self, @@ -110,7 +106,6 @@ class LocalOpenAICompatibleLLM(LLMProvider): language: str | None = None, **kwargs, # z. B. on_tool_start — hier ignoriert (kein Tool-Calling) ) -> AsyncIterator[str]: - t0 = time.monotonic() async with httpx.AsyncClient(timeout=120) as client: async with client.stream( "POST", @@ -128,4 +123,3 @@ class LocalOpenAICompatibleLLM(LLMProvider): delta = sse_delta(line) if delta: yield delta - add_cost(electricity_usd(time.monotonic() - t0), "llm_local") diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index a20eae5..722f788 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -5,7 +5,6 @@ from collections.abc import AsyncIterator import httpx from app.core import clock -from app.core.costs import record_openrouter_cost, record_sse_usage from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -123,7 +122,6 @@ class OpenRouterLLMProvider(LLMProvider): payload = { "model": self.model, "messages": self._build_messages(text, history, language=language), - "usage": {"include": True}, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) last_error: Exception | None = None @@ -160,7 +158,6 @@ class OpenRouterLLMProvider(LLMProvider): raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc data = response.json() - record_openrouter_cost(data.get("usage"), "llm") try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: @@ -185,7 +182,6 @@ class OpenRouterLLMProvider(LLMProvider): "model": self.model, "messages": self._build_messages(text, history, language=language), "stream": True, - "usage": {"include": True}, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) last_error: Exception | None = None @@ -217,7 +213,6 @@ class OpenRouterLLMProvider(LLMProvider): f"{body.decode(errors='replace')}" ) async for line in response.aiter_lines(): - record_sse_usage(line, "llm") delta = sse_delta(line) if delta: yield delta diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 8340f24..97e8667 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -17,7 +17,6 @@ from collections.abc import AsyncIterator import httpx from app.core import clock -from app.core.costs import record_openrouter_cost from app.metrics import metrics from app.pipeline.search_backstop import should_force_search from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder @@ -181,8 +180,7 @@ class ToolCallingLLM(LLMProvider): async def _chat(self, messages: list[dict], allow_tools: bool = True) -> dict: """Ein Modell-Aufruf; liefert die Assistant-Message (mit/ohne tool_calls).""" - payload = {"model": self.model, "messages": messages, "temperature": self.temperature, - "usage": {"include": True}} + payload = {"model": self.model, "messages": messages, "temperature": self.temperature} if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" @@ -217,7 +215,6 @@ class ToolCallingLLM(LLMProvider): await asyncio.sleep(min(2.0 * attempt, 30.0)) continue raise RuntimeError(f"Ungültige OpenRouter-Antwort: {resp.text[:300]}") - record_openrouter_cost(data.get("usage"), "llm") return data["choices"][0]["message"] raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen") @@ -341,8 +338,8 @@ class ToolCallingLLM(LLMProvider): async def _stream_chat(self, messages: list[dict], allow_tools: bool, acc: _StreamAcc) -> AsyncIterator[str]: """Ein gestreamter Modell-Call. Yieldet Text-Deltas; füllt acc (Text + tool_calls).""" - payload = {"model": self.model, "messages": messages, "temperature": self.temperature, - "stream": True, "usage": {"include": True}} + payload = {"model": self.model, "messages": messages, + "temperature": self.temperature, "stream": True} if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" @@ -365,15 +362,9 @@ class ToolCallingLLM(LLMProvider): if not data or data == "[DONE]": continue try: - obj = json.loads(data) - except ValueError: + delta = json.loads(data)["choices"][0]["delta"] + except (ValueError, KeyError, IndexError, TypeError): continue - if obj.get("usage"): # finaler Chunk -> Ist-Kosten - record_openrouter_cost(obj["usage"], "llm") - choices = obj.get("choices") or [] - if not choices: - continue - delta = choices[0].get("delta") or {} content = delta.get("content") if content: acc.add_text(content) diff --git a/app/providers/tts/cartesia.py b/app/providers/tts/cartesia.py index 5a97541..fbcbe21 100644 --- a/app/providers/tts/cartesia.py +++ b/app/providers/tts/cartesia.py @@ -1,6 +1,5 @@ import httpx -from app.core.costs import add_cost, cartesia_usd from app.providers.tts.base import TTSProvider _API_URL = "https://api.cartesia.ai/tts/bytes" @@ -140,5 +139,4 @@ class CartesiaTTSProvider(TTSProvider): if not response.content: raise RuntimeError("Cartesia TTS returned empty audio") - add_cost(cartesia_usd(len(text.strip())), "tts") # geschätzt (Zeichen × Preis) return response.content diff --git a/app/quota.py b/app/quota.py index 4b0cc2c..5733e84 100644 --- a/app/quota.py +++ b/app/quota.py @@ -68,25 +68,3 @@ 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 4e76e74..ef99a53 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -34,7 +34,6 @@ 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/safety/emergency.py b/app/safety/emergency.py index d8b9a1d..0dfa255 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -24,7 +24,6 @@ from email.message import EmailMessage import httpx from app.config import settings -from app.core.costs import eur_to_usd from app.metrics import metrics logger = logging.getLogger(__name__) @@ -423,8 +422,6 @@ async def record_manual_emergency(user, store, language: str | None = None, webhook_sent = await _post_webhook(cfg.emergency_webhook_url, payload, cfg) if webhook_sent: metrics.inc("emergency_notify_total", {"channel": "webhook"}) - # Geschätzte Alarm-Kosten (SMS+Anruf) dokumentieren — nie blockieren. - store.add_cost_usage(user.id, "alert", eur_to_usd(cfg.emergency_alert_eur, cfg)) notified = email_sent or webhook_sent ready = notify_readiness(user, cfg) diff --git a/app/schemas.py b/app/schemas.py index 34a0a4b..6a9b0f8 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -165,9 +165,8 @@ 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/store.py b/app/store.py index 8629938..4a6b35d 100644 --- a/app/store.py +++ b/app/store.py @@ -128,14 +128,6 @@ class Store(ABC): def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int: """Zaehlt n Web-Suchen dazu und liefert die neue Tages-Zahl.""" - @abstractmethod - def add_cost_usage(self, user_id: str, category: str, usd: float, day: str | None = None) -> None: - """Bucht Kosten (USD) auf Nutzer/Tag/Kategorie.""" - - @abstractmethod - def cost_for_day(self, user_id: str, day: str | None = None) -> float: - """Summe der Kosten (USD) des Nutzers am Tag (Default: heute, UTC).""" - @abstractmethod def delete_user(self, user_id: str) -> bool: """Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen, @@ -251,13 +243,6 @@ class SQLiteStore(Store): count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, day) ); - CREATE TABLE IF NOT EXISTS cost_usage ( - user_id TEXT NOT NULL, - day TEXT NOT NULL, - category TEXT NOT NULL, - amount_usd REAL NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, day, category) - ); CREATE TABLE IF NOT EXISTS emergency_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -532,29 +517,6 @@ class SQLiteStore(Store): ).fetchone() return int(row["count"]) - def add_cost_usage(self, user_id: str, category: str, usd: float, - day: str | None = None) -> None: - if not usd or usd <= 0: - return - day = day or self._today() - with self._connect() as conn: - conn.execute( - "INSERT INTO cost_usage (user_id, day, category, amount_usd) VALUES (?, ?, ?, ?)" - " ON CONFLICT(user_id, day, category) DO UPDATE SET" - " amount_usd = amount_usd + excluded.amount_usd", - (user_id, day, category, float(usd)), - ) - - def cost_for_day(self, user_id: str, day: str | None = None) -> float: - day = day or self._today() - with self._connect() as conn: - row = conn.execute( - "SELECT COALESCE(SUM(amount_usd), 0) AS s FROM cost_usage" - " WHERE user_id = ? AND day = ?", - (user_id, day), - ).fetchone() - return float(row["s"]) if row else 0.0 - def delete_user(self, user_id: str) -> bool: if user_id == ANONYMOUS_USER_ID: raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.") @@ -570,7 +532,6 @@ class SQLiteStore(Store): conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM web_search_usage WHERE user_id = ?", (user_id,)) - conn.execute("DELETE FROM cost_usage WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) return True @@ -678,24 +639,13 @@ class SQLiteStore(Store): return [dict(r) for r in rows] def get_all_usage(self) -> list[dict]: - month = datetime.now(timezone.utc).strftime("%Y-%m") + "-%" with self._connect() as conn: rows = conn.execute( "SELECT u.user_id, COALESCE(usr.display_name, u.user_id) AS display_name," " SUM(u.requests) AS total_requests, SUM(u.units) AS total_units," - " MAX(u.day) AS last_active," - " COALESCE((SELECT SUM(w.count) FROM web_search_usage w" - " WHERE w.user_id = u.user_id), 0) AS total_web_searches," - " COALESCE((SELECT SUM(c.amount_usd) FROM cost_usage c" - " WHERE c.user_id = u.user_id AND c.day LIKE ?), 0) AS month_cost_usd," - " COALESCE((SELECT SUM(ce.amount_usd) FROM cost_usage ce" - " WHERE ce.user_id = u.user_id AND ce.day LIKE ?" - " AND ce.category IN ('tts','alert','llm_local')), 0) AS month_cost_estimated_usd," - " COALESCE((SELECT SUM(uu.requests) FROM usage uu" - " WHERE uu.user_id = u.user_id AND uu.day LIKE ?), 0) AS month_requests" + " MAX(u.day) AS last_active" " FROM usage u LEFT JOIN users usr ON usr.id = u.user_id" " GROUP BY u.user_id ORDER BY total_requests DESC", - (month, month, month), ).fetchall() return [dict(r) for r in rows] diff --git a/app/tools/web_search.py b/app/tools/web_search.py index 8b949ba..fa1ca67 100644 --- a/app/tools/web_search.py +++ b/app/tools/web_search.py @@ -11,7 +11,6 @@ from dataclasses import dataclass, field import httpx -from app.core.costs import record_openrouter_cost from app.metrics import metrics from app.providers.llm.base import lang_instruction @@ -71,8 +70,7 @@ class SonarTool: logger.warning("SonarTool ohne API-Key") return ToolResult(text=_NO_DATA, ok=False) - payload = {"model": self.model, "messages": self._messages(query, language), - "usage": {"include": True}} + payload = {"model": self.model, "messages": self._messages(query, language)} headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} timeout = httpx.Timeout(self.timeout) @@ -92,7 +90,6 @@ class SonarTool: return ToolResult(text=_NO_DATA, ok=False) data = resp.json() - record_openrouter_cost(data.get("usage"), "web_search") try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError): diff --git a/app/web/app.js b/app/web/app.js index 9496dc0..eff7acd 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1235,11 +1235,6 @@ function buildUserCard(u) { -
- max. Kosten pro Tag ($): - - -
@@ -1363,24 +1358,6 @@ 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"); @@ -1849,10 +1826,6 @@ async function loadMetrics() { if (!data.length) { container.innerHTML = '

Noch keine Nutzungsdaten vorhanden.

'; return; } const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); - const totalWS = data.reduce((s, u) => s + (u.total_web_searches || 0), 0); - const totalCost = data.reduce((s, u) => s + (u.month_cost_usd || 0), 0); - const totalEstimated = data.reduce((s, u) => s + (u.month_cost_estimated_usd || 0), 0); - const fmtUsd = (n) => "$" + (Number(n) || 0).toFixed(4); const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1); const bars = data.map((u) => { @@ -1874,9 +1847,6 @@ async function loadMetrics() { Nutzer Anfragen gesamt - mit Websuche - Kosten (Monat) - Ø/Anfrage Einheiten Letzte Aktivität @@ -1886,9 +1856,6 @@ async function loadMetrics() { ${escHtml(u.display_name)} ${u.total_requests ?? 0} - ${u.total_web_searches ?? 0} - ${fmtUsd(u.month_cost_usd)} - ${u.month_requests ? fmtUsd((u.month_cost_usd || 0) / u.month_requests) : "—"} ${u.total_units ?? 0} ${u.last_active || "—"} `).join("")} @@ -1897,9 +1864,6 @@ async function loadMetrics() { Gesamt ${totalReq} - ${totalWS} - ${fmtUsd(totalCost)} - @@ -1907,8 +1871,6 @@ async function loadMetrics() { -

Kosten = laufender Monat (USD). Exakt via OpenRouter (abgleichbar): ${fmtUsd(totalCost - totalEstimated)} · geschätzt (TTS/Notruf/Strom): ${fmtUsd(totalEstimated)}.

-

Anfragen je Nutzer

${bars}
diff --git a/pyproject.toml b/pyproject.toml index 282c506..68b674a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "voice-assistant-gateway" -version = "0.3.2" +version = "0.3.1" description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters" readme = "README.md" requires-python = ">=3.11"