From cbd68fa43f3f92c2b67c2db4d37634d6edca5486 Mon Sep 17 00:00:00 2001 From: ajaysi Date: Wed, 22 Apr 2026 12:27:51 +0530 Subject: [PATCH] Fix voice clone NotSupportedError and improve subscription services --- backend/api/assets_serving.py | 7 -- .../onboarding_utils/step4_asset_routes.py | 11 ++- backend/models/subscription_models.py | 1 + .../services/subscription/limit_validation.py | 93 +++++++++++++++---- .../services/subscription/pricing_service.py | 65 +++++++------ backend/services/subscription/schema_utils.py | 2 + .../subscription/usage_tracking_service.py | 35 ++++--- .../src/components/shared/UsageDashboard.tsx | 56 +++++++++-- frontend/src/contexts/SubscriptionContext.tsx | 1 + frontend/src/hooks/useSubscriptionGuard.ts | 2 + frontend/src/services/billingService.ts | 2 + frontend/src/services/podcastApi.ts | 16 ++++ frontend/src/types/billing.ts | 2 + 13 files changed, 221 insertions(+), 72 deletions(-) diff --git a/backend/api/assets_serving.py b/backend/api/assets_serving.py index eb19d40e..b9ad6037 100644 --- a/backend/api/assets_serving.py +++ b/backend/api/assets_serving.py @@ -43,16 +43,9 @@ def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path: safe_user_id = sanitize_user_id(user_id) 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() 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)): raise HTTPException(status_code=403, detail="Access denied") diff --git a/backend/api/onboarding_utils/step4_asset_routes.py b/backend/api/onboarding_utils/step4_asset_routes.py index a99d4f5c..dd1bb73b 100644 --- a/backend/api/onboarding_utils/step4_asset_routes.py +++ b/backend/api/onboarding_utils/step4_asset_routes.py @@ -539,6 +539,7 @@ async def create_voice_clone( # 3. Save Preview Audio (if generated) preview_url = None preview_mime_type = "audio/wav" + actual_filename = None # Default if preview save fails if preview_audio_bytes: 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}") # 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( db=db, user_id=user_id, file_path=file_path, asset_type="audio", source_module="voice_cloner", - filename=filename, - file_url=f"/api/assets/{user_id}/voice_samples/{filename}", + filename=stored_filename, + file_url=f"/api/assets/{user_id}/voice_samples/{stored_filename}", asset_metadata={ "voice_name": voice_name, "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 filename: {filename}") + logger.warning(f"[VoiceClone] Response stored_filename: {stored_filename}") return { "success": True, "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, "message": "Voice clone created successfully" } diff --git a/backend/models/subscription_models.py b/backend/models/subscription_models.py index 482e2c04..33b2ff0c 100644 --- a/backend/models/subscription_models.py +++ b/backend/models/subscription_models.py @@ -80,6 +80,7 @@ class SubscriptionPlan(Base): video_calls_limit = Column(Integer, default=0) # AI video generation image_edit_calls_limit = Column(Integer, default=0) # AI image editing 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) gemini_tokens_limit = Column(Integer, default=0) diff --git a/backend/services/subscription/limit_validation.py b/backend/services/subscription/limit_validation.py index 72e590cd..b2a822d7 100644 --- a/backend/services/subscription/limit_validation.py +++ b/backend/services/subscription/limit_validation.py @@ -107,6 +107,20 @@ class LimitValidator: } 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) # CRITICAL: Expire SQLAlchemy objects to ensure we get fresh plan data after renewal try: @@ -144,6 +158,9 @@ class LimitValidator: 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.", {} + # 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 # Use targeted expiry instead of expire_all() to avoid nuking the entire session cache try: @@ -245,8 +262,8 @@ class LimitValidator: (usage.mistral_calls or 0) ) - # Only enforce limit if limit > 0 (0 means unlimited for Enterprise) - if ai_text_gen_limit > 0 and current_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, 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})") 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, @@ -278,8 +295,8 @@ class LimitValidator: current_calls = getattr(usage, 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) - if call_limit > 0 and current_calls >= call_limit: + # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) + 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}") 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, @@ -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'}") except Exception as 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 # 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 token_limit = limits['limits'].get(f"{provider_name}_tokens", 0) or 0 - # Only enforce limit if limit > 0 (0 means unlimited for Enterprise) - if token_limit > 0 and (current_tokens + tokens_requested) > token_limit: + # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) + 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}", { 'current_tokens': current_tokens, 'requested_tokens': tokens_requested, @@ -328,14 +351,19 @@ class LimitValidator: return result except Exception as 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 - # NOTE: cost_limit = 0 means UNLIMITED (Enterprise plans) try: cost_limit = limits['limits'].get('monthly_cost', 0) or 0 - # Only enforce limit if limit > 0 (0 means unlimited for Enterprise) - if cost_limit > 0 and usage.total_cost >= cost_limit: + # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) + 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}", { 'current_cost': usage.total_cost, 'limit': cost_limit, @@ -348,7 +376,13 @@ class LimitValidator: return result except Exception as 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 try: @@ -503,6 +537,7 @@ class LimitValidator: return False, "No subscription plan found. Please subscribe to a plan.", {} limits = limits_dict.get('limits', {}) + tier = limits_dict.get('tier', 'free') # Track cumulative usage across all operations total_llm_calls = ( @@ -547,7 +582,8 @@ class LimitValidator: # Count this operation as an LLM call 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 = { 'current_calls': total_llm_calls, 'limit': ai_text_gen_limit, @@ -654,7 +690,8 @@ class LimitValidator: 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 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 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 = { 'current_images': total_images, 'limit': image_limit, @@ -737,7 +775,8 @@ class LimitValidator: total_video_calls = usage.video_calls or 0 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 = { 'current_calls': total_video_calls, 'limit': video_limit, @@ -756,7 +795,8 @@ class LimitValidator: total_image_edit_calls = getattr(usage, 'image_edit_calls', 0) or 0 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 = { 'current_calls': total_image_edit_calls, 'limit': image_edit_limit, @@ -789,6 +829,25 @@ class LimitValidator: 'error_type': 'call_limit', '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 logger.info(f"[Pre-flight Check] ✅ All {len(operations)} operation(s) validated successfully") diff --git a/backend/services/subscription/pricing_service.py b/backend/services/subscription/pricing_service.py index 3d2ac9ec..6134ec3a 100644 --- a/backend/services/subscription/pricing_service.py +++ b/backend/services/subscription/pricing_service.py @@ -505,21 +505,26 @@ class PricingService: "tier": SubscriptionTier.FREE, "price_monthly": 0.0, "price_yearly": 0.0, - "gemini_calls_limit": 100, - "openai_calls_limit": 0, - "anthropic_calls_limit": 0, - "mistral_calls_limit": 50, - "tavily_calls_limit": 20, - "serper_calls_limit": 20, - "metaphor_calls_limit": 10, - "firecrawl_calls_limit": 10, - "stability_calls_limit": 5, - "exa_calls_limit": 100, - "video_calls_limit": 0, # No video generation for free tier - "image_edit_calls_limit": 10, # 10 AI image editing calls/month - "audio_calls_limit": 20, # 20 AI audio generation calls/month - "gemini_tokens_limit": 100000, - "monthly_cost_limit": 0.0, + "ai_text_generation_calls_limit": 50, # Explicit: Free gets 50 AI text calls (via Gemini fallback) + "gemini_calls_limit": 50, + "openai_calls_limit": 0, # DISABLED: OpenAI access not included in Free tier + "anthropic_calls_limit": 0, # DISABLED: Anthropic access not included in Free tier + "mistral_calls_limit": 0, # DISABLED: HuggingFace not in Free tier + "tavily_calls_limit": 10, + "serper_calls_limit": 10, + "metaphor_calls_limit": 0, # DISABLED: Metaphor not in Free tier + "firecrawl_calls_limit": 0, # DISABLED: Firecrawl not in Free tier + "stability_calls_limit": 3, # 3 images - enough to try the product + "exa_calls_limit": 10, # 10 research queries - enough to try the product + "video_calls_limit": 0, # DISABLED: Video generation not in Free tier + "image_edit_calls_limit": 5, # 5 image edits - enough to try the product + "audio_calls_limit": 5, # 5 audio clips - enough to try the product + "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"], "description": "Perfect for trying out ALwrity" }, @@ -528,7 +533,7 @@ class PricingService: "tier": SubscriptionTier.BASIC, "price_monthly": 29.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) "openai_calls_limit": 500, "anthropic_calls_limit": 200, @@ -537,16 +542,17 @@ class PricingService: "serper_calls_limit": 200, "metaphor_calls_limit": 100, "firecrawl_calls_limit": 100, - "stability_calls_limit": 50, # INCREASED: Now includes WaveSpeed OSS models (Qwen Image $0.03) - "exa_calls_limit": 500, - "video_calls_limit": 30, # INCREASED: 30 videos/month (WAN 2.5 OSS $0.25) - "image_edit_calls_limit": 50, # INCREASED: 50 AI image editing calls/month (Qwen Edit OSS $0.02) + "stability_calls_limit": 25, # 25 images - good for podcast episode covers + "exa_calls_limit": 100, # 100 research queries + "video_calls_limit": 10, # 10 videos - enough for a few podcast episodes + "image_edit_calls_limit": 25, # 25 AI image edits "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) "openai_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 - "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"], "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, "price_monthly": 79.0, "price_yearly": 790.0, + "ai_text_generation_calls_limit": 3000, # Explicit: Pro gets 3000 AI text calls "gemini_calls_limit": 5000, "openai_calls_limit": 2500, "anthropic_calls_limit": 1000, @@ -563,16 +570,17 @@ class PricingService: "serper_calls_limit": 1000, "metaphor_calls_limit": 500, "firecrawl_calls_limit": 500, - "stability_calls_limit": 200, - "exa_calls_limit": 2000, - "video_calls_limit": 50, # 50 videos/month for pro plan - "image_edit_calls_limit": 100, # 100 AI image editing calls/month - "audio_calls_limit": 200, # 200 AI audio generation calls/month + "stability_calls_limit": 100, # 100 images - good for regular podcasts + "exa_calls_limit": 500, # 500 research queries + "video_calls_limit": 30, # 30 videos - enough for daily episodes + "image_edit_calls_limit": 100, # 100 AI image edits + "audio_calls_limit": 100, # 100 audio clips - podcast-focused + "wavespeed_calls_limit": 500, # WaveSpeed combined limit: TTS + video + image + LLM "gemini_tokens_limit": 5000000, "openai_tokens_limit": 2500000, "anthropic_tokens_limit": 1000000, "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"], "description": "Perfect for growing businesses" }, @@ -581,6 +589,7 @@ class PricingService: "tier": SubscriptionTier.ENTERPRISE, "price_monthly": 199.0, "price_yearly": 1990.0, + "ai_text_generation_calls_limit": 0, # Unlimited "gemini_calls_limit": 0, # Unlimited "openai_calls_limit": 0, "anthropic_calls_limit": 0, @@ -594,6 +603,7 @@ class PricingService: "video_calls_limit": 0, # Unlimited for enterprise "image_edit_calls_limit": 0, # Unlimited image editing for enterprise "audio_calls_limit": 0, # Unlimited audio generation for enterprise + "wavespeed_calls_limit": 0, # Unlimited for enterprise "gemini_tokens_limit": 0, "openai_tokens_limit": 0, "anthropic_tokens_limit": 0, @@ -815,6 +825,7 @@ class PricingService: 'video_calls': getattr(plan, 'video_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 + 'wavespeed_calls': getattr(plan, 'wavespeed_calls_limit', 0), # WaveSpeed API calls # Token limits 'gemini_tokens': plan.gemini_tokens_limit, 'openai_tokens': plan.openai_tokens_limit, diff --git a/backend/services/subscription/schema_utils.py b/backend/services/subscription/schema_utils.py index 7e0d508f..ee5b756e 100644 --- a/backend/services/subscription/schema_utils.py +++ b/backend/services/subscription/schema_utils.py @@ -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 required_columns = { + "ai_text_generation_calls_limit": "INTEGER DEFAULT 0", "exa_calls_limit": "INTEGER DEFAULT 0", "video_calls_limit": "INTEGER DEFAULT 0", "image_edit_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(): diff --git a/backend/services/subscription/usage_tracking_service.py b/backend/services/subscription/usage_tracking_service.py index 5c9cf7e2..f79b53fc 100644 --- a/backend/services/subscription/usage_tracking_service.py +++ b/backend/services/subscription/usage_tracking_service.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import Session from sqlalchemy import desc from loguru import logger import json +from api.subscription.cache import clear_dashboard_cache from models.subscription_models import ( APIUsageLog, UsageSummary, APIProvider, UsageAlert, @@ -170,6 +171,12 @@ class UsageTrackingService: 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}") return { @@ -521,20 +528,26 @@ class UsageTrackingService: 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).""" + 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: - 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) 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") return {"reset": True, "counters_reset": True} except Exception as e: diff --git a/frontend/src/components/shared/UsageDashboard.tsx b/frontend/src/components/shared/UsageDashboard.tsx index e67919d5..141fc27c 100644 --- a/frontend/src/components/shared/UsageDashboard.tsx +++ b/frontend/src/components/shared/UsageDashboard.tsx @@ -58,6 +58,7 @@ interface UsageLimits { video_calls: number; image_edit_calls: number; audio_calls: number; + wavespeed_calls: number; monthly_cost: number; }; } @@ -107,10 +108,13 @@ const UsageDashboard: React.FC = ({ checkInterval: 120000, // Check every 2 minutes }); - const fetchUsageData = useCallback(async (period?: string) => { + const fetchUsageData = useCallback(async (period?: string, silent = false) => { if (!userId) return; - setLoading(true); + // Don't block UI for silent background refreshes (menu open, visibility change) + if (!silent) { + setLoading(true); + } setError(null); try { const url = period @@ -136,10 +140,14 @@ const UsageDashboard: React.FC = ({ throw new Error(response.data?.error || 'Failed to fetch usage data'); } } catch (err: any) { - console.error('Error fetching usage data:', err); - setError(err.message || 'Failed to load usage statistics'); + if (!silent) { + console.error('Error fetching usage data:', err); + setError(err.message || 'Failed to load usage statistics'); + } } finally { - setLoading(false); + if (!silent) { + setLoading(false); + } } }, [userId]); @@ -154,13 +162,30 @@ const UsageDashboard: React.FC = ({ if (userId) { 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 = () => { fetchUsageData(selectedPeriod); }; const handleMenuOpen = (event: React.MouseEvent) => { + // Show cached data immediately, don't wait for fetch + // Data will refresh when user clicks the manual refresh button setAnchorEl(event.currentTarget); }; @@ -266,6 +291,10 @@ const UsageDashboard: React.FC = ({ 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); + // WaveSpeed calls (all WaveSpeed API calls) + const wavespeedCalls = providerBreakdown.wavespeed?.calls || 0; + const wavespeedCallLimit = providerLimits.wavespeed_calls || 0; + const formatLimit = (used: number, limit: number) => { if (limit === 0) return `${used} / ∞`; return `${used} / ${limit}`; @@ -470,6 +499,21 @@ const UsageDashboard: React.FC = ({ )} + {wavespeedCallLimit > 0 && ( + + WaveSpeed + + 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 } }} + /> + + {formatLimit(wavespeedCalls, wavespeedCallLimit)} + + + + )} return subscription.limits.ai_text_generation_calls || 0; case 'exa_calls': return subscription.limits.exa_calls || 0; + case 'wavespeed_calls': + return subscription.limits.wavespeed_calls || 0; case 'monthly_cost': return subscription.limits.monthly_cost; default: diff --git a/frontend/src/services/billingService.ts b/frontend/src/services/billingService.ts index d0a7489f..dc467fda 100644 --- a/frontend/src/services/billingService.ts +++ b/frontend/src/services/billingService.ts @@ -161,6 +161,7 @@ const defaultLimits = { video_calls: 0, image_edit_calls: 0, audio_calls: 0, + wavespeed_calls: 0, gemini_tokens: 0, openai_tokens: 0, anthropic_tokens: 0, @@ -211,6 +212,7 @@ function coerceUsageStats(raw: any): UsageStats { video_calls: raw?.limits?.limits?.video_calls ?? 0, image_edit_calls: raw?.limits?.limits?.image_edit_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, openai_tokens: raw?.limits?.limits?.openai_tokens ?? 0, anthropic_tokens: raw?.limits?.limits?.anthropic_tokens ?? 0, diff --git a/frontend/src/services/podcastApi.ts b/frontend/src/services/podcastApi.ts index 31356aae..842c478d 100644 --- a/frontend/src/services/podcastApi.ts +++ b/frontend/src/services/podcastApi.ts @@ -786,6 +786,14 @@ export const podcastApi = { seed?: number; maskImageUrl?: 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", { project_id: params.projectId, scene_id: params.sceneId, @@ -884,6 +892,14 @@ export const podcastApi = { cost: number; 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", { scene_id: params.sceneId, scene_title: params.sceneTitle, diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts index 0fb1dcf8..5878b76f 100644 --- a/frontend/src/types/billing.ts +++ b/frontend/src/types/billing.ts @@ -63,6 +63,7 @@ export interface SubscriptionLimits { video_calls: number; image_edit_calls: number; audio_calls: number; + wavespeed_calls: number; gemini_tokens: number; openai_tokens: number; anthropic_tokens: number; @@ -224,6 +225,7 @@ export const SubscriptionLimitsSchema = z.object({ video_calls: z.number().optional().default(0), image_edit_calls: z.number().optional().default(0), audio_calls: z.number().optional().default(0), + wavespeed_calls: z.number().optional().default(0), gemini_tokens: z.number(), openai_tokens: z.number(), anthropic_tokens: z.number(),