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:
root
2026-03-31 20:51:51 -05:00
parent 0ee8f9a144
commit f2b8dadbf8
7 changed files with 104 additions and 3 deletions

View File

@@ -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,
} }

View File

@@ -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("```"):

View File

@@ -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("```"):

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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 */}