my_voice_assistant_v3_jamulix/Docs/kosten-erfassung.md
dschlueter 21a5250282 docs: Konzept Pro-Nutzer-Kostenerfassung (USD)
CostMeter via ContextVars (multitool-ready), OpenRouter-Ist-Kosten inline,
geschaetzte Quellen (Cartesia/Notruf), cost_usage-Tabelle, Darstellung
(laufender Monat) + $-Tagesbudget. Docs/kosten-erfassung.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:57:20 +02:00

9.9 KiB
Raw Blame History

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) 0 $ (nicht über OpenRouter; Strom = amortisierte Infrastruktur, NICHT pro Anfrage)
Cartesia-TTS synth. Zeichen × Preis (100k Credits ≈ 100.000 Zeichen → 0,00005 $/Zeichen) geschätzt
piper / Gerät-TTS 0 $
Notruf (SMS+Voice) pro emergency_event × Preis (≈ 0,22 $) geschätzt
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:

# 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):

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:<name>
    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:<name>-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.pyusage:{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.pystart_meter() pro Turn + Persist
  • app/store.pycost_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)