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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-03-26 20:33:04 +01:00
Ursprung 86db66a787
Commit 19c9376f7d

Datei anzeigen

@@ -9,8 +9,11 @@ from pathlib import Path
from PIL import Image from PIL import Image
from data_geoint import extract_exif 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 pydantic import BaseModel
from auth import get_current_user
from database import get_db
logger = logging.getLogger("globe.vlm") logger = logging.getLogger("globe.vlm")
router = APIRouter() router = APIRouter()
@@ -378,15 +381,30 @@ async def _run_claude(image_path: str, viewport_info: str = "", filename: str =
try: try:
wrapper = json.loads(stdout.decode()) 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) # --json-schema liefert structured_output (bevorzugt)
structured = wrapper.get("structured_output") structured = wrapper.get("structured_output")
if structured and isinstance(structured, dict): if structured and isinstance(structured, dict):
return structured return structured, usage
# Fallback: result als JSON-String parsen # Fallback: result als JSON-String parsen
result_str = wrapper.get("result", "") result_str = wrapper.get("result", "")
if isinstance(result_str, str) and result_str.strip().startswith("{"): 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 # Fallback: Claude hat Klartext statt JSON geliefert
logger.warning(f"VLM: Kein strukturiertes JSON, Klartext: {result_str[:200]}") 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": [], "objects": [],
"terrain": "unknown", "terrain": "unknown",
"estimated_location_type": "unknown", "estimated_location_type": "unknown",
} }, usage
except (json.JSONDecodeError, KeyError) as e: except (json.JSONDecodeError, KeyError) as e:
logger.error(f"VLM JSON-Parse-Fehler: {e}, stdout={stdout.decode()[:500]}") logger.error(f"VLM JSON-Parse-Fehler: {e}, stdout={stdout.decode()[:500]}")
raise HTTPException(502, "VLM-Analyse: Unerwartetes Antwortformat") 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( async def analyze_image(
file: UploadFile = File(...), file: UploadFile = File(...),
viewport_info: str = Form(""), viewport_info: str = Form(""),
user: dict = Depends(get_current_user),
db=Depends(get_db),
): ):
"""Analysiert ein hochgeladenes Bild mittels Claude Code VLM.""" """Analysiert ein hochgeladenes Bild mittels Claude Code VLM."""
# Validierung # Validierung
@@ -439,7 +459,7 @@ async def analyze_image(
logger.info(f"EXIF GPS: {exif_data['latitude']}, {exif_data['longitude']}") logger.info(f"EXIF GPS: {exif_data['latitude']}, {exif_data['longitude']}")
# Claude Code aufrufen (Dateiname + EXIF als Kontext, NICHT Viewport) # 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 # EXIF-Daten zum Ergebnis hinzufuegen
result["exif"] = exif_data 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"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')}" 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 return result
finally: finally: