From 19c9376f7d90a21027d5d1f2d2c7acb2694e2fc3 Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Thu, 26 Mar 2026 20:33:04 +0100 Subject: [PATCH] feat: Token-Tracking fuer VLM-Bildanalyse Usage-Daten (input/output/cache tokens, theoretische USD-Kosten) werden jetzt wie beim Monitor aus der Claude CLI-Antwort extrahiert und in token_usage_monthly geschrieben. Credits werden auf der Lizenz abgezogen. Verwaltungsportal zeigt die Zahlen automatisch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/data_vlm.py | 70 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/src/data_vlm.py b/src/data_vlm.py index 2ca4dfb..33dd6cc 100644 --- a/src/data_vlm.py +++ b/src/data_vlm.py @@ -9,8 +9,11 @@ from pathlib import Path from PIL import Image from data_geoint import extract_exif -from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from pydantic import BaseModel +from auth import get_current_user +from database import get_db logger = logging.getLogger("globe.vlm") router = APIRouter() @@ -378,15 +381,30 @@ async def _run_claude(image_path: str, viewport_info: str = "", filename: str = try: wrapper = json.loads(stdout.decode()) + # Usage extrahieren (theoretische Token-Zahlen wie beim Monitor) + u = wrapper.get("usage", {}) + usage = { + "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), + "total_cost_usd": wrapper.get("total_cost_usd", 0.0), + } + logger.info( + f"VLM-Usage: {usage['input_tokens']} in / {usage['output_tokens']} out / " + f"cache {usage['cache_creation_tokens']}+{usage['cache_read_tokens']} / " + f"${usage['total_cost_usd']:.4f}" + ) + # --json-schema liefert structured_output (bevorzugt) structured = wrapper.get("structured_output") if structured and isinstance(structured, dict): - return structured + return structured, usage # Fallback: result als JSON-String parsen result_str = wrapper.get("result", "") if isinstance(result_str, str) and result_str.strip().startswith("{"): - return json.loads(result_str) + return json.loads(result_str), usage # Fallback: Claude hat Klartext statt JSON geliefert logger.warning(f"VLM: Kein strukturiertes JSON, Klartext: {result_str[:200]}") @@ -395,7 +413,7 @@ async def _run_claude(image_path: str, viewport_info: str = "", filename: str = "objects": [], "terrain": "unknown", "estimated_location_type": "unknown", - } + }, usage except (json.JSONDecodeError, KeyError) as e: logger.error(f"VLM JSON-Parse-Fehler: {e}, stdout={stdout.decode()[:500]}") raise HTTPException(502, "VLM-Analyse: Unerwartetes Antwortformat") @@ -405,6 +423,8 @@ async def _run_claude(image_path: str, viewport_info: str = "", filename: str = async def analyze_image( file: UploadFile = File(...), viewport_info: str = Form(""), + user: dict = Depends(get_current_user), + db=Depends(get_db), ): """Analysiert ein hochgeladenes Bild mittels Claude Code VLM.""" # Validierung @@ -439,7 +459,7 @@ async def analyze_image( logger.info(f"EXIF GPS: {exif_data['latitude']}, {exif_data['longitude']}") # Claude Code aufrufen (Dateiname + EXIF als Kontext, NICHT Viewport) - result = await _run_claude(resized_path, filename=file.filename, exif_data=exif_data) + result, usage = await _run_claude(resized_path, filename=file.filename, exif_data=exif_data) # EXIF-Daten zum Ergebnis hinzufuegen result["exif"] = exif_data @@ -452,6 +472,46 @@ async def analyze_image( f"Koordinaten: {ec.get('latitude')}/{ec.get('longitude')} (r={ec.get('confidence_radius_km')}km), " f"Gewaesser: {idf.get('water_body')}, Region: {idf.get('specific_region')}" ) + + # Token-Tracking: Usage in DB schreiben (wie Monitor-Orchestrator) + org_id = user.get("organization_id") + if org_id and usage.get("total_cost_usd", 0) > 0: + try: + year_month = datetime.now(timezone.utc).strftime('%Y-%m') + await db.execute(""" + INSERT INTO token_usage_monthly + (organization_id, year_month, input_tokens, output_tokens, + cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1) + ON CONFLICT(organization_id, year_month) 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 + """, (org_id, year_month, + usage["input_tokens"], usage["output_tokens"], + usage["cache_creation_tokens"], usage["cache_read_tokens"], + round(usage["total_cost_usd"], 7))) + + # 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", + (org_id,)) + lic = await lic_cursor.fetchone() + if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0: + credits_consumed = usage["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), org_id)) + logger.info(f"VLM Credits: {round(credits_consumed, 2)} abgezogen fuer Org {org_id}") + await db.commit() + except Exception as e: + logger.error(f"VLM Token-Tracking Fehler: {e}") + return result finally: