From e8ac0d0c505a546b222020d0f183a73bf60f470f Mon Sep 17 00:00:00 2001 From: claude-dev Date: Thu, 23 Apr 2026 17:49:32 +0000 Subject: [PATCH] Block A: License-Check + Credits-Tracking fuer Enhance und Chat - Neuer Helper charge_usage_to_tenant() in services/license_service.py: UPSERT in token_usage_monthly und Credits-Abzug aus licenses.credits_used. Wiederverwendbar fuer alle Claude-Call-Verursacher. - Orchestrator: Inline-Buchungslogik (35 Zeilen) durch Helper-Aufruf ersetzt. - routers/incidents.py POST /enhance-description: require_writable_license statt get_current_user, db_dependency hinzugefuegt, Credits-Buchung mit source="enhance" nach jedem Claude-Call. - routers/chat.py POST /: analog require_writable_license + Credits-Buchung mit source="chat". _call_claude_chat() gibt jetzt zusaetzlich ClaudeUsage zurueck. Abgelaufene/gesperrte Lizenzen koennen damit keine Haiku-Calls mehr ausloesen, und alle Kosten werden konsistent auf Tenant-Ebene verbucht. --- src/agents/orchestrator.py | 33 +------------ src/routers/chat.py | 34 ++++++++++--- src/routers/incidents.py | 6 ++- src/services/license_service.py | 86 +++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 40 deletions(-) diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index 66244c5..23c5247 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -1528,38 +1528,9 @@ class AgentOrchestrator: # Credits-Tracking: Monatliche Aggregation + Credits abziehen if tenant_id and usage_acc.total_cost_usd > 0: - year_month = datetime.now(TIMEZONE).strftime('%Y-%m') - await db.execute(""" - INSERT INTO token_usage_monthly - (organization_id, year_month, source, input_tokens, output_tokens, - cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count) - VALUES (?, ?, 'monitor', ?, ?, ?, ?, ?, ?, 1) - ON CONFLICT(organization_id, year_month, source) DO UPDATE SET - input_tokens = input_tokens + excluded.input_tokens, - output_tokens = output_tokens + excluded.output_tokens, - cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens, - cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens, - total_cost_usd = total_cost_usd + excluded.total_cost_usd, - api_calls = api_calls + excluded.api_calls, - refresh_count = refresh_count + 1, - updated_at = CURRENT_TIMESTAMP - """, (tenant_id, year_month, - usage_acc.input_tokens, usage_acc.output_tokens, - usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens, - round(usage_acc.total_cost_usd, 7), usage_acc.call_count)) - - # Credits auf Lizenz abziehen - lic_cursor = await db.execute( - "SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", - (tenant_id,)) - lic = await lic_cursor.fetchone() - if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0: - credits_consumed = usage_acc.total_cost_usd / lic["cost_per_credit"] - await db.execute( - "UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'", - (round(credits_consumed, 2), tenant_id)) + from services.license_service import charge_usage_to_tenant + await charge_usage_to_tenant(db, tenant_id, usage_acc, source="monitor") await db.commit() - logger.info(f"Credits: {round(credits_consumed, 1) if lic and lic['cost_per_credit'] else 0} abgezogen für Tenant {tenant_id}") # Quellen-Discovery im Background starten if unique_results: diff --git a/src/routers/chat.py b/src/routers/chat.py index 40b5c74..93fc0a1 100644 --- a/src/routers/chat.py +++ b/src/routers/chat.py @@ -12,6 +12,11 @@ from pydantic import BaseModel, Field from auth import get_current_user from config import CLAUDE_PATH, CLAUDE_MODEL_FAST +from database import db_dependency +from middleware.license_check import require_writable_license +from services.license_service import charge_usage_to_tenant +from agents.claude_client import ClaudeUsage +import aiosqlite logger = logging.getLogger("osint.chat") @@ -21,8 +26,8 @@ router = APIRouter(tags=["chat"]) # Claude CLI Aufruf (Chat-spezifisch, kein JSON-Modus) # --------------------------------------------------------------------------- -async def _call_claude_chat(prompt: str) -> tuple[str, int]: - """Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms) zurueck. +async def _call_claude_chat(prompt: str) -> tuple[str, int, ClaudeUsage]: + """Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms, usage) zurueck. Anders als call_claude(): kein JSON-Output-Modus, kein append-system-prompt. """ @@ -62,21 +67,29 @@ async def _call_claude_chat(prompt: str) -> tuple[str, int]: raw = stdout.decode("utf-8", errors="replace").strip() duration_ms = 0 result_text = raw + usage = ClaudeUsage() try: data = _json.loads(raw) result_text = data.get("result", raw) duration_ms = data.get("duration_ms", 0) - cost = data.get("total_cost_usd", 0.0) u = data.get("usage", {}) + usage = ClaudeUsage( + input_tokens=u.get("input_tokens", 0), + output_tokens=u.get("output_tokens", 0), + cache_creation_tokens=u.get("cache_creation_input_tokens", 0), + cache_read_tokens=u.get("cache_read_input_tokens", 0), + cost_usd=data.get("total_cost_usd", 0.0), + duration_ms=duration_ms, + ) logger.info( - f"Chat Claude: {u.get('input_tokens', 0)} in / {u.get('output_tokens', 0)} out / " - f"${cost:.4f} / {duration_ms}ms" + f"Chat Claude: {usage.input_tokens} in / {usage.output_tokens} out / " + f"${usage.cost_usd:.4f} / {duration_ms}ms" ) except _json.JSONDecodeError: logger.warning("Chat Claude CLI Antwort kein JSON, nutze raw output") - return result_text, duration_ms + return result_text, duration_ms, usage # --------------------------------------------------------------------------- # Models @@ -395,7 +408,8 @@ def _build_prompt(user_message: str, history: list[dict]) -> str: @router.post("", response_model=ChatResponse) async def chat( req: ChatRequest, - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(require_writable_license), + db: aiosqlite.Connection = Depends(db_dependency), ): """Chat-Nachricht verarbeiten und Antwort generieren.""" user_id = current_user["id"] @@ -420,7 +434,7 @@ async def chat( # Claude CLI aufrufen try: - result, duration_ms = await _call_claude_chat(prompt) + result, duration_ms, usage = await _call_claude_chat(prompt) except TimeoutError: raise HTTPException(status_code=504, detail="Der Assistent antwortet gerade nicht. Bitte versuche es erneut.") except RuntimeError as e: @@ -430,6 +444,10 @@ async def chat( logger.error(f"Chat Claude-Fehler: {e}") raise HTTPException(status_code=502, detail="Der Assistent ist voruebergehend nicht erreichbar.") + # Credits buchen + await charge_usage_to_tenant(db, current_user.get("tenant_id"), usage, source="chat") + await db.commit() + # Output sanitieren reply = _sanitize_output(result) if not reply: diff --git a/src/routers/incidents.py b/src/routers/incidents.py index 9a7c8ad..fd02e8c 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -241,11 +241,13 @@ _enhance_logger = logging.getLogger("osint.enhance") @router.post("/enhance-description") async def enhance_description( data: DescriptionEnhanceRequest, - current_user: dict = Depends(get_current_user), + current_user: dict = Depends(require_writable_license), + db: aiosqlite.Connection = Depends(db_dependency), ): """Generiert eine strukturierte Beschreibung per KI aus dem Titel.""" from agents.claude_client import call_claude from config import CLAUDE_MODEL_FAST + from services.license_service import charge_usage_to_tenant template = ENHANCE_PROMPT_RESEARCH if data.type == "research" else ENHANCE_PROMPT_ADHOC context = data.description.strip() if data.description and data.description.strip() else "Kein Kontext angegeben" @@ -257,6 +259,8 @@ async def enhance_description( f"Beschreibung generiert fuer \"{data.title[:50]}\": " f"{usage.input_tokens}in/{usage.output_tokens}out" ) + await charge_usage_to_tenant(db, current_user.get("tenant_id"), usage, source="enhance") + await db.commit() return {"description": result.strip()} except Exception as e: _enhance_logger.error(f"Beschreibung generieren fehlgeschlagen: {e}") diff --git a/src/services/license_service.py b/src/services/license_service.py index e292147..61eaa11 100644 --- a/src/services/license_service.py +++ b/src/services/license_service.py @@ -91,6 +91,92 @@ async def can_add_user(db: aiosqlite.Connection, organization_id: int) -> tuple[ return True, "" +async def charge_usage_to_tenant( + db: aiosqlite.Connection, + tenant_id: int | None, + usage, + source: str, +) -> None: + """Verbucht Token-Verbrauch auf einen Tenant. + + Aktualisiert `token_usage_monthly` (UPSERT pro organization_id+year_month+source) + und zieht Credits von der aktiven Lizenz ab (wenn cost_per_credit gesetzt). + + Args: + db: offene aiosqlite.Connection + tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung) + usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/ + cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count + source: 'monitor' | 'enhance' | 'chat' + + Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller. + Ohne Verbrauch (total_cost_usd == 0) oder ohne tenant_id wird nichts gebucht. + """ + total_cost = getattr(usage, "total_cost_usd", None) + if total_cost is None: + total_cost = getattr(usage, "cost_usd", 0.0) + + if not tenant_id: + logger.info( + f"charge_usage_to_tenant[{source}]: kein tenant_id, uebersprungen " + f"(cost=${total_cost:.4f})" + ) + return + + if total_cost <= 0: + return + + input_tokens = getattr(usage, "input_tokens", 0) + output_tokens = getattr(usage, "output_tokens", 0) + cache_creation = getattr(usage, "cache_creation_tokens", 0) + cache_read = getattr(usage, "cache_read_tokens", 0) + api_calls = getattr(usage, "call_count", 1) + refresh_increment = 1 if source == "monitor" else 0 + + year_month = datetime.now(TIMEZONE).strftime("%Y-%m") + + await db.execute( + """ + INSERT INTO token_usage_monthly + (organization_id, year_month, source, input_tokens, output_tokens, + cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(organization_id, year_month, source) DO UPDATE SET + input_tokens = input_tokens + excluded.input_tokens, + output_tokens = output_tokens + excluded.output_tokens, + cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens, + cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens, + total_cost_usd = total_cost_usd + excluded.total_cost_usd, + api_calls = api_calls + excluded.api_calls, + refresh_count = refresh_count + excluded.refresh_count, + updated_at = CURRENT_TIMESTAMP + """, + ( + tenant_id, year_month, source, + input_tokens, output_tokens, cache_creation, cache_read, + round(total_cost, 7), api_calls, refresh_increment, + ), + ) + + lic_cursor = await db.execute( + "SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", + (tenant_id,), + ) + lic = await lic_cursor.fetchone() + credits_consumed = 0.0 + if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0: + credits_consumed = total_cost / lic["cost_per_credit"] + await db.execute( + "UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'", + (round(credits_consumed, 2), tenant_id), + ) + + logger.info( + f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: " + f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits" + ) + + async def expire_licenses(db: aiosqlite.Connection): """Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen.""" cursor = await db.execute(