feat(costs): Pro-Nutzer-Kostenerfassung Stufe 1 (OpenRouter-Ist-Kosten)

CostMeter via ContextVars (app/core/costs.py, multitool-ready). Alle
OpenRouter-Calls (LLM, Sonar, Koreferenz, Fallback) senden usage:{include:true}
und buchen die Ist-Kosten je Kategorie (llm/web_search) — inkl. finalem
Streaming-usage-Chunk. cost_usage-Tabelle (user/day/category) + Store-Methoden;
ws.py/chat.py start_meter pro Turn + persist_costs. Statistik: Spalten
"Kosten (Monat)" + "Ø/Anfrage" + Gesamt. Live: 1 Such-Turn ~ $0,0052
(web_search dominiert, LLM ~ $0,00007). Tests: 318.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 11:08:45 +02:00
commit d87f0ce424
9 changed files with 171 additions and 10 deletions

View file

@ -16,7 +16,8 @@ from app.dependencies import (
voice_for_route,
)
from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.core.costs import start_meter, persist_costs
from app.quota import enforce_quota, record_usage, QuotaExceededError, record_web_search
from app.runtime_config import runtime_settings
from app.safety.emergency import (
record_manual_emergency,
@ -97,6 +98,7 @@ 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,
@ -110,6 +112,8 @@ 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:

View file

@ -34,6 +34,7 @@ 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)
from app.core.costs import start_meter, persist_costs
from app.pipeline.search_backstop import should_force_search
# Freundliche Hinweise (Senioren), wenn die Web-Suche bei einer offensichtlich
@ -106,6 +107,7 @@ 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
@ -199,6 +201,7 @@ 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)