feat: Credits-System mit Verbrauchsanzeige im User-Dropdown
- DB-Migration: credits_total/credits_used/cost_per_credit auf licenses, token_usage_monthly Tabelle - Orchestrator: Monatliche Token-Aggregation + Credits-Abzug nach Refresh - Auth: Credits-Daten im /me Endpoint + Bugfix fehlende Klammer in get() - Frontend: Credits-Balken im User-Dropdown mit Farbwechsel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -1316,6 +1316,41 @@ class AgentOrchestrator:
|
|||||||
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
|
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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, input_tokens, output_tokens,
|
||||||
|
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 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
|
||||||
|
""", (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()
|
||||||
|
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:
|
||||||
asyncio.create_task(_background_discover_sources(unique_results))
|
asyncio.create_task(_background_discover_sources(unique_results))
|
||||||
|
|||||||
@@ -582,7 +582,42 @@ async def init_db():
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Migration: article_locations-Tabelle erstellt")
|
logger.info("Migration: article_locations-Tabelle erstellt")
|
||||||
|
|
||||||
# Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
|
|
||||||
|
# Migration: Credits-System fuer Lizenzen
|
||||||
|
cursor = await db.execute("PRAGMA table_info(licenses)")
|
||||||
|
columns = [row[1] for row in await cursor.fetchall()]
|
||||||
|
if "token_budget_usd" not in columns:
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN token_budget_usd REAL")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN credits_total INTEGER")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN credits_used REAL DEFAULT 0")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN cost_per_credit REAL")
|
||||||
|
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_percent INTEGER DEFAULT 80")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt")
|
||||||
|
|
||||||
|
# Migration: Token-Usage-Monatstabelle
|
||||||
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE token_usage_monthly (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
organization_id INTEGER REFERENCES organizations(id),
|
||||||
|
year_month TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER DEFAULT 0,
|
||||||
|
output_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_creation_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER DEFAULT 0,
|
||||||
|
total_cost_usd REAL DEFAULT 0.0,
|
||||||
|
api_calls INTEGER DEFAULT 0,
|
||||||
|
refresh_count INTEGER DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(organization_id, year_month)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: token_usage_monthly Tabelle erstellt")
|
||||||
|
|
||||||
|
# Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',
|
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',
|
||||||
completed_at = CURRENT_TIMESTAMP
|
completed_at = CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class UserMeResponse(BaseModel):
|
|||||||
license_status: str = "unknown"
|
license_status: str = "unknown"
|
||||||
license_type: str = ""
|
license_type: str = ""
|
||||||
read_only: bool = False
|
read_only: bool = False
|
||||||
|
credits_total: Optional[int] = None
|
||||||
|
credits_remaining: Optional[int] = None
|
||||||
|
credits_percent_used: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
# Incidents (Lagen)
|
# Incidents (Lagen)
|
||||||
|
|||||||
@@ -261,10 +261,28 @@ async def get_me(
|
|||||||
from services.license_service import check_license
|
from services.license_service import check_license
|
||||||
license_info = await check_license(db, current_user["tenant_id"])
|
license_info = await check_license(db, current_user["tenant_id"])
|
||||||
|
|
||||||
|
# Credits-Daten laden
|
||||||
|
credits_total = None
|
||||||
|
credits_remaining = None
|
||||||
|
credits_percent_used = None
|
||||||
|
if current_user.get("tenant_id"):
|
||||||
|
lic_cursor = await db.execute(
|
||||||
|
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_user["tenant_id"],))
|
||||||
|
lic_row = await lic_cursor.fetchone()
|
||||||
|
if lic_row and lic_row["credits_total"]:
|
||||||
|
credits_total = lic_row["credits_total"]
|
||||||
|
credits_used = lic_row["credits_used"] or 0
|
||||||
|
credits_remaining = max(0, int(credits_total - credits_used))
|
||||||
|
credits_percent_used = round(min(100, (credits_used / credits_total) * 100), 1) if credits_total > 0 else 0
|
||||||
|
|
||||||
return UserMeResponse(
|
return UserMeResponse(
|
||||||
id=current_user["id"],
|
id=current_user["id"],
|
||||||
username=current_user["username"],
|
username=current_user["username"],
|
||||||
email=current_user.get("email", ""),
|
email=current_user.get("email", ""),
|
||||||
|
credits_total=credits_total,
|
||||||
|
credits_remaining=credits_remaining,
|
||||||
|
credits_percent_used=credits_percent_used,
|
||||||
role=current_user["role"],
|
role=current_user["role"],
|
||||||
org_name=org_name,
|
org_name=org_name,
|
||||||
org_slug=current_user.get("org_slug", ""),
|
org_slug=current_user.get("org_slug", ""),
|
||||||
|
|||||||
@@ -5335,3 +5335,61 @@ body.tutorial-active .tutorial-cursor {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(150, 121, 26, 0.25);
|
box-shadow: 0 0 0 2px rgba(150, 121, 26, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Credits-Anzeige im User-Dropdown ===== */
|
||||||
|
.credits-section {
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.6s ease, background-color 0.3s ease;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar.warning {
|
||||||
|
background: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-bar.critical {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-info span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,16 @@
|
|||||||
<span class="header-dropdown-label">Lizenz</span>
|
<span class="header-dropdown-label">Lizenz</span>
|
||||||
<span class="header-dropdown-value" id="header-license-info">-</span>
|
<span class="header-dropdown-value" id="header-license-info">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="credits-section" class="credits-section" style="display: none;">
|
||||||
|
<div class="credits-divider"></div>
|
||||||
|
<div class="credits-label">Credits</div>
|
||||||
|
<div class="credits-bar-container">
|
||||||
|
<div id="credits-bar" class="credits-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="credits-info">
|
||||||
|
<span id="credits-remaining">0</span> von <span id="credits-total">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-license-warning" id="header-license-warning"></div>
|
<div class="header-license-warning" id="header-license-warning"></div>
|
||||||
|
|||||||
@@ -466,6 +466,32 @@ const App = {
|
|||||||
licInfoEl.textContent = label;
|
licInfoEl.textContent = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Credits-Anzeige im Dropdown
|
||||||
|
const creditsSection = document.getElementById('credits-section');
|
||||||
|
if (creditsSection && user.credits_total) {
|
||||||
|
creditsSection.style.display = 'block';
|
||||||
|
const bar = document.getElementById('credits-bar');
|
||||||
|
const remainingEl = document.getElementById('credits-remaining');
|
||||||
|
const totalEl = document.getElementById('credits-total');
|
||||||
|
|
||||||
|
const remaining = user.credits_remaining || 0;
|
||||||
|
const total = user.credits_total || 1;
|
||||||
|
const percentUsed = user.credits_percent_used || 0;
|
||||||
|
const percentRemaining = Math.max(0, 100 - percentUsed);
|
||||||
|
|
||||||
|
remainingEl.textContent = remaining.toLocaleString('de-DE');
|
||||||
|
totalEl.textContent = total.toLocaleString('de-DE');
|
||||||
|
bar.style.width = percentRemaining + '%';
|
||||||
|
|
||||||
|
// Farbwechsel je nach Verbrauch
|
||||||
|
bar.classList.remove('warning', 'critical');
|
||||||
|
if (percentUsed > 80) {
|
||||||
|
bar.classList.add('critical');
|
||||||
|
} else if (percentUsed > 50) {
|
||||||
|
bar.classList.add('warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dropdown Toggle
|
// Dropdown Toggle
|
||||||
const userBtn = document.getElementById('header-user-btn');
|
const userBtn = document.getElementById('header-user-btn');
|
||||||
const userDropdown = document.getElementById('header-user-dropdown');
|
const userDropdown = document.getElementById('header-user-dropdown');
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren