Move podcast cost estimates to backend pricing catalog
This commit is contained in:
133
backend/api/podcast/cost_estimator.py
Normal file
133
backend/api/podcast/cost_estimator.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Podcast cost estimation helpers.
|
||||
|
||||
Builds user-facing podcast estimates from the subscription pricing catalog
|
||||
instead of hard-coded frontend heuristics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.subscription_models import APIProvider
|
||||
from services.subscription.pricing_service import PricingService
|
||||
|
||||
|
||||
def _round_money(value: float) -> float:
|
||||
return round(float(value), 4)
|
||||
|
||||
|
||||
def _load_pricing(
|
||||
pricing_service: PricingService,
|
||||
provider: APIProvider,
|
||||
preferred_model: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
pricing = pricing_service.get_pricing_for_provider_model(provider, preferred_model)
|
||||
if pricing:
|
||||
return pricing
|
||||
# Fallback to provider default model row (if configured).
|
||||
return pricing_service.get_pricing_for_provider_model(provider, "default")
|
||||
|
||||
|
||||
def estimate_podcast_cost(
|
||||
*,
|
||||
db: Session,
|
||||
duration_minutes: int,
|
||||
speakers: int,
|
||||
query_count: int,
|
||||
include_avatar_phase: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Compute a backend estimate for podcast creation.
|
||||
|
||||
Returns None when pricing rows are unavailable so UI can display "Unavailable".
|
||||
"""
|
||||
pricing_service = PricingService(db)
|
||||
|
||||
gemini_pricing = _load_pricing(pricing_service, APIProvider.GEMINI, "gemini-2.5-flash")
|
||||
exa_pricing = _load_pricing(pricing_service, APIProvider.EXA, "exa-search")
|
||||
audio_pricing = _load_pricing(pricing_service, APIProvider.AUDIO, "minimax/speech-02-hd")
|
||||
video_pricing = _load_pricing(pricing_service, APIProvider.VIDEO, "default")
|
||||
image_pricing = _load_pricing(pricing_service, APIProvider.STABILITY, "qwen-image")
|
||||
|
||||
if not gemini_pricing:
|
||||
return None
|
||||
|
||||
minutes = max(1, int(duration_minutes or 1))
|
||||
speaker_count = max(1, int(speakers or 1))
|
||||
research_queries = max(1, int(query_count or 1))
|
||||
|
||||
# Phase-level usage assumptions (token/request proxies for pre-creation estimate).
|
||||
analysis_input_tokens = 1800
|
||||
analysis_output_tokens = 1000
|
||||
research_synthesis_input_tokens = 2200
|
||||
research_synthesis_output_tokens = 900
|
||||
script_input_tokens = max(1800, minutes * 300)
|
||||
script_output_tokens = max(2200, minutes * 700)
|
||||
|
||||
# TTS token proxy: ~900 chars per minute per speaker.
|
||||
estimated_tts_tokens = max(900, minutes * 900 * speaker_count)
|
||||
|
||||
analysis_cost = (
|
||||
analysis_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0)
|
||||
+ analysis_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0)
|
||||
+ float(gemini_pricing.get("cost_per_request") or 0.0)
|
||||
)
|
||||
research_llm_cost = (
|
||||
research_synthesis_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0)
|
||||
+ research_synthesis_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0)
|
||||
+ float(gemini_pricing.get("cost_per_request") or 0.0)
|
||||
)
|
||||
script_cost = (
|
||||
script_input_tokens * float(gemini_pricing.get("cost_per_input_token") or 0.0)
|
||||
+ script_output_tokens * float(gemini_pricing.get("cost_per_output_token") or 0.0)
|
||||
+ float(gemini_pricing.get("cost_per_request") or 0.0)
|
||||
)
|
||||
|
||||
research_search_cost = 0.0
|
||||
if exa_pricing:
|
||||
research_search_cost = research_queries * float(exa_pricing.get("cost_per_request") or 0.0)
|
||||
|
||||
tts_cost = 0.0
|
||||
if audio_pricing:
|
||||
tts_cost = (
|
||||
estimated_tts_tokens * float(audio_pricing.get("cost_per_input_token") or 0.0)
|
||||
+ float(audio_pricing.get("cost_per_request") or 0.0)
|
||||
)
|
||||
|
||||
# Assume one video render request per minute (upper-bound planning estimate).
|
||||
video_cost = 0.0
|
||||
if video_pricing:
|
||||
video_cost = minutes * float(video_pricing.get("cost_per_request") or 0.0)
|
||||
|
||||
avatar_cost = 0.0
|
||||
if include_avatar_phase and image_pricing:
|
||||
image_unit = float(image_pricing.get("cost_per_image") or image_pricing.get("cost_per_request") or 0.0)
|
||||
avatar_cost = speaker_count * image_unit
|
||||
|
||||
research_cost = research_search_cost + research_llm_cost
|
||||
total = analysis_cost + research_cost + script_cost + tts_cost + video_cost + avatar_cost
|
||||
|
||||
return {
|
||||
"ttsCost": _round_money(tts_cost),
|
||||
"avatarCost": _round_money(avatar_cost),
|
||||
"videoCost": _round_money(video_cost),
|
||||
"researchCost": _round_money(research_cost),
|
||||
"analysisCost": _round_money(analysis_cost),
|
||||
"scriptCost": _round_money(script_cost),
|
||||
"total": _round_money(total),
|
||||
"currency": "USD",
|
||||
"source": "pricing_catalog",
|
||||
"assumptions": {
|
||||
"analysis_input_tokens": analysis_input_tokens,
|
||||
"analysis_output_tokens": analysis_output_tokens,
|
||||
"research_synthesis_input_tokens": research_synthesis_input_tokens,
|
||||
"research_synthesis_output_tokens": research_synthesis_output_tokens,
|
||||
"script_input_tokens": script_input_tokens,
|
||||
"script_output_tokens": script_output_tokens,
|
||||
"estimated_tts_tokens": estimated_tts_tokens,
|
||||
"research_queries": research_queries,
|
||||
"video_requests": minutes,
|
||||
},
|
||||
}
|
||||
@@ -27,6 +27,7 @@ from ..models import (
|
||||
PodcastEnhanceIdeaRequest,
|
||||
PodcastEnhanceIdeaResponse
|
||||
)
|
||||
from ..cost_estimator import estimate_podcast_cost
|
||||
|
||||
# Check if running in podcast-only demo mode
|
||||
def _is_podcast_only_mode() -> bool:
|
||||
@@ -372,6 +373,13 @@ Requirements:
|
||||
listener_cta = data.get("listener_cta") or ""
|
||||
research_queries = data.get("research_queries") or []
|
||||
exa_suggested_config = data.get("exa_suggested_config") or None
|
||||
estimate = estimate_podcast_cost(
|
||||
db=db,
|
||||
duration_minutes=request.duration,
|
||||
speakers=request.speakers,
|
||||
query_count=len(research_queries) if isinstance(research_queries, list) else 0,
|
||||
include_avatar_phase=podcast_mode != "audio_only",
|
||||
)
|
||||
|
||||
return PodcastAnalyzeResponse(
|
||||
audience=audience,
|
||||
@@ -388,6 +396,7 @@ Requirements:
|
||||
bible=bible_obj.model_dump() if bible_obj else None,
|
||||
avatar_url=final_avatar_url,
|
||||
avatar_prompt=final_avatar_prompt,
|
||||
estimate=estimate,
|
||||
)
|
||||
|
||||
|
||||
@@ -492,4 +501,3 @@ Requirements:
|
||||
except Exception as exc:
|
||||
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ from typing import Dict, Any, List
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import re
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from api.story_writer.utils.auth import require_authenticated_user
|
||||
from services.database import get_db
|
||||
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.podcast_bible_service import PodcastBibleService
|
||||
from loguru import logger
|
||||
from ..cost_estimator import estimate_podcast_cost
|
||||
from ..models import (
|
||||
PodcastExaResearchRequest,
|
||||
PodcastExaResearchResponse,
|
||||
@@ -32,6 +35,7 @@ router = APIRouter()
|
||||
async def podcast_research_exa(
|
||||
request: PodcastExaResearchRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Run podcast research via Exa and then use LLM to extract deep insights.
|
||||
@@ -297,6 +301,20 @@ QUALITY STANDARDS:
|
||||
"credibility_score": src.get("credibility_score"),
|
||||
}))
|
||||
|
||||
duration_minutes = 10
|
||||
speakers = 1
|
||||
if request.analysis:
|
||||
duration_minutes = int(request.analysis.get("duration", 10) or 10)
|
||||
speakers = int(request.analysis.get("speakers", 1) or 1)
|
||||
|
||||
estimate = estimate_podcast_cost(
|
||||
db=db,
|
||||
duration_minutes=duration_minutes,
|
||||
speakers=speakers,
|
||||
query_count=len(queries),
|
||||
include_avatar_phase=True,
|
||||
)
|
||||
|
||||
return PodcastExaResearchResponse(
|
||||
sources=sources_payload,
|
||||
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,
|
||||
@@ -306,5 +324,5 @@ QUALITY STANDARDS:
|
||||
search_type=result.get("search_type") if isinstance(result, dict) else None,
|
||||
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
|
||||
content=raw_content,
|
||||
estimate=estimate,
|
||||
)
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class PodcastAnalyzeResponse(BaseModel):
|
||||
bible: Optional[Dict[str, Any]] = None
|
||||
avatar_url: Optional[str] = None
|
||||
avatar_prompt: Optional[str] = None
|
||||
estimate: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PodcastEnhanceIdeaRequest(BaseModel):
|
||||
@@ -193,6 +194,7 @@ class PodcastExaResearchResponse(BaseModel):
|
||||
mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode
|
||||
expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research
|
||||
listener_cta_suggestions: List[str] = [] # CTA suggestions
|
||||
estimate: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PodcastScriptResponse(BaseModel):
|
||||
@@ -450,4 +452,3 @@ class VoiceCloneResult(BaseModel):
|
||||
file_size: int
|
||||
task_id: str
|
||||
status: str = "completed"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user