Add API cost tracking to admin dashboard
Track estimated Anthropic API costs per request across all Claude API call sites (recommender, analyze, artist-dive, generate-playlist, crate, rabbit-hole, playlist-fix, timeline, compatibility). Log token usage and estimated cost to the app logger. Aggregate costs in admin stats endpoint and display total/today costs and token usage in the admin dashboard.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@@ -113,6 +114,28 @@ async def get_stats(
|
|||||||
for row in user_stats_result.all()
|
for row in user_stats_result.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Calculate API costs from log buffer
|
||||||
|
total_cost = 0.0
|
||||||
|
today_cost = 0.0
|
||||||
|
total_tokens_in = 0
|
||||||
|
total_tokens_out = 0
|
||||||
|
for log in _log_buffer:
|
||||||
|
if 'API_COST' in log.get('message', ''):
|
||||||
|
msg = log['message']
|
||||||
|
cost_match = re.search(r'cost=\$([0-9.]+)', msg)
|
||||||
|
if cost_match:
|
||||||
|
c = float(cost_match.group(1))
|
||||||
|
total_cost += c
|
||||||
|
# Check if today
|
||||||
|
if log['timestamp'][:10] == now.strftime('%Y-%m-%d'):
|
||||||
|
today_cost += c
|
||||||
|
input_match = re.search(r'input=(\d+)', msg)
|
||||||
|
output_match = re.search(r'output=(\d+)', msg)
|
||||||
|
if input_match:
|
||||||
|
total_tokens_in += int(input_match.group(1))
|
||||||
|
if output_match:
|
||||||
|
total_tokens_out += int(output_match.group(1))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"users": {
|
"users": {
|
||||||
"total": total_users,
|
"total": total_users,
|
||||||
@@ -131,6 +154,12 @@ async def get_stats(
|
|||||||
"saved": saved_recs,
|
"saved": saved_recs,
|
||||||
"disliked": disliked_recs,
|
"disliked": disliked_recs,
|
||||||
},
|
},
|
||||||
|
"api_costs": {
|
||||||
|
"total_estimated": round(total_cost, 4),
|
||||||
|
"today_estimated": round(today_cost, 4),
|
||||||
|
"total_input_tokens": total_tokens_in,
|
||||||
|
"total_output_tokens": total_tokens_out,
|
||||||
|
},
|
||||||
"user_breakdown": user_breakdown,
|
"user_breakdown": user_breakdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
|
||||||
|
api_logger = logging.getLogger("app")
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -144,6 +147,12 @@ Return ONLY the JSON. Include exactly 5 recommendations."""
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost (Haiku: $0.80/M input, $4/M output)
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user=system|endpoint=compatibility")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = message.content[0].text.strip()
|
text = message.content[0].text.strip()
|
||||||
if text.startswith("```"):
|
if text.startswith("```"):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
|
||||||
|
api_logger = logging.getLogger("app")
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -114,6 +117,12 @@ Return ONLY the JSON object."""
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost (Haiku: $0.80/M input, $4/M output)
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=playlist_fix")
|
||||||
|
|
||||||
response_text = message.content[0].text.strip()
|
response_text = message.content[0].text.strip()
|
||||||
# Handle potential markdown code blocks
|
# Handle potential markdown code blocks
|
||||||
if response_text.startswith("```"):
|
if response_text.startswith("```"):
|
||||||
|
|||||||
@@ -250,6 +250,12 @@ Return ONLY the JSON object."""
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost (Haiku: $0.80/M input, $4/M output)
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=artist_dive")
|
||||||
|
|
||||||
response_text = message.content[0].text.strip()
|
response_text = message.content[0].text.strip()
|
||||||
if response_text.startswith("```"):
|
if response_text.startswith("```"):
|
||||||
response_text = response_text.split("\n", 1)[1]
|
response_text = response_text.split("\n", 1)[1]
|
||||||
@@ -346,6 +352,12 @@ Return ONLY the JSON object."""
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost (Haiku: $0.80/M input, $4/M output)
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=generate_playlist")
|
||||||
|
|
||||||
response_text = message.content[0].text.strip()
|
response_text = message.content[0].text.strip()
|
||||||
if response_text.startswith("```"):
|
if response_text.startswith("```"):
|
||||||
response_text = response_text.split("\n", 1)[1]
|
response_text = response_text.split("\n", 1)[1]
|
||||||
@@ -464,6 +476,12 @@ Only recommend real songs. Return ONLY the JSON array."""
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost (Haiku: $0.80/M input, $4/M output)
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=crate")
|
||||||
|
|
||||||
response_text = message.content[0].text.strip()
|
response_text = message.content[0].text.strip()
|
||||||
if response_text.startswith("```"):
|
if response_text.startswith("```"):
|
||||||
response_text = response_text.split("\n", 1)[1]
|
response_text = response_text.split("\n", 1)[1]
|
||||||
@@ -598,6 +616,12 @@ Only use real songs. Return ONLY the JSON."""
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost (Haiku: $0.80/M input, $4/M output)
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=rabbit_hole")
|
||||||
|
|
||||||
response_text = message.content[0].text.strip()
|
response_text = message.content[0].text.strip()
|
||||||
if response_text.startswith("```"):
|
if response_text.startswith("```"):
|
||||||
response_text = response_text.split("\n", 1)[1]
|
response_text = response_text.split("\n", 1)[1]
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ Return ONLY a valid JSON object with "decades" and "insight" keys. No other text
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost (Haiku: $0.80/M input, $4/M output)
|
||||||
|
api_logger = logging.getLogger("app")
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=timeline")
|
||||||
|
|
||||||
response_text = message.content[0].text.strip()
|
response_text = message.content[0].text.strip()
|
||||||
|
|
||||||
# Try to extract JSON if wrapped in markdown code blocks
|
# Try to extract JSON if wrapped in markdown code blocks
|
||||||
|
|||||||
@@ -238,6 +238,15 @@ Return ONLY the JSON array, no other text."""
|
|||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track API cost
|
||||||
|
import logging
|
||||||
|
api_logger = logging.getLogger("app")
|
||||||
|
input_tokens = message.usage.input_tokens
|
||||||
|
output_tokens = message.usage.output_tokens
|
||||||
|
# Sonnet pricing: $3/M input, $15/M output
|
||||||
|
cost = (input_tokens * 3 / 1_000_000) + (output_tokens * 15 / 1_000_000)
|
||||||
|
api_logger.info(f"API_COST|model=claude-sonnet|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=generate_recommendations")
|
||||||
|
|
||||||
# Parse response
|
# Parse response
|
||||||
response_text = message.content[0].text.strip()
|
response_text = message.content[0].text.strip()
|
||||||
# Handle potential markdown code blocks
|
# Handle potential markdown code blocks
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Shield, Users, ListMusic, Sparkles, Heart, ThumbsDown, ScrollText, RefreshCw } from 'lucide-react'
|
import { Shield, Users, ListMusic, Sparkles, Heart, ThumbsDown, ScrollText, RefreshCw, DollarSign } from 'lucide-react'
|
||||||
import { useAuth } from '../lib/auth'
|
import { useAuth } from '../lib/auth'
|
||||||
import { getAdminStats, getAdminLogs, type AdminStats, type LogEntry } from '../lib/api'
|
import { getAdminStats, getAdminLogs, type AdminStats, type LogEntry } from '../lib/api'
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle row - 2 stat cards */}
|
{/* Middle row - 3 stat cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||||
<div className="bg-white rounded-2xl p-6 border border-purple-100">
|
<div className="bg-white rounded-2xl p-6 border border-purple-100">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||||
@@ -145,6 +145,20 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-4xl font-bold text-charcoal">{stats.recommendations.disliked}</p>
|
<p className="text-4xl font-bold text-charcoal">{stats.recommendations.disliked}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl p-6 border border-purple-100">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||||
|
<DollarSign className="w-5 h-5 text-purple" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-charcoal-muted">API Costs</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-4xl font-bold text-charcoal">${stats.api_costs.total_estimated.toFixed(4)}</p>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-charcoal-muted mt-2">
|
||||||
|
<span><span className="font-semibold text-charcoal">${stats.api_costs.today_estimated.toFixed(4)}</span> today</span>
|
||||||
|
<span><span className="font-semibold text-charcoal">{(stats.api_costs.total_input_tokens + stats.api_costs.total_output_tokens).toLocaleString()}</span> tokens</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User breakdown table */}
|
{/* User breakdown table */}
|
||||||
|
|||||||
Reference in New Issue
Block a user