Fix voice clone NotSupportedError and improve subscription services

This commit is contained in:
ajaysi
2026-04-22 12:27:51 +05:30
parent 641143a7d6
commit cbd68fa43f
13 changed files with 221 additions and 72 deletions

View File

@@ -43,16 +43,9 @@ def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path:
safe_user_id = sanitize_user_id(user_id) safe_user_id = sanitize_user_id(user_id)
repo_root = get_repo_root() repo_root = get_repo_root()
logger.warning(f"[Assets] repo_root: {repo_root}")
logger.warning(f"[Assets] user_id: {user_id}, safe_user_id: {safe_user_id}")
file_path = (repo_root / "workspace" / f"workspace_{safe_user_id}" / "assets" / category / filename).resolve() file_path = (repo_root / "workspace" / f"workspace_{safe_user_id}" / "assets" / category / filename).resolve()
workspace_dir = (repo_root / "workspace" / f"workspace_{safe_user_id}").resolve() workspace_dir = (repo_root / "workspace" / f"workspace_{safe_user_id}").resolve()
logger.warning(f"[Assets] resolved path: {file_path}")
logger.warning(f"[Assets] workspace_dir: {workspace_dir}")
logger.warning(f"[Assets] path exists: {file_path.exists()}")
if not str(file_path).startswith(str(workspace_dir)): if not str(file_path).startswith(str(workspace_dir)):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")

View File

@@ -539,6 +539,7 @@ async def create_voice_clone(
# 3. Save Preview Audio (if generated) # 3. Save Preview Audio (if generated)
preview_url = None preview_url = None
preview_mime_type = "audio/wav" preview_mime_type = "audio/wav"
actual_filename = None # Default if preview save fails
if preview_audio_bytes: if preview_audio_bytes:
from utils.media_utils import detect_audio_format, ensure_audio_extension from utils.media_utils import detect_audio_format, ensure_audio_extension
@@ -579,14 +580,16 @@ async def create_voice_clone(
logger.warning(f"[VoiceClone] Failed to save preview audio: {error}") logger.warning(f"[VoiceClone] Failed to save preview audio: {error}")
# 4. Save to Asset Library # 4. Save to Asset Library
# Use the preview file (with corrected .wav extension) as the main asset file
stored_filename = actual_filename if preview_audio_bytes and saved_preview_path else filename
asset_id = save_asset_to_library( asset_id = save_asset_to_library(
db=db, db=db,
user_id=user_id, user_id=user_id,
file_path=file_path, file_path=file_path,
asset_type="audio", asset_type="audio",
source_module="voice_cloner", source_module="voice_cloner",
filename=filename, filename=stored_filename,
file_url=f"/api/assets/{user_id}/voice_samples/{filename}", file_url=f"/api/assets/{user_id}/voice_samples/{stored_filename}",
asset_metadata={ asset_metadata={
"voice_name": voice_name, "voice_name": voice_name,
"engine": engine, "engine": engine,
@@ -599,12 +602,12 @@ async def create_voice_clone(
) )
logger.warning(f"[VoiceClone] Response preview_url: {preview_url}") logger.warning(f"[VoiceClone] Response preview_url: {preview_url}")
logger.warning(f"[VoiceClone] Response filename: {filename}") logger.warning(f"[VoiceClone] Response stored_filename: {stored_filename}")
return { return {
"success": True, "success": True,
"custom_voice_id": custom_voice_id, "custom_voice_id": custom_voice_id,
"preview_audio_url": preview_url or f"/api/assets/{user_id}/voice_samples/{filename}", "preview_audio_url": preview_url or f"/api/assets/{user_id}/voice_samples/{stored_filename}",
"asset_id": asset_id, "asset_id": asset_id,
"message": "Voice clone created successfully" "message": "Voice clone created successfully"
} }

View File

@@ -80,6 +80,7 @@ class SubscriptionPlan(Base):
video_calls_limit = Column(Integer, default=0) # AI video generation video_calls_limit = Column(Integer, default=0) # AI video generation
image_edit_calls_limit = Column(Integer, default=0) # AI image editing image_edit_calls_limit = Column(Integer, default=0) # AI image editing
audio_calls_limit = Column(Integer, default=0) # AI audio generation (text-to-speech) audio_calls_limit = Column(Integer, default=0) # AI audio generation (text-to-speech)
wavespeed_calls_limit = Column(Integer, default=0) # WaveSpeed API calls (LLM + TTS + video + image)
# Token Limits (for LLM providers) # Token Limits (for LLM providers)
gemini_tokens_limit = Column(Integer, default=0) gemini_tokens_limit = Column(Integer, default=0)

View File

@@ -107,6 +107,20 @@ class LimitValidator:
} }
return result return result
# Helper: Check if a limit should be enforced based on tier
def should_enforce_limit(limit_value: int, tier: str) -> bool:
"""
Determine if a limit should be enforced.
- Free tier: 0 means DISABLED (not unlimited)
- Basic/Pro/Enterprise: 0 means UNLIMITED
"""
if tier == 'free':
# Free tier: 0 means disabled
return limit_value > 0
else:
# Basic/Pro/Enterprise: 0 means unlimited
return limit_value > 0
# Get user limits with error handling (STRICT: fail on errors) # Get user limits with error handling (STRICT: fail on errors)
# CRITICAL: Expire SQLAlchemy objects to ensure we get fresh plan data after renewal # CRITICAL: Expire SQLAlchemy objects to ensure we get fresh plan data after renewal
try: try:
@@ -144,6 +158,9 @@ class LimitValidator:
logger.warning(f"[Subscription Check] No subscription or free tier found for user {user_id}, denying access") logger.warning(f"[Subscription Check] No subscription or free tier found for user {user_id}, denying access")
return False, "No subscription plan found. Please subscribe to a plan.", {} return False, "No subscription plan found. Please subscribe to a plan.", {}
# Extract tier for limit enforcement logic
user_tier = limits.get('tier', 'free') if limits else 'free'
# Get current usage for this billing period with error handling # Get current usage for this billing period with error handling
# Use targeted expiry instead of expire_all() to avoid nuking the entire session cache # Use targeted expiry instead of expire_all() to avoid nuking the entire session cache
try: try:
@@ -245,8 +262,8 @@ class LimitValidator:
(usage.mistral_calls or 0) (usage.mistral_calls or 0)
) )
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise) # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if ai_text_gen_limit > 0 and current_total_llm_calls >= ai_text_gen_limit: if should_enforce_limit(ai_text_gen_limit, user_tier) and current_total_llm_calls >= ai_text_gen_limit:
logger.error(f"[Subscription Check] AI text generation call limit exceeded for user {user_id}: {current_total_llm_calls}/{ai_text_gen_limit} (provider: {display_provider_name})") logger.error(f"[Subscription Check] AI text generation call limit exceeded for user {user_id}: {current_total_llm_calls}/{ai_text_gen_limit} (provider: {display_provider_name})")
result = (False, f"AI text generation call limit reached. Used {current_total_llm_calls} of {ai_text_gen_limit} total AI text generation calls this billing period.", { result = (False, f"AI text generation call limit reached. Used {current_total_llm_calls} of {ai_text_gen_limit} total AI text generation calls this billing period.", {
'current_calls': current_total_llm_calls, 'current_calls': current_total_llm_calls,
@@ -278,8 +295,8 @@ class LimitValidator:
current_calls = getattr(usage, f"{provider_name}_calls", 0) or 0 current_calls = getattr(usage, f"{provider_name}_calls", 0) or 0
call_limit = limits['limits'].get(f"{provider_name}_calls", 0) or 0 call_limit = limits['limits'].get(f"{provider_name}_calls", 0) or 0
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise) # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if call_limit > 0 and current_calls >= call_limit: if should_enforce_limit(call_limit, user_tier) and current_calls >= call_limit:
logger.error(f"[Subscription Check] Call limit exceeded for user {user_id}, provider {display_provider_name}: {current_calls}/{call_limit}") logger.error(f"[Subscription Check] Call limit exceeded for user {user_id}, provider {display_provider_name}: {current_calls}/{call_limit}")
result = (False, f"API call limit reached for {display_provider_name}. Used {current_calls} of {call_limit} calls this billing period.", { result = (False, f"API call limit reached for {display_provider_name}. Used {current_calls} of {call_limit} calls this billing period.", {
'current_calls': current_calls, 'current_calls': current_calls,
@@ -296,7 +313,13 @@ class LimitValidator:
logger.debug(f"[Subscription Check] Call limit check passed for user {user_id}, provider {display_provider_name}: {current_calls}/{call_limit if call_limit > 0 else 'unlimited'}") logger.debug(f"[Subscription Check] Call limit check passed for user {user_id}, provider {display_provider_name}: {current_calls}/{call_limit if call_limit > 0 else 'unlimited'}")
except Exception as e: except Exception as e:
logger.error(f"Error checking call limits: {e}") logger.error(f"Error checking call limits: {e}")
# Continue to next check # Fail closed - deny if we can't verify the limit
result = (False, f"Unable to verify call limit: {str(e)}", {})
self.pricing_service._limits_cache[cache_key] = {
'result': result,
'expires_at': now + timedelta(seconds=30)
}
return result
# Check token limits for LLM providers with error handling # Check token limits for LLM providers with error handling
# NOTE: token_limit = 0 means UNLIMITED (Enterprise plans) # NOTE: token_limit = 0 means UNLIMITED (Enterprise plans)
@@ -305,8 +328,8 @@ class LimitValidator:
current_tokens = getattr(usage, f"{provider_name}_tokens", 0) or 0 current_tokens = getattr(usage, f"{provider_name}_tokens", 0) or 0
token_limit = limits['limits'].get(f"{provider_name}_tokens", 0) or 0 token_limit = limits['limits'].get(f"{provider_name}_tokens", 0) or 0
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise) # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if token_limit > 0 and (current_tokens + tokens_requested) > token_limit: if should_enforce_limit(token_limit, user_tier) and (current_tokens + tokens_requested) > token_limit:
result = (False, f"Token limit would be exceeded for {display_provider_name}. Current: {current_tokens}, Requested: {tokens_requested}, Limit: {token_limit}", { result = (False, f"Token limit would be exceeded for {display_provider_name}. Current: {current_tokens}, Requested: {tokens_requested}, Limit: {token_limit}", {
'current_tokens': current_tokens, 'current_tokens': current_tokens,
'requested_tokens': tokens_requested, 'requested_tokens': tokens_requested,
@@ -328,14 +351,19 @@ class LimitValidator:
return result return result
except Exception as e: except Exception as e:
logger.error(f"Error checking token limits: {e}") logger.error(f"Error checking token limits: {e}")
# Continue to next check # Fail closed - deny if we can't verify the limit
result = (False, f"Unable to verify token limit: {str(e)}", {})
self.pricing_service._limits_cache[cache_key] = {
'result': result,
'expires_at': now + timedelta(seconds=30)
}
return result
# Check cost limits with error handling # Check cost limits with error handling
# NOTE: cost_limit = 0 means UNLIMITED (Enterprise plans)
try: try:
cost_limit = limits['limits'].get('monthly_cost', 0) or 0 cost_limit = limits['limits'].get('monthly_cost', 0) or 0
# Only enforce limit if limit > 0 (0 means unlimited for Enterprise) # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if cost_limit > 0 and usage.total_cost >= cost_limit: if should_enforce_limit(cost_limit, user_tier) and usage.total_cost >= cost_limit:
result = (False, f"Monthly cost limit reached. Current cost: ${usage.total_cost:.2f}, Limit: ${cost_limit:.2f}", { result = (False, f"Monthly cost limit reached. Current cost: ${usage.total_cost:.2f}, Limit: ${cost_limit:.2f}", {
'current_cost': usage.total_cost, 'current_cost': usage.total_cost,
'limit': cost_limit, 'limit': cost_limit,
@@ -348,7 +376,13 @@ class LimitValidator:
return result return result
except Exception as e: except Exception as e:
logger.error(f"Error checking cost limits: {e}") logger.error(f"Error checking cost limits: {e}")
# Continue to success case # Fail closed - deny if we can't verify the limit
result = (False, f"Unable to verify cost limit: {str(e)}", {})
self.pricing_service._limits_cache[cache_key] = {
'result': result,
'expires_at': now + timedelta(seconds=30)
}
return result
# Calculate usage percentages for warnings # Calculate usage percentages for warnings
try: try:
@@ -503,6 +537,7 @@ class LimitValidator:
return False, "No subscription plan found. Please subscribe to a plan.", {} return False, "No subscription plan found. Please subscribe to a plan.", {}
limits = limits_dict.get('limits', {}) limits = limits_dict.get('limits', {})
tier = limits_dict.get('tier', 'free')
# Track cumulative usage across all operations # Track cumulative usage across all operations
total_llm_calls = ( total_llm_calls = (
@@ -547,7 +582,8 @@ class LimitValidator:
# Count this operation as an LLM call # Count this operation as an LLM call
projected_total_llm_calls = total_llm_calls + 1 projected_total_llm_calls = total_llm_calls + 1
if ai_text_gen_limit > 0 and projected_total_llm_calls > ai_text_gen_limit: # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if should_enforce_limit(ai_text_gen_limit, tier) and projected_total_llm_calls > ai_text_gen_limit:
error_info = { error_info = {
'current_calls': total_llm_calls, 'current_calls': total_llm_calls,
'limit': ai_text_gen_limit, 'limit': ai_text_gen_limit,
@@ -654,7 +690,8 @@ class LimitValidator:
token_limit = limits.get(provider_tokens_key, 0) or 0 token_limit = limits.get(provider_tokens_key, 0) or 0
if token_limit > 0 and tokens_requested > 0: # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if should_enforce_limit(token_limit, tier) and tokens_requested > 0:
projected_tokens = current_provider_tokens + tokens_requested projected_tokens = current_provider_tokens + tokens_requested
logger.info(f" └─ Token Check: {current_provider_tokens} (current) + {tokens_requested} (requested) = {projected_tokens} (total) / {token_limit} (limit)") logger.info(f" └─ Token Check: {current_provider_tokens} (current) + {tokens_requested} (requested) = {projected_tokens} (total) / {token_limit} (limit)")
@@ -716,7 +753,8 @@ class LimitValidator:
image_limit = limits.get('stability_calls', 0) or 0 image_limit = limits.get('stability_calls', 0) or 0
projected_images = total_images + 1 projected_images = total_images + 1
if image_limit > 0 and projected_images > image_limit: # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if should_enforce_limit(image_limit, tier) and projected_images > image_limit:
error_info = { error_info = {
'current_images': total_images, 'current_images': total_images,
'limit': image_limit, 'limit': image_limit,
@@ -737,7 +775,8 @@ class LimitValidator:
total_video_calls = usage.video_calls or 0 total_video_calls = usage.video_calls or 0
projected_video_calls = total_video_calls + 1 projected_video_calls = total_video_calls + 1
if video_limit > 0 and projected_video_calls > video_limit: # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if should_enforce_limit(video_limit, tier) and projected_video_calls > video_limit:
error_info = { error_info = {
'current_calls': total_video_calls, 'current_calls': total_video_calls,
'limit': video_limit, 'limit': video_limit,
@@ -756,7 +795,8 @@ class LimitValidator:
total_image_edit_calls = getattr(usage, 'image_edit_calls', 0) or 0 total_image_edit_calls = getattr(usage, 'image_edit_calls', 0) or 0
projected_image_edit_calls = total_image_edit_calls + 1 projected_image_edit_calls = total_image_edit_calls + 1
if image_edit_limit > 0 and projected_image_edit_calls > image_edit_limit: # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited)
if should_enforce_limit(image_edit_limit, tier) and projected_image_edit_calls > image_edit_limit:
error_info = { error_info = {
'current_calls': total_image_edit_calls, 'current_calls': total_image_edit_calls,
'limit': image_edit_limit, 'limit': image_edit_limit,
@@ -789,6 +829,25 @@ class LimitValidator:
'error_type': 'call_limit', 'error_type': 'call_limit',
'usage_info': error_info 'usage_info': error_info
} }
# Check WaveSpeed combined limit if actual_provider is WaveSpeed
if actual_provider_name == 'wavespeed':
wavespeed_limit = limits.get('wavespeed_calls', 0) or 0
if should_enforce_limit(wavespeed_limit, tier):
wavespeed_usage = usage.wavespeed_calls or 0
projected_wavespeed = wavespeed_usage + 1
if projected_wavespeed > wavespeed_limit:
error_info = {
'current_calls': wavespeed_usage,
'limit': wavespeed_limit,
'provider': 'wavespeed',
'operation_type': operation_type,
'operation_index': op_idx
}
return False, f"WaveSpeed API limit would be exceeded. Would use {projected_wavespeed} of {wavespeed_limit} WaveSpeed calls this billing period.", {
'error_type': 'wavespeed_limit',
'usage_info': error_info
}
# All checks passed # All checks passed
logger.info(f"[Pre-flight Check] ✅ All {len(operations)} operation(s) validated successfully") logger.info(f"[Pre-flight Check] ✅ All {len(operations)} operation(s) validated successfully")

View File

@@ -505,21 +505,26 @@ class PricingService:
"tier": SubscriptionTier.FREE, "tier": SubscriptionTier.FREE,
"price_monthly": 0.0, "price_monthly": 0.0,
"price_yearly": 0.0, "price_yearly": 0.0,
"gemini_calls_limit": 100, "ai_text_generation_calls_limit": 50, # Explicit: Free gets 50 AI text calls (via Gemini fallback)
"openai_calls_limit": 0, "gemini_calls_limit": 50,
"anthropic_calls_limit": 0, "openai_calls_limit": 0, # DISABLED: OpenAI access not included in Free tier
"mistral_calls_limit": 50, "anthropic_calls_limit": 0, # DISABLED: Anthropic access not included in Free tier
"tavily_calls_limit": 20, "mistral_calls_limit": 0, # DISABLED: HuggingFace not in Free tier
"serper_calls_limit": 20, "tavily_calls_limit": 10,
"metaphor_calls_limit": 10, "serper_calls_limit": 10,
"firecrawl_calls_limit": 10, "metaphor_calls_limit": 0, # DISABLED: Metaphor not in Free tier
"stability_calls_limit": 5, "firecrawl_calls_limit": 0, # DISABLED: Firecrawl not in Free tier
"exa_calls_limit": 100, "stability_calls_limit": 3, # 3 images - enough to try the product
"video_calls_limit": 0, # No video generation for free tier "exa_calls_limit": 10, # 10 research queries - enough to try the product
"image_edit_calls_limit": 10, # 10 AI image editing calls/month "video_calls_limit": 0, # DISABLED: Video generation not in Free tier
"audio_calls_limit": 20, # 20 AI audio generation calls/month "image_edit_calls_limit": 5, # 5 image edits - enough to try the product
"gemini_tokens_limit": 100000, "audio_calls_limit": 5, # 5 audio clips - enough to try the product
"monthly_cost_limit": 0.0, "wavespeed_calls_limit": 0, # DISABLED: WaveSpeed not included in Free tier
"gemini_tokens_limit": 50000,
"openai_tokens_limit": 0, # DISABLED
"anthropic_tokens_limit": 0, # DISABLED
"mistral_tokens_limit": 0, # DISABLED
"monthly_cost_limit": 2.0, # $2 cap - prevents runaway costs on free tier
"features": ["basic_content_generation", "limited_research"], "features": ["basic_content_generation", "limited_research"],
"description": "Perfect for trying out ALwrity" "description": "Perfect for trying out ALwrity"
}, },
@@ -528,7 +533,7 @@ class PricingService:
"tier": SubscriptionTier.BASIC, "tier": SubscriptionTier.BASIC,
"price_monthly": 29.0, "price_monthly": 29.0,
"price_yearly": 290.0, "price_yearly": 290.0,
"ai_text_generation_calls_limit": 50, # INCREASED: Unified limit for all LLM providers (OSS-focused strategy) "ai_text_generation_calls_limit": 500, # Unified limit for all LLM providers
"gemini_calls_limit": 1000, # Legacy, kept for backwards compatibility (not used for enforcement) "gemini_calls_limit": 1000, # Legacy, kept for backwards compatibility (not used for enforcement)
"openai_calls_limit": 500, "openai_calls_limit": 500,
"anthropic_calls_limit": 200, "anthropic_calls_limit": 200,
@@ -537,16 +542,17 @@ class PricingService:
"serper_calls_limit": 200, "serper_calls_limit": 200,
"metaphor_calls_limit": 100, "metaphor_calls_limit": 100,
"firecrawl_calls_limit": 100, "firecrawl_calls_limit": 100,
"stability_calls_limit": 50, # INCREASED: Now includes WaveSpeed OSS models (Qwen Image $0.03) "stability_calls_limit": 25, # 25 images - good for podcast episode covers
"exa_calls_limit": 500, "exa_calls_limit": 100, # 100 research queries
"video_calls_limit": 30, # INCREASED: 30 videos/month (WAN 2.5 OSS $0.25) "video_calls_limit": 10, # 10 videos - enough for a few podcast episodes
"image_edit_calls_limit": 50, # INCREASED: 50 AI image editing calls/month (Qwen Edit OSS $0.02) "image_edit_calls_limit": 25, # 25 AI image edits
"audio_calls_limit": 100, # INCREASED: 100 AI audio generation calls/month (Minimax Speech OSS) "audio_calls_limit": 100, # INCREASED: 100 AI audio generation calls/month (Minimax Speech OSS)
"wavespeed_calls_limit": 200, # WaveSpeed combined limit: TTS + video + image + LLM (Minimax Speech $0.002/min, Qwen $0.03/img, Kling $0.25/5s)
"gemini_tokens_limit": 100000, # INCREASED: 100K tokens per provider (OSS-focused strategy) "gemini_tokens_limit": 100000, # INCREASED: 100K tokens per provider (OSS-focused strategy)
"openai_tokens_limit": 100000, # INCREASED: 100K tokens per provider "openai_tokens_limit": 100000, # INCREASED: 100K tokens per provider
"anthropic_tokens_limit": 100000, # INCREASED: 100K tokens per provider "anthropic_tokens_limit": 100000, # INCREASED: 100K tokens per provider
"mistral_tokens_limit": 100000, # INCREASED: 100K tokens per provider "mistral_tokens_limit": 100000, # INCREASED: 100K tokens per provider
"monthly_cost_limit": 45.0, # ADJUSTED: $45 cap (aligns with $40-50 hard limit target) "monthly_cost_limit": 25.0, # $25 cap - podcast-focused pricing
"features": ["full_content_generation", "advanced_research", "basic_analytics", "all_tools_access", "oss_models_priority"], "features": ["full_content_generation", "advanced_research", "basic_analytics", "all_tools_access", "oss_models_priority"],
"description": "Perfect for individuals and small teams. Access all ALwrity features with generous limits powered by OSS AI models." "description": "Perfect for individuals and small teams. Access all ALwrity features with generous limits powered by OSS AI models."
}, },
@@ -555,6 +561,7 @@ class PricingService:
"tier": SubscriptionTier.PRO, "tier": SubscriptionTier.PRO,
"price_monthly": 79.0, "price_monthly": 79.0,
"price_yearly": 790.0, "price_yearly": 790.0,
"ai_text_generation_calls_limit": 3000, # Explicit: Pro gets 3000 AI text calls
"gemini_calls_limit": 5000, "gemini_calls_limit": 5000,
"openai_calls_limit": 2500, "openai_calls_limit": 2500,
"anthropic_calls_limit": 1000, "anthropic_calls_limit": 1000,
@@ -563,16 +570,17 @@ class PricingService:
"serper_calls_limit": 1000, "serper_calls_limit": 1000,
"metaphor_calls_limit": 500, "metaphor_calls_limit": 500,
"firecrawl_calls_limit": 500, "firecrawl_calls_limit": 500,
"stability_calls_limit": 200, "stability_calls_limit": 100, # 100 images - good for regular podcasts
"exa_calls_limit": 2000, "exa_calls_limit": 500, # 500 research queries
"video_calls_limit": 50, # 50 videos/month for pro plan "video_calls_limit": 30, # 30 videos - enough for daily episodes
"image_edit_calls_limit": 100, # 100 AI image editing calls/month "image_edit_calls_limit": 100, # 100 AI image edits
"audio_calls_limit": 200, # 200 AI audio generation calls/month "audio_calls_limit": 100, # 100 audio clips - podcast-focused
"wavespeed_calls_limit": 500, # WaveSpeed combined limit: TTS + video + image + LLM
"gemini_tokens_limit": 5000000, "gemini_tokens_limit": 5000000,
"openai_tokens_limit": 2500000, "openai_tokens_limit": 2500000,
"anthropic_tokens_limit": 1000000, "anthropic_tokens_limit": 1000000,
"mistral_tokens_limit": 2500000, "mistral_tokens_limit": 2500000,
"monthly_cost_limit": 150.0, "monthly_cost_limit": 100.0, # $100 cap - podcast-focused
"features": ["unlimited_content_generation", "premium_research", "advanced_analytics", "priority_support"], "features": ["unlimited_content_generation", "premium_research", "advanced_analytics", "priority_support"],
"description": "Perfect for growing businesses" "description": "Perfect for growing businesses"
}, },
@@ -581,6 +589,7 @@ class PricingService:
"tier": SubscriptionTier.ENTERPRISE, "tier": SubscriptionTier.ENTERPRISE,
"price_monthly": 199.0, "price_monthly": 199.0,
"price_yearly": 1990.0, "price_yearly": 1990.0,
"ai_text_generation_calls_limit": 0, # Unlimited
"gemini_calls_limit": 0, # Unlimited "gemini_calls_limit": 0, # Unlimited
"openai_calls_limit": 0, "openai_calls_limit": 0,
"anthropic_calls_limit": 0, "anthropic_calls_limit": 0,
@@ -594,6 +603,7 @@ class PricingService:
"video_calls_limit": 0, # Unlimited for enterprise "video_calls_limit": 0, # Unlimited for enterprise
"image_edit_calls_limit": 0, # Unlimited image editing for enterprise "image_edit_calls_limit": 0, # Unlimited image editing for enterprise
"audio_calls_limit": 0, # Unlimited audio generation for enterprise "audio_calls_limit": 0, # Unlimited audio generation for enterprise
"wavespeed_calls_limit": 0, # Unlimited for enterprise
"gemini_tokens_limit": 0, "gemini_tokens_limit": 0,
"openai_tokens_limit": 0, "openai_tokens_limit": 0,
"anthropic_tokens_limit": 0, "anthropic_tokens_limit": 0,
@@ -815,6 +825,7 @@ class PricingService:
'video_calls': getattr(plan, 'video_calls_limit', 0), # Support missing column 'video_calls': getattr(plan, 'video_calls_limit', 0), # Support missing column
'image_edit_calls': getattr(plan, 'image_edit_calls_limit', 0), # Support missing column 'image_edit_calls': getattr(plan, 'image_edit_calls_limit', 0), # Support missing column
'audio_calls': getattr(plan, 'audio_calls_limit', 0), # Support missing column 'audio_calls': getattr(plan, 'audio_calls_limit', 0), # Support missing column
'wavespeed_calls': getattr(plan, 'wavespeed_calls_limit', 0), # WaveSpeed API calls
# Token limits # Token limits
'gemini_tokens': plan.gemini_tokens_limit, 'gemini_tokens': plan.gemini_tokens_limit,
'openai_tokens': plan.openai_tokens_limit, 'openai_tokens': plan.openai_tokens_limit,

View File

@@ -29,10 +29,12 @@ def ensure_subscription_plan_columns(db: Session) -> None:
# Columns we may reference in models but might be missing in older DBs # Columns we may reference in models but might be missing in older DBs
required_columns = { required_columns = {
"ai_text_generation_calls_limit": "INTEGER DEFAULT 0",
"exa_calls_limit": "INTEGER DEFAULT 0", "exa_calls_limit": "INTEGER DEFAULT 0",
"video_calls_limit": "INTEGER DEFAULT 0", "video_calls_limit": "INTEGER DEFAULT 0",
"image_edit_calls_limit": "INTEGER DEFAULT 0", "image_edit_calls_limit": "INTEGER DEFAULT 0",
"audio_calls_limit": "INTEGER DEFAULT 0", "audio_calls_limit": "INTEGER DEFAULT 0",
"wavespeed_calls_limit": "INTEGER DEFAULT 0",
} }
for col_name, ddl in required_columns.items(): for col_name, ddl in required_columns.items():

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import desc from sqlalchemy import desc
from loguru import logger from loguru import logger
import json import json
from api.subscription.cache import clear_dashboard_cache
from models.subscription_models import ( from models.subscription_models import (
APIUsageLog, UsageSummary, APIProvider, UsageAlert, APIUsageLog, UsageSummary, APIProvider, UsageAlert,
@@ -170,6 +171,12 @@ class UsageTrackingService:
self.db.commit() self.db.commit()
# Invalidate dashboard cache so header stats update immediately
try:
clear_dashboard_cache(user_id)
except Exception as cache_err:
logger.debug(f"Could not clear dashboard cache: {cache_err}")
logger.info(f"Tracked API usage: {user_id} -> {provider.value} -> ${cost_data['cost_total']:.6f}") logger.info(f"Tracked API usage: {user_id} -> {provider.value} -> ${cost_data['cost_total']:.6f}")
return { return {
@@ -521,20 +528,26 @@ class UsageTrackingService:
async def reset_current_billing_period(self, user_id: str) -> Dict[str, Any]: async def reset_current_billing_period(self, user_id: str) -> Dict[str, Any]:
"""Reset usage status and counters for the current billing period (after plan renewal/change).""" """Reset usage status and counters for the current billing period (after plan renewal/change)."""
period_keys = self._get_authoritative_billing_period_keys(user_id)
billing_period = period_keys["billing_period"]
summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
).first()
if not summary:
return {"reset": False, "reason": "no_summary"}
try: try:
period_keys = self._get_authoritative_billing_period_keys(user_id)
billing_period = period_keys["billing_period"]
summary = self.db.query(UsageSummary).filter(
UsageSummary.user_id == user_id,
UsageSummary.billing_period.in_(period_keys["lookup_periods"])
).first()
if not summary:
return {"reset": False, "reason": "no_summary"}
reset_usage_summary_counters(summary) reset_usage_summary_counters(summary)
self.db.commit() self.db.commit()
# Invalidate dashboard cache so header stats update after reset
try:
clear_dashboard_cache(user_id)
except Exception as cache_err:
logger.debug(f"Could not clear dashboard cache: {cache_err}")
logger.info(f"Reset usage counters for user {user_id} in billing period {billing_period} after renewal") logger.info(f"Reset usage counters for user {user_id} in billing period {billing_period} after renewal")
return {"reset": True, "counters_reset": True} return {"reset": True, "counters_reset": True}
except Exception as e: except Exception as e:

View File

@@ -58,6 +58,7 @@ interface UsageLimits {
video_calls: number; video_calls: number;
image_edit_calls: number; image_edit_calls: number;
audio_calls: number; audio_calls: number;
wavespeed_calls: number;
monthly_cost: number; monthly_cost: number;
}; };
} }
@@ -107,10 +108,13 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
checkInterval: 120000, // Check every 2 minutes checkInterval: 120000, // Check every 2 minutes
}); });
const fetchUsageData = useCallback(async (period?: string) => { const fetchUsageData = useCallback(async (period?: string, silent = false) => {
if (!userId) return; if (!userId) return;
setLoading(true); // Don't block UI for silent background refreshes (menu open, visibility change)
if (!silent) {
setLoading(true);
}
setError(null); setError(null);
try { try {
const url = period const url = period
@@ -136,10 +140,14 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
throw new Error(response.data?.error || 'Failed to fetch usage data'); throw new Error(response.data?.error || 'Failed to fetch usage data');
} }
} catch (err: any) { } catch (err: any) {
console.error('Error fetching usage data:', err); if (!silent) {
setError(err.message || 'Failed to load usage statistics'); console.error('Error fetching usage data:', err);
setError(err.message || 'Failed to load usage statistics');
}
} finally { } finally {
setLoading(false); if (!silent) {
setLoading(false);
}
} }
}, [userId]); }, [userId]);
@@ -154,13 +162,30 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
if (userId) { if (userId) {
fetchUsageData(); fetchUsageData();
} }
}, [userId, fetchUsageData]); // Added fetchUsageData to deps since it's memoized }, [userId, fetchUsageData]);
// Refresh on visibility change (user returns to tab) - only if data is stale (>60s old)
useEffect(() => {
const STALE_THRESHOLD_MS = 60000; // 60 seconds
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && userId && lastUpdated) {
const ageMs = Date.now() - lastUpdated.getTime();
if (ageMs > STALE_THRESHOLD_MS) {
fetchUsageData(selectedPeriod, true);
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [userId, fetchUsageData, selectedPeriod, lastUpdated]);
const handleRefresh = () => { const handleRefresh = () => {
fetchUsageData(selectedPeriod); fetchUsageData(selectedPeriod);
}; };
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
// Show cached data immediately, don't wait for fetch
// Data will refresh when user clicks the manual refresh button
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
}; };
@@ -266,6 +291,10 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
const researchCalls = (providerBreakdown.exa?.calls || 0) + (providerBreakdown.tavily?.calls || 0) + (providerBreakdown.serper?.calls || 0) + (providerBreakdown.firecrawl?.calls || 0); const researchCalls = (providerBreakdown.exa?.calls || 0) + (providerBreakdown.tavily?.calls || 0) + (providerBreakdown.serper?.calls || 0) + (providerBreakdown.firecrawl?.calls || 0);
const researchCallLimit = (providerLimits.exa_calls || 0) + (providerLimits.tavily_calls || 0) + (providerLimits.serper_calls || 0) + (providerLimits.firecrawl_calls || 0); const researchCallLimit = (providerLimits.exa_calls || 0) + (providerLimits.tavily_calls || 0) + (providerLimits.serper_calls || 0) + (providerLimits.firecrawl_calls || 0);
// WaveSpeed calls (all WaveSpeed API calls)
const wavespeedCalls = providerBreakdown.wavespeed?.calls || 0;
const wavespeedCallLimit = providerLimits.wavespeed_calls || 0;
const formatLimit = (used: number, limit: number) => { const formatLimit = (used: number, limit: number) => {
if (limit === 0) return `${used} / ∞`; if (limit === 0) return `${used} / ∞`;
return `${used} / ${limit}`; return `${used} / ${limit}`;
@@ -470,6 +499,21 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
</Box> </Box>
</Box> </Box>
)} )}
{wavespeedCallLimit > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>WaveSpeed</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
<LinearProgress
variant="determinate"
value={wavespeedCallLimit > 0 ? Math.min((wavespeedCalls / wavespeedCallLimit) * 100, 100) : 0}
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(wavespeedCalls, wavespeedCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(wavespeedCalls, wavespeedCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(wavespeedCalls, wavespeedCallLimit)}
</Typography>
</Box>
</Box>
)}
</Box> </Box>
<Menu <Menu

View File

@@ -24,6 +24,7 @@ export interface SubscriptionLimits {
video_calls: number; video_calls: number;
image_edit_calls: number; image_edit_calls: number;
audio_calls: number; audio_calls: number;
wavespeed_calls: number;
monthly_cost: number; monthly_cost: number;
} }

View File

@@ -90,6 +90,8 @@ export const useSubscriptionGuard = (options: SubscriptionGuardOptions = {}) =>
return subscription.limits.ai_text_generation_calls || 0; return subscription.limits.ai_text_generation_calls || 0;
case 'exa_calls': case 'exa_calls':
return subscription.limits.exa_calls || 0; return subscription.limits.exa_calls || 0;
case 'wavespeed_calls':
return subscription.limits.wavespeed_calls || 0;
case 'monthly_cost': case 'monthly_cost':
return subscription.limits.monthly_cost; return subscription.limits.monthly_cost;
default: default:

View File

@@ -161,6 +161,7 @@ const defaultLimits = {
video_calls: 0, video_calls: 0,
image_edit_calls: 0, image_edit_calls: 0,
audio_calls: 0, audio_calls: 0,
wavespeed_calls: 0,
gemini_tokens: 0, gemini_tokens: 0,
openai_tokens: 0, openai_tokens: 0,
anthropic_tokens: 0, anthropic_tokens: 0,
@@ -211,6 +212,7 @@ function coerceUsageStats(raw: any): UsageStats {
video_calls: raw?.limits?.limits?.video_calls ?? 0, video_calls: raw?.limits?.limits?.video_calls ?? 0,
image_edit_calls: raw?.limits?.limits?.image_edit_calls ?? 0, image_edit_calls: raw?.limits?.limits?.image_edit_calls ?? 0,
audio_calls: raw?.limits?.limits?.audio_calls ?? 0, audio_calls: raw?.limits?.limits?.audio_calls ?? 0,
wavespeed_calls: raw?.limits?.limits?.wavespeed_calls ?? 0,
gemini_tokens: raw?.limits?.limits?.gemini_tokens ?? 0, gemini_tokens: raw?.limits?.limits?.gemini_tokens ?? 0,
openai_tokens: raw?.limits?.limits?.openai_tokens ?? 0, openai_tokens: raw?.limits?.limits?.openai_tokens ?? 0,
anthropic_tokens: raw?.limits?.limits?.anthropic_tokens ?? 0, anthropic_tokens: raw?.limits?.limits?.anthropic_tokens ?? 0,

View File

@@ -786,6 +786,14 @@ export const podcastApi = {
seed?: number; seed?: number;
maskImageUrl?: string; maskImageUrl?: string;
}): Promise<{ taskId: string; status: string; message: string }> { }): Promise<{ taskId: string; status: string; message: string }> {
// Preflight check for video generation
await ensurePreflight({
provider: 'video',
model: 'kling-v2.5-turbo-5s',
operation_type: 'video_generation',
actual_provider_name: 'wavespeed',
});
const response = await aiApiClient.post("/api/podcast/render/video", { const response = await aiApiClient.post("/api/podcast/render/video", {
project_id: params.projectId, project_id: params.projectId,
scene_id: params.sceneId, scene_id: params.sceneId,
@@ -884,6 +892,14 @@ export const podcastApi = {
cost: number; cost: number;
image_prompt?: string; image_prompt?: string;
}> { }> {
// Preflight check for image generation
await ensurePreflight({
provider: 'stability',
model: 'stability-ai',
operation_type: 'image_generation',
actual_provider_name: 'wavespeed',
});
const response = await aiApiClient.post("/api/podcast/image", { const response = await aiApiClient.post("/api/podcast/image", {
scene_id: params.sceneId, scene_id: params.sceneId,
scene_title: params.sceneTitle, scene_title: params.sceneTitle,

View File

@@ -63,6 +63,7 @@ export interface SubscriptionLimits {
video_calls: number; video_calls: number;
image_edit_calls: number; image_edit_calls: number;
audio_calls: number; audio_calls: number;
wavespeed_calls: number;
gemini_tokens: number; gemini_tokens: number;
openai_tokens: number; openai_tokens: number;
anthropic_tokens: number; anthropic_tokens: number;
@@ -224,6 +225,7 @@ export const SubscriptionLimitsSchema = z.object({
video_calls: z.number().optional().default(0), video_calls: z.number().optional().default(0),
image_edit_calls: z.number().optional().default(0), image_edit_calls: z.number().optional().default(0),
audio_calls: z.number().optional().default(0), audio_calls: z.number().optional().default(0),
wavespeed_calls: z.number().optional().default(0),
gemini_tokens: z.number(), gemini_tokens: z.number(),
openai_tokens: z.number(), openai_tokens: z.number(),
anthropic_tokens: z.number(), anthropic_tokens: z.number(),