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.
Dieser Commit ist enthalten in:
@@ -1528,38 +1528,9 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
# Credits-Tracking: Monatliche Aggregation + Credits abziehen
|
# Credits-Tracking: Monatliche Aggregation + Credits abziehen
|
||||||
if tenant_id and usage_acc.total_cost_usd > 0:
|
if tenant_id and usage_acc.total_cost_usd > 0:
|
||||||
year_month = datetime.now(TIMEZONE).strftime('%Y-%m')
|
from services.license_service import charge_usage_to_tenant
|
||||||
await db.execute("""
|
await charge_usage_to_tenant(db, tenant_id, usage_acc, source="monitor")
|
||||||
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))
|
|
||||||
await db.commit()
|
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
|
# Quellen-Discovery im Background starten
|
||||||
if unique_results:
|
if unique_results:
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from config import CLAUDE_PATH, CLAUDE_MODEL_FAST
|
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")
|
logger = logging.getLogger("osint.chat")
|
||||||
|
|
||||||
@@ -21,8 +26,8 @@ router = APIRouter(tags=["chat"])
|
|||||||
# Claude CLI Aufruf (Chat-spezifisch, kein JSON-Modus)
|
# Claude CLI Aufruf (Chat-spezifisch, kein JSON-Modus)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _call_claude_chat(prompt: str) -> tuple[str, int]:
|
async def _call_claude_chat(prompt: str) -> tuple[str, int, ClaudeUsage]:
|
||||||
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms) zurueck.
|
"""Ruft Claude CLI fuer Chat auf. Gibt (text, duration_ms, usage) zurueck.
|
||||||
|
|
||||||
Anders als call_claude(): kein JSON-Output-Modus, kein append-system-prompt.
|
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()
|
raw = stdout.decode("utf-8", errors="replace").strip()
|
||||||
duration_ms = 0
|
duration_ms = 0
|
||||||
result_text = raw
|
result_text = raw
|
||||||
|
usage = ClaudeUsage()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = _json.loads(raw)
|
data = _json.loads(raw)
|
||||||
result_text = data.get("result", raw)
|
result_text = data.get("result", raw)
|
||||||
duration_ms = data.get("duration_ms", 0)
|
duration_ms = data.get("duration_ms", 0)
|
||||||
cost = data.get("total_cost_usd", 0.0)
|
|
||||||
u = data.get("usage", {})
|
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(
|
logger.info(
|
||||||
f"Chat Claude: {u.get('input_tokens', 0)} in / {u.get('output_tokens', 0)} out / "
|
f"Chat Claude: {usage.input_tokens} in / {usage.output_tokens} out / "
|
||||||
f"${cost:.4f} / {duration_ms}ms"
|
f"${usage.cost_usd:.4f} / {duration_ms}ms"
|
||||||
)
|
)
|
||||||
except _json.JSONDecodeError:
|
except _json.JSONDecodeError:
|
||||||
logger.warning("Chat Claude CLI Antwort kein JSON, nutze raw output")
|
logger.warning("Chat Claude CLI Antwort kein JSON, nutze raw output")
|
||||||
|
|
||||||
return result_text, duration_ms
|
return result_text, duration_ms, usage
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Models
|
# Models
|
||||||
@@ -395,7 +408,8 @@ def _build_prompt(user_message: str, history: list[dict]) -> str:
|
|||||||
@router.post("", response_model=ChatResponse)
|
@router.post("", response_model=ChatResponse)
|
||||||
async def chat(
|
async def chat(
|
||||||
req: ChatRequest,
|
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."""
|
"""Chat-Nachricht verarbeiten und Antwort generieren."""
|
||||||
user_id = current_user["id"]
|
user_id = current_user["id"]
|
||||||
@@ -420,7 +434,7 @@ async def chat(
|
|||||||
|
|
||||||
# Claude CLI aufrufen
|
# Claude CLI aufrufen
|
||||||
try:
|
try:
|
||||||
result, duration_ms = await _call_claude_chat(prompt)
|
result, duration_ms, usage = await _call_claude_chat(prompt)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise HTTPException(status_code=504, detail="Der Assistent antwortet gerade nicht. Bitte versuche es erneut.")
|
raise HTTPException(status_code=504, detail="Der Assistent antwortet gerade nicht. Bitte versuche es erneut.")
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -430,6 +444,10 @@ async def chat(
|
|||||||
logger.error(f"Chat Claude-Fehler: {e}")
|
logger.error(f"Chat Claude-Fehler: {e}")
|
||||||
raise HTTPException(status_code=502, detail="Der Assistent ist voruebergehend nicht erreichbar.")
|
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
|
# Output sanitieren
|
||||||
reply = _sanitize_output(result)
|
reply = _sanitize_output(result)
|
||||||
if not reply:
|
if not reply:
|
||||||
|
|||||||
@@ -241,11 +241,13 @@ _enhance_logger = logging.getLogger("osint.enhance")
|
|||||||
@router.post("/enhance-description")
|
@router.post("/enhance-description")
|
||||||
async def enhance_description(
|
async def enhance_description(
|
||||||
data: DescriptionEnhanceRequest,
|
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."""
|
"""Generiert eine strukturierte Beschreibung per KI aus dem Titel."""
|
||||||
from agents.claude_client import call_claude
|
from agents.claude_client import call_claude
|
||||||
from config import CLAUDE_MODEL_FAST
|
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
|
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"
|
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"Beschreibung generiert fuer \"{data.title[:50]}\": "
|
||||||
f"{usage.input_tokens}in/{usage.output_tokens}out"
|
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()}
|
return {"description": result.strip()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_enhance_logger.error(f"Beschreibung generieren fehlgeschlagen: {e}")
|
_enhance_logger.error(f"Beschreibung generieren fehlgeschlagen: {e}")
|
||||||
|
|||||||
@@ -91,6 +91,92 @@ async def can_add_user(db: aiosqlite.Connection, organization_id: int) -> tuple[
|
|||||||
return True, ""
|
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):
|
async def expire_licenses(db: aiosqlite.Connection):
|
||||||
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
|
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren