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.
142 lines
4.2 KiB
Python
142 lines
4.2 KiB
Python
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
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user
|
|
from app.models.playlist import Playlist
|
|
from app.models.track import Track
|
|
from app.models.user import User
|
|
from app.services.recommender import build_taste_profile
|
|
|
|
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
|
|
|
|
|
class PlaylistFixRequest(BaseModel):
|
|
count: int = 5
|
|
|
|
|
|
class OutlierTrack(BaseModel):
|
|
track_number: int
|
|
artist: str
|
|
title: str
|
|
reason: str
|
|
|
|
|
|
class ReplacementTrack(BaseModel):
|
|
title: str
|
|
artist: str
|
|
album: str | None = None
|
|
reason: str
|
|
|
|
|
|
class PlaylistFixResponse(BaseModel):
|
|
playlist_vibe: str
|
|
outliers: list[OutlierTrack]
|
|
replacements: list[ReplacementTrack]
|
|
|
|
|
|
@router.post("/{playlist_id}/fix", response_model=PlaylistFixResponse)
|
|
async def fix_playlist(
|
|
playlist_id: int,
|
|
data: PlaylistFixRequest = PlaylistFixRequest(),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
# Load playlist
|
|
result = await db.execute(
|
|
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
|
|
)
|
|
playlist = result.scalar_one_or_none()
|
|
if not playlist:
|
|
raise HTTPException(status_code=404, detail="Playlist not found")
|
|
|
|
# Load tracks
|
|
result = await db.execute(
|
|
select(Track).where(Track.playlist_id == playlist.id)
|
|
)
|
|
tracks = list(result.scalars().all())
|
|
if not tracks:
|
|
raise HTTPException(status_code=400, detail="Playlist has no tracks")
|
|
|
|
# Build taste profile
|
|
taste_profile = build_taste_profile(tracks)
|
|
|
|
# Build numbered track list
|
|
track_list = "\n".join(
|
|
f"{i + 1}. {t.artist} - {t.title}" for i, t in enumerate(tracks)
|
|
)
|
|
|
|
count = min(max(data.count, 1), 10)
|
|
|
|
prompt = f"""You are Vynl, a music playlist curator. Analyze this playlist and identify tracks that don't fit the overall vibe.
|
|
|
|
Playlist: {playlist.name}
|
|
Taste profile: {json.dumps(taste_profile, indent=2)}
|
|
|
|
Tracks:
|
|
{track_list}
|
|
|
|
Analyze the playlist and respond with a JSON object:
|
|
{{
|
|
"playlist_vibe": "A 1-2 sentence description of the overall playlist vibe/mood",
|
|
"outliers": [
|
|
{{
|
|
"track_number": 1,
|
|
"artist": "...",
|
|
"title": "...",
|
|
"reason": "Why this track doesn't fit the playlist vibe"
|
|
}}
|
|
],
|
|
"replacements": [
|
|
{{
|
|
"title": "...",
|
|
"artist": "...",
|
|
"album": "...",
|
|
"reason": "Why this fits better"
|
|
}}
|
|
]
|
|
}}
|
|
|
|
Identify up to {count} outlier tracks. For each outlier, suggest a replacement that fits the playlist vibe better. Focus on maintaining sonic cohesion — same energy, tempo range, and mood.
|
|
Return ONLY the JSON object."""
|
|
|
|
# Call Claude API
|
|
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
|
message = client.messages.create(
|
|
model="claude-haiku-4-5-20251001",
|
|
max_tokens=2000,
|
|
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("```"):
|
|
response_text = response_text.split("\n", 1)[1]
|
|
response_text = response_text.rsplit("```", 1)[0]
|
|
|
|
try:
|
|
fix_data = json.loads(response_text)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=500, detail="Failed to parse AI response")
|
|
|
|
return PlaylistFixResponse(
|
|
playlist_vibe=fix_data.get("playlist_vibe", ""),
|
|
outliers=[OutlierTrack(**o) for o in fix_data.get("outliers", [])],
|
|
replacements=[ReplacementTrack(**r) for r in fix_data.get("replacements", [])],
|
|
)
|