From f2b8dadbf82b95969f9ca0634ac84d6675d75c69 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 20:51:51 -0500 Subject: [PATCH] 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. --- backend/app/api/endpoints/admin.py | 29 ++++++++++++++++++++ backend/app/api/endpoints/compatibility.py | 9 ++++++ backend/app/api/endpoints/playlist_fix.py | 9 ++++++ backend/app/api/endpoints/recommendations.py | 24 ++++++++++++++++ backend/app/api/endpoints/timeline.py | 7 +++++ backend/app/services/recommender.py | 9 ++++++ frontend/src/pages/Admin.tsx | 20 ++++++++++++-- 7 files changed, 104 insertions(+), 3 deletions(-) diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index 1c1434b..31eee6a 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -1,5 +1,6 @@ import logging import io +import re from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, HTTPException, Query @@ -113,6 +114,28 @@ async def get_stats( 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 { "users": { "total": total_users, @@ -131,6 +154,12 @@ async def get_stats( "saved": saved_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, } diff --git a/backend/app/api/endpoints/compatibility.py b/backend/app/api/endpoints/compatibility.py index 9283956..899e382 100644 --- a/backend/app/api/endpoints/compatibility.py +++ b/backend/app/api/endpoints/compatibility.py @@ -1,6 +1,9 @@ import json +import logging import anthropic + +api_logger = logging.getLogger("app") from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select @@ -144,6 +147,12 @@ Return ONLY the JSON. Include exactly 5 recommendations.""" 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: text = message.content[0].text.strip() if text.startswith("```"): diff --git a/backend/app/api/endpoints/playlist_fix.py b/backend/app/api/endpoints/playlist_fix.py index 1771adb..fd774dd 100644 --- a/backend/app/api/endpoints/playlist_fix.py +++ b/backend/app/api/endpoints/playlist_fix.py @@ -1,6 +1,9 @@ import json +import logging import anthropic + +api_logger = logging.getLogger("app") from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select @@ -114,6 +117,12 @@ Return ONLY the JSON object.""" 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() # Handle potential markdown code blocks if response_text.startswith("```"): diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 6a5500e..5f3f8c7 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -250,6 +250,12 @@ Return ONLY the JSON object.""" 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() if response_text.startswith("```"): response_text = response_text.split("\n", 1)[1] @@ -346,6 +352,12 @@ Return ONLY the JSON object.""" 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() if response_text.startswith("```"): 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}], ) + # 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() if response_text.startswith("```"): 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}], ) + # 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() if response_text.startswith("```"): response_text = response_text.split("\n", 1)[1] diff --git a/backend/app/api/endpoints/timeline.py b/backend/app/api/endpoints/timeline.py index 50cc0da..d60ad0c 100644 --- a/backend/app/api/endpoints/timeline.py +++ b/backend/app/api/endpoints/timeline.py @@ -106,6 +106,13 @@ Return ONLY a valid JSON object with "decades" and "insight" keys. No other text 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() # Try to extract JSON if wrapped in markdown code blocks diff --git a/backend/app/services/recommender.py b/backend/app/services/recommender.py index 4af5a8b..6e26945 100644 --- a/backend/app/services/recommender.py +++ b/backend/app/services/recommender.py @@ -238,6 +238,15 @@ Return ONLY the JSON array, no other text.""" 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 response_text = message.content[0].text.strip() # Handle potential markdown code blocks diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 4b552eb..e10715a 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,5 +1,5 @@ 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 { getAdminStats, getAdminLogs, type AdminStats, type LogEntry } from '../lib/api' @@ -124,8 +124,8 @@ export default function Admin() { - {/* Middle row - 2 stat cards */} -
+ {/* Middle row - 3 stat cards */} +
@@ -145,6 +145,20 @@ export default function Admin() {

{stats.recommendations.disliked}

+ +
+
+
+ +
+

API Costs

+
+

${stats.api_costs.total_estimated.toFixed(4)}

+
+ ${stats.api_costs.today_estimated.toFixed(4)} today + {(stats.api_costs.total_input_tokens + stats.api_costs.total_output_tokens).toLocaleString()} tokens +
+
{/* User breakdown table */}