Move podcast cost estimates to backend pricing catalog

This commit is contained in:
ي
2026-04-19 16:23:00 +05:30
parent bcf62017aa
commit e71cf65802
9 changed files with 256 additions and 111 deletions

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

View File

@@ -27,6 +27,7 @@ from ..models import (
PodcastEnhanceIdeaRequest, PodcastEnhanceIdeaRequest,
PodcastEnhanceIdeaResponse PodcastEnhanceIdeaResponse
) )
from ..cost_estimator import estimate_podcast_cost
# Check if running in podcast-only demo mode # Check if running in podcast-only demo mode
def _is_podcast_only_mode() -> bool: def _is_podcast_only_mode() -> bool:
@@ -372,6 +373,13 @@ Requirements:
listener_cta = data.get("listener_cta") or "" listener_cta = data.get("listener_cta") or ""
research_queries = data.get("research_queries") or [] research_queries = data.get("research_queries") or []
exa_suggested_config = data.get("exa_suggested_config") or None 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( return PodcastAnalyzeResponse(
audience=audience, audience=audience,
@@ -388,6 +396,7 @@ Requirements:
bible=bible_obj.model_dump() if bible_obj else None, bible=bible_obj.model_dump() if bible_obj else None,
avatar_url=final_avatar_url, avatar_url=final_avatar_url,
avatar_prompt=final_avatar_prompt, avatar_prompt=final_avatar_prompt,
estimate=estimate,
) )
@@ -492,4 +501,3 @@ Requirements:
except Exception as exc: except Exception as exc:
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}") logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}") raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")

View File

@@ -9,13 +9,16 @@ from typing import Dict, Any, List
from types import SimpleNamespace from types import SimpleNamespace
import json import json
import re import re
from sqlalchemy.orm import Session
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_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.blog_writer.research.exa_provider import ExaResearchProvider
from services.llm_providers.main_text_generation import llm_text_gen from services.llm_providers.main_text_generation import llm_text_gen
from services.podcast_bible_service import PodcastBibleService from services.podcast_bible_service import PodcastBibleService
from loguru import logger from loguru import logger
from ..cost_estimator import estimate_podcast_cost
from ..models import ( from ..models import (
PodcastExaResearchRequest, PodcastExaResearchRequest,
PodcastExaResearchResponse, PodcastExaResearchResponse,
@@ -32,6 +35,7 @@ router = APIRouter()
async def podcast_research_exa( async def podcast_research_exa(
request: PodcastExaResearchRequest, request: PodcastExaResearchRequest,
current_user: Dict[str, Any] = Depends(get_current_user), 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. 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"), "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( return PodcastExaResearchResponse(
sources=sources_payload, sources=sources_payload,
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries, 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, search_type=result.get("search_type") if isinstance(result, dict) else None,
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa", provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
content=raw_content, content=raw_content,
estimate=estimate,
) )

View File

@@ -73,6 +73,7 @@ class PodcastAnalyzeResponse(BaseModel):
bible: Optional[Dict[str, Any]] = None bible: Optional[Dict[str, Any]] = None
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
avatar_prompt: Optional[str] = None avatar_prompt: Optional[str] = None
estimate: Optional[Dict[str, Any]] = None
class PodcastEnhanceIdeaRequest(BaseModel): class PodcastEnhanceIdeaRequest(BaseModel):
@@ -193,6 +194,7 @@ class PodcastExaResearchResponse(BaseModel):
mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode mapped_angles: List[Dict[str, Any]] = [] # Content angles for the episode
expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research expert_quotes: List[Dict[str, Any]] = [] # Expert quotes from research
listener_cta_suggestions: List[str] = [] # CTA suggestions listener_cta_suggestions: List[str] = [] # CTA suggestions
estimate: Optional[Dict[str, Any]] = None
class PodcastScriptResponse(BaseModel): class PodcastScriptResponse(BaseModel):
@@ -450,4 +452,3 @@ class VoiceCloneResult(BaseModel):
file_size: int file_size: int
task_id: str task_id: str
status: str = "completed" status: str = "completed"

View File

@@ -253,26 +253,6 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl); setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
}, [topicInput, isUrl]); }, [topicInput, isUrl]);
// Calculate estimated cost
const estimatedCost = useMemo(() => {
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
const secs = duration * 60;
const ttsCost = (chars / 1000) * 0.05;
const avatarCost = speakers * 0.15;
const videoRate = knobs.bitrate === 'hd' ? 0.06 : 0.03;
const videoCost = secs * videoRate;
const researchCost = 0.3; // Fixed research cost
return {
ttsCost: +ttsCost.toFixed(2),
avatarCost: +avatarCost.toFixed(2),
videoCost: +videoCost.toFixed(2),
researchCost: +researchCost.toFixed(2),
total: +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2),
};
}, [duration, speakers, knobs.bitrate, knobs.scene_length_target]);
// Check if avatar is present (from any source: upload, brand avatar, or generated) // Check if avatar is present (from any source: upload, brand avatar, or generated)
const hasAvatar = Boolean( const hasAvatar = Boolean(
avatarFile || // User uploaded an image avatarFile || // User uploaded an image
@@ -560,7 +540,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
placeholderIndex={placeholderIndex} placeholderIndex={placeholderIndex}
loading={enhancingTopic} loading={enhancingTopic}
loadingMessage={enhanceTopicMessage} loadingMessage={enhanceTopicMessage}
estimatedCost={estimatedCost} estimatedCost={null}
duration={duration} duration={duration}
speakers={speakers} speakers={speakers}
knobs={knobs} knobs={knobs}

View File

@@ -27,7 +27,7 @@ interface TopicUrlInputProps {
videoCost: number; videoCost: number;
researchCost: number; researchCost: number;
total: number; total: number;
}; } | null;
duration?: number; duration?: number;
speakers?: number; speakers?: number;
knobs?: Knobs; knobs?: Knobs;
@@ -115,9 +115,9 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
</Typography> </Typography>
</Stack> </Stack>
{estimatedCost && ( <Tooltip
<Tooltip title={
title={ estimatedCost ? (
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}> <Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Estimated Cost Breakdown: Estimated Cost Breakdown:
@@ -135,43 +135,49 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
</Typography> </Typography>
</Typography> </Typography>
</Box> </Box>
} ) : (
arrow "Estimate unavailable until returned by the server."
placement="top" )
componentsProps={{ }
tooltip: { arrow
sx: { placement="top"
bgcolor: "#0f172a", componentsProps={{
color: "#ffffff", tooltip: {
maxWidth: 280, sx: {
fontSize: "0.875rem", bgcolor: "#0f172a",
p: 1.5, color: "#ffffff",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)", maxWidth: 280,
}, fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
}, },
arrow: { },
sx: { arrow: {
color: "#0f172a", sx: {
}, color: "#0f172a",
}, },
},
}}
>
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={estimatedCost ? `Est. $${estimatedCost.total}` : "Est. Unavailable"}
size="small"
sx={{
background: estimatedCost
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
: "rgba(100, 116, 139, 0.12)",
color: estimatedCost ? "#059669" : "#475569",
fontWeight: 600,
border: estimatedCost
? "1px solid rgba(16, 185, 129, 0.2)"
: "1px solid rgba(100, 116, 139, 0.25)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}} }}
> />
<Chip </Tooltip>
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`Est. $${estimatedCost.total}`}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
)}
</Stack> </Stack>
<Tooltip <Tooltip
title={ title={

View File

@@ -354,7 +354,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
try { try {
console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider }); console.log('[Research] Starting research with:', { topic: project.idea, approvedQueries, provider: researchProvider });
console.log('[Research] Calling podcastApi.runResearch...'); console.log('[Research] Calling podcastApi.runResearch...');
const { research: mapped, raw } = await podcastApi.runResearch({ const { research: mapped, raw, estimate } = await podcastApi.runResearch({
projectId: project.id, projectId: project.id,
topic: project.idea, topic: project.idea,
approvedQueries, approvedQueries,
@@ -369,6 +369,9 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
console.log('[Research] Response received:', { mapped, raw }); console.log('[Research] Response received:', { mapped, raw });
setResearch(mapped); setResearch(mapped);
setRawResearch(raw); setRawResearch(raw);
if (estimate) {
setEstimate(estimate);
}
setAnnouncement("Research complete — review fact cards below"); setAnnouncement("Research complete — review fact cards below");
} catch (researchError) { } catch (researchError) {
const errorMessage = researchError instanceof Error const errorMessage = researchError instanceof Error
@@ -392,7 +395,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally { } finally {
setIsResearching(false); setIsResearching(false);
} }
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]); }, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
// Add a ref to track if we're currently generating to prevent double calls // Add a ref to track if we're currently generating to prevent double calls
const isGeneratingRef = useRef(false); const isGeneratingRef = useRef(false);
@@ -625,4 +628,3 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
handleDeleteQuery, handleDeleteQuery,
}; };
}; };

View File

@@ -186,7 +186,7 @@ export type CreateProjectPayload = {
export type CreateProjectResult = { export type CreateProjectResult = {
projectId: string; projectId: string;
analysis: PodcastAnalysis; analysis: PodcastAnalysis;
estimate: PodcastEstimate; estimate: PodcastEstimate | null;
queries: Query[]; queries: Query[];
bible?: PodcastBible; bible?: PodcastBible;
avatar_url?: string | null; avatar_url?: string | null;
@@ -222,4 +222,3 @@ export type TaskStatus = {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
}; };

View File

@@ -59,39 +59,41 @@ const deriveSegments = (option?: OptionLike): string[] => {
return segments.slice(0, 5); return segments.slice(0, 5);
}; };
const estimateCosts = ({ const toPodcastEstimate = (raw: any, voiceId?: string): PodcastEstimate | null => {
minutes, if (!raw || typeof raw !== "object") return null;
scenes, const numeric = ["ttsCost", "avatarCost", "videoCost", "researchCost", "total"] as const;
chars, if (numeric.some((key) => typeof raw[key] !== "number" || Number.isNaN(raw[key]))) {
quality, return null;
avatars, }
queryCount = 3, const isCustomVoice = Boolean(
voiceId, voiceId &&
}: { ![
minutes: number; "Wise_Woman",
scenes: number; "Friendly_Person",
chars: number; "Inspirational_girl",
quality: string; "Deep_Voice_Man",
avatars: number; "Calm_Woman",
queryCount?: number; "Casual_Guy",
voiceId?: string; "Lively_Girl",
}): PodcastEstimate => { "Patient_Man",
const secs = Math.max(60, minutes * 60); "Young_Knight",
const ttsCost = (chars / 1000) * 0.05; "Determined_Man",
const avatarCost = avatars * 0.15; "Lovely_Girl",
const videoRate = quality === "hd" ? 0.06 : 0.03; "Decent_Boy",
const videoCost = secs * videoRate; "Imposing_Manner",
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2); "Elegant_Man",
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2); "Abbess",
const isCustomVoice = Boolean(voiceId && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(voiceId)); "Sweet_Girl_2",
const voiceName = isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")); "Exuberant_Girl",
].includes(voiceId)
);
return { return {
ttsCost: +ttsCost.toFixed(2), ttsCost: raw.ttsCost,
avatarCost: +avatarCost.toFixed(2), avatarCost: raw.avatarCost,
videoCost: +videoCost.toFixed(2), videoCost: raw.videoCost,
researchCost, researchCost: raw.researchCost,
total, total: raw.total,
voiceName, voiceName: isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")),
isCustomVoice, isCustomVoice,
}; };
}; };
@@ -174,6 +176,7 @@ type ExaResearchResult = {
sources: ExaSource[]; sources: ExaSource[];
search_queries?: string[]; search_queries?: string[];
cost?: { total?: number }; cost?: { total?: number };
estimate?: PodcastEstimate | null;
search_type?: string; search_type?: string;
provider?: string; provider?: string;
content?: string; content?: string;
@@ -290,15 +293,7 @@ export const podcastApi = {
// so users can manually choose which queries to run // so users can manually choose which queries to run
const projectId = createId("podcast"); const projectId = createId("podcast");
const estimate = estimateCosts({ const estimate = toPodcastEstimate(analysisResp.data?.estimate, payload.knobs.voice_id);
minutes: payload.duration,
scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)),
chars: Math.max(1000, payload.duration * 900),
quality: payload.knobs.bitrate || "standard",
avatars: payload.speakers,
queryCount: queries.length || 3,
voiceId: payload.knobs.voice_id,
});
return { return {
projectId, projectId,
@@ -325,7 +320,7 @@ export const podcastApi = {
bible?: any; bible?: any;
analysis?: PodcastAnalysis | null; analysis?: PodcastAnalysis | null;
onProgress?: (message: string) => void; onProgress?: (message: string) => void;
}): Promise<{ research: Research; raw: any }> { }): Promise<{ research: Research; raw: any; estimate?: PodcastEstimate | null }> {
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean); const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
if (!keywords.length) { if (!keywords.length) {
throw new Error("At least one query must be approved for research."); throw new Error("At least one query must be approved for research.");
@@ -372,7 +367,11 @@ export const podcastApi = {
params.onProgress("Deep research completed with Exa."); params.onProgress("Deep research completed with Exa.");
} }
const mapped = mapExaResearchResponse(exaResult); const mapped = mapExaResearchResponse(exaResult);
return { research: mapped, raw: exaResult }; return {
research: mapped,
raw: exaResult,
estimate: toPodcastEstimate(exaResult.estimate, params.analysis?.suggestedKnobs?.voice_id),
};
}, },
async generateScript(params: { async generateScript(params: {
@@ -953,4 +952,3 @@ export const podcastApi = {
}; };
export type PodcastApi = typeof podcastApi; export type PodcastApi = typeof podcastApi;