Commit_all_local_changes_after_PR_406_merge

This commit is contained in:
ajaysi
2026-03-10 17:01:36 +05:30
parent f78b5f1e04
commit 8c2d88efb9
17 changed files with 936 additions and 412 deletions

View File

@@ -45,11 +45,17 @@ def _extract_error_message(exc: Exception) -> str:
"""
Extract user-friendly error message from exception.
Handles HTTPException with nested error details from WaveSpeed API.
Preserves subscription modal flags for frontend.
"""
if isinstance(exc, HTTPException):
detail = exc.detail
# If detail is a dict (from WaveSpeed client)
if isinstance(detail, dict):
# Check if this is a subscription/credit error
if detail.get("error_type") == "insufficient_credits" or detail.get("show_subscription_modal"):
# Return the error message with subscription modal flag
return detail.get("message", "Insufficient credits. Please top up your account.")
# Try to extract message from nested response JSON
response_str = detail.get("response", "")
if response_str:
@@ -86,6 +92,27 @@ def _extract_error_message(exc: Exception) -> str:
return error_str
def _extract_error_metadata(exc: Exception) -> Dict[str, Any]:
"""Extract structured error metadata for task polling clients."""
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, dict):
return {
"error_status": exc.status_code,
"error_data": detail,
}
if isinstance(detail, str):
return {
"error_status": exc.status_code,
"error_data": {
"error": detail,
"message": detail,
},
}
return {}
def _execute_podcast_video_task(
task_id: str,
request: PodcastVideoGenerationRequest,
@@ -229,9 +256,15 @@ def _execute_podcast_video_task(
# Extract user-friendly error message from exception
error_msg = _extract_error_message(exc)
error_meta = _extract_error_metadata(exc)
task_manager.update_task_status(
task_id, "failed", error=error_msg, message=f"Video generation failed: {error_msg}"
task_id,
"failed",
error=error_msg,
message=f"Video generation failed: {error_msg}",
error_status=error_meta.get("error_status"),
error_data=error_meta.get("error_data"),
)
@@ -257,7 +290,7 @@ async def generate_podcast_video(
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
auth_header = request.headers.get("Authorization")
except:
except Exception:
pass
if auth_header and auth_header.startswith("Bearer "):

View File

@@ -76,6 +76,10 @@ class TaskManager:
if task["status"] == "failed" and task.get("error"):
response["error"] = task["error"]
if task.get("error_status") is not None:
response["error_status"] = task["error_status"]
if task.get("error_data") is not None:
response["error_data"] = task["error_data"]
return response
@@ -86,7 +90,9 @@ class TaskManager:
progress: Optional[float] = None,
message: Optional[str] = None,
result: Optional[Dict[str, Any]] = None,
error: Optional[str] = None
error: Optional[str] = None,
error_status: Optional[int] = None,
error_data: Optional[Dict[str, Any]] = None,
):
"""Update the status of a task."""
if task_id not in self.task_storage:
@@ -112,6 +118,10 @@ class TaskManager:
if error is not None:
task["error"] = error
logger.error(f"[StoryWriter] Task {task_id} error: {error}")
if error_status is not None:
task["error_status"] = error_status
if error_data is not None:
task["error_data"] = error_data
async def execute_story_generation_task(
self,

View File

@@ -11,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError
from middleware.auth_middleware import get_current_user
from services.database import get_db
from services.today_workflow_service import get_or_create_daily_workflow_plan, update_task_status
from services.today_workflow_service import get_or_create_daily_workflow_plan, update_task_status, _today_date_str
from models.daily_workflow_models import DailyWorkflowPlan, DailyWorkflowTask
import asyncio
from services.intelligence.txtai_service import TxtaiIntelligenceService
@@ -81,26 +81,7 @@ async def _index_tasks_to_sif(user_id: str, date: str, tasks: list[dict], label:
logger.debug(f"Background indexing failed for user {user_id}: {e}")
@router.get("")
async def get_today_workflow(
date: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
from starlette.concurrency import run_in_threadpool
user_id = str(current_user.get("id"))
plan, created = await get_or_create_daily_workflow_plan(db, user_id, date=date)
def _fetch_tasks():
return (
db.query(DailyWorkflowTask)
.filter(DailyWorkflowTask.plan_id == plan.id, DailyWorkflowTask.user_id == user_id)
.order_by(DailyWorkflowTask.created_at.asc())
.all()
)
tasks = await run_in_threadpool(_fetch_tasks)
def _build_workflow_payload(user_id: str, plan: DailyWorkflowPlan, tasks: list[DailyWorkflowTask]) -> Dict[str, Any]:
response_tasks = []
for t in tasks:
response_tasks.append(
@@ -136,8 +117,156 @@ async def get_today_workflow(
workflow_status = "completed"
total_estimated = int(sum(int(t.get("estimatedTime") or 0) for t in response_tasks))
plan_json = plan.plan_json or {}
return {
"workflow": {
"id": f"daily-{user_id}-{plan.date}",
"date": plan.date,
"userId": user_id,
"tasks": response_tasks,
"currentTaskIndex": current_index,
"completedTasks": completed,
"totalTasks": total,
"workflowStatus": workflow_status,
"totalEstimatedTime": total_estimated,
"actualTimeSpent": 0,
},
"plan": {
"id": plan.id,
"date": plan.date,
"source": plan.source,
"generation_mode": plan.generation_mode,
"committee_agent_count": plan.committee_agent_count,
"fallback_used": bool(plan.fallback_used),
"quality_status": plan_json.get("quality_status", "contextual"),
"contextuality_validation": plan_json.get("contextuality_validation"),
"provenance_summary": {
"generationMode": plan.generation_mode,
"committeeAgentCount": plan.committee_agent_count,
"fallbackUsed": bool(plan.fallback_used),
"taskSourceBreakdown": {},
},
"created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
},
"schedule_status": {
"date": plan.date,
"generated": True,
"scheduled_run_completed": plan.source == "scheduled",
"source": plan.source,
"created_at": plan.created_at.isoformat() if plan.created_at else None,
},
}
@router.get("")
async def get_today_workflow(
date: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""Get existing daily workflow for the specified date.
Returns 404 if no workflow exists for the date.
Workflow should only be created via explicit user action or scheduled job.
"""
from starlette.concurrency import run_in_threadpool
user_id = str(current_user.get("id"))
date_str = date or _today_date_str()
def _get_existing():
return (
db.query(DailyWorkflowPlan)
.filter(DailyWorkflowPlan.user_id == user_id, DailyWorkflowPlan.date == date_str)
.first()
)
plan = await run_in_threadpool(_get_existing)
if not plan:
raise HTTPException(
status_code=404,
detail=f"No workflow found for date {date_str}. Workflow should be generated via explicit user action or scheduled job."
)
def _fetch_tasks():
return (
db.query(DailyWorkflowTask)
.filter(DailyWorkflowTask.plan_id == plan.id, DailyWorkflowTask.user_id == user_id)
.order_by(DailyWorkflowTask.created_at.asc())
.all()
)
tasks = await run_in_threadpool(_fetch_tasks)
return {
"success": True,
"data": _build_workflow_payload(user_id, plan, tasks),
"timestamp": datetime.utcnow().isoformat(),
"user_id": user_id,
}
@router.get("/status")
async def get_today_workflow_status(
date: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
from starlette.concurrency import run_in_threadpool
user_id = str(current_user.get("id"))
date_str = date or _today_date_str()
def _get_existing():
return (
db.query(DailyWorkflowPlan)
.filter(DailyWorkflowPlan.user_id == user_id, DailyWorkflowPlan.date == date_str)
.first()
)
plan = await run_in_threadpool(_get_existing)
return {
"success": True,
"data": {
"date": date_str,
"generated": plan is not None,
"scheduled_run_completed": bool(plan and plan.source == "scheduled"),
"source": plan.source if plan else None,
"created_at": plan.created_at.isoformat() if plan and plan.created_at else None,
},
"timestamp": datetime.utcnow().isoformat(),
"user_id": user_id,
}
@router.post("/generate")
async def generate_workflow(
date: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
"""Explicitly generate a new daily workflow for the specified date.
This should only be called when the user explicitly requests workflow generation
or via a scheduled job at night.
"""
from starlette.concurrency import run_in_threadpool
user_id = str(current_user.get("id"))
plan, created = await get_or_create_daily_workflow_plan(db, user_id, date=date, creation_source="manual")
def _fetch_tasks():
return (
db.query(DailyWorkflowTask)
.filter(DailyWorkflowTask.plan_id == plan.id, DailyWorkflowTask.user_id == user_id)
.order_by(DailyWorkflowTask.created_at.asc())
.all()
)
tasks = await run_in_threadpool(_fetch_tasks)
if created:
response_tasks = _build_workflow_payload(user_id, plan, tasks)["workflow"]["tasks"]
asyncio.create_task(_index_tasks_to_sif(user_id, plan.date, response_tasks, label="today"))
from datetime import date as date_type, timedelta
@@ -200,29 +329,7 @@ async def get_today_workflow(
return {
"success": True,
"data": {
"workflow": {
"id": f"daily-{user_id}-{plan.date}",
"date": plan.date,
"userId": user_id,
"tasks": response_tasks,
"currentTaskIndex": current_index,
"completedTasks": completed,
"totalTasks": total,
"workflowStatus": workflow_status,
"totalEstimatedTime": total_estimated,
"actualTimeSpent": 0,
},
"plan": {
"id": plan.id,
"date": plan.date,
"source": plan.source,
"quality_status": (plan.plan_json or {}).get("quality_status", "contextual"),
"contextuality_validation": (plan.plan_json or {}).get("contextuality_validation"),
"created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
},
},
"data": _build_workflow_payload(user_id, plan, tasks),
"timestamp": datetime.utcnow().isoformat(),
"user_id": user_id,
}

View File

@@ -18,6 +18,26 @@ router = APIRouter()
UPLOAD_DIR = Path("backend/data/video_studio/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def _extract_error_metadata(exc: Exception) -> Dict[str, Any]:
"""Extract structured HTTP error metadata for polling clients."""
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, dict):
return {
"error_status": exc.status_code,
"error_data": detail,
}
if isinstance(detail, str):
return {
"error_status": exc.status_code,
"error_data": {
"error": detail,
"message": detail,
},
}
return {}
def _process_avatar_generation(task_id: str, image_path: Path, audio_path: Path, user_id: str, resolution: str, model: str):
"""
Background task to process avatar generation using shared InfiniteTalk service.
@@ -94,7 +114,15 @@ def _process_avatar_generation(task_id: str, image_path: Path, audio_path: Path,
except Exception as e:
logger.error(f"[VideoStudio] Avatar generation failed for task {task_id}: {e}", exc_info=True)
task_manager.update_task(task_id, "failed", error=str(e), user_id=user_id)
error_meta = _extract_error_metadata(e)
task_manager.update_task(
task_id,
"failed",
error=str(e),
user_id=user_id,
error_status=error_meta.get("error_status"),
error_data=error_meta.get("error_data"),
)
finally:
# Cleanup temp upload files
try:

View File

@@ -39,7 +39,18 @@ class TaskManager:
logger.error(f"[VideoStudio] Failed to create task: {e}")
raise
def update_task(self, task_id: str, status: str, result: Optional[Dict] = None, error: Optional[str] = None, user_id: str = None, progress: float = None, message: str = None):
def update_task(
self,
task_id: str,
status: str,
result: Optional[Dict] = None,
error: Optional[str] = None,
user_id: str = None,
progress: float = None,
message: str = None,
error_status: Optional[int] = None,
error_data: Optional[Dict[str, Any]] = None,
):
"""Update an existing task."""
if not user_id:
logger.error(f"[VideoStudio] Cannot update task {task_id} without user_id")
@@ -74,6 +85,13 @@ class TaskManager:
task.result = result
if error:
task.error = error
if error_status is not None or error_data is not None:
result_payload = task.result if isinstance(task.result, dict) else {}
if error_status is not None:
result_payload["error_status"] = error_status
if error_data is not None:
result_payload["error_data"] = error_data
task.result = result_payload
if progress is not None:
task.progress = progress
if message:
@@ -107,7 +125,7 @@ class TaskManager:
if status_val == "processing":
status_val = "running"
return {
response = {
"task_id": task.task_id,
"status": status_val,
"result": task.result,
@@ -117,6 +135,12 @@ class TaskManager:
"created_at": task.created_at,
"updated_at": task.updated_at
}
if isinstance(task.result, dict):
if task.result.get("error_status") is not None:
response["error_status"] = task.result.get("error_status")
if task.result.get("error_data") is not None:
response["error_data"] = task.result.get("error_data")
return response
finally:
db.close()
except Exception as e:

View File

@@ -4,6 +4,10 @@ import sqlite3
import os
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent.parent
WORKSPACE_DIR = ROOT_DIR / "workspace"
def migrate_database(db_path):
"""Add missing columns to daily_workflow_plans table."""
if not os.path.exists(db_path):
@@ -46,14 +50,14 @@ def migrate_database(db_path):
def find_and_migrate_databases():
"""Find all databases and apply migrations."""
workspace_dir = r'c:\Users\diksha rawat\Desktop\ALwrity\workspace'
workspace_dir = WORKSPACE_DIR
if not os.path.exists(workspace_dir):
if not workspace_dir.exists():
print(f"Workspace directory not found: {workspace_dir}")
return
# Find all .db files
db_files = list(Path(workspace_dir).glob('**/db/*.db'))
db_files = list(workspace_dir.glob('**/db/*.db'))
if not db_files:
print("No databases found to migrate")

View File

@@ -53,6 +53,39 @@ WORKSPACE_DIR = os.path.join(ROOT_DIR, 'workspace')
# Engine cache for multi-tenant support
_user_engines = {}
def _ensure_daily_workflow_schema(engine, user_id: str) -> None:
"""Backfill required daily_workflow_plans columns for legacy tenant DBs."""
required_columns = {
"generation_mode": "VARCHAR(30) NOT NULL DEFAULT 'llm_generation'",
"committee_agent_count": "INTEGER NOT NULL DEFAULT 0",
"fallback_used": "BOOLEAN NOT NULL DEFAULT 0",
"generation_run_id": "INTEGER",
}
try:
with engine.begin() as conn:
table_check = conn.exec_driver_sql(
"SELECT name FROM sqlite_master WHERE type='table' AND name='daily_workflow_plans'"
).fetchone()
if not table_check:
return
existing_cols = {
row[1] for row in conn.exec_driver_sql("PRAGMA table_info(daily_workflow_plans)").fetchall()
}
for col_name, col_def in required_columns.items():
if col_name not in existing_cols:
conn.exec_driver_sql(
f"ALTER TABLE daily_workflow_plans ADD COLUMN {col_name} {col_def}"
)
logger.warning(
f"Auto-migrated daily_workflow_plans column '{col_name}' for user {user_id}"
)
except Exception as e:
logger.error(f"Failed daily_workflow_plans schema compatibility check for user {user_id}: {e}")
def get_user_db_path(user_id: str) -> str:
"""Get the database path for a specific user."""
# Sanitize user_id to be safe for filesystem
@@ -192,6 +225,7 @@ def init_user_database(user_id: str):
UserBusinessInfoBase.metadata.create_all(bind=engine)
ContentAssetBase.metadata.create_all(bind=engine)
BingAnalyticsBase.metadata.create_all(bind=engine)
_ensure_daily_workflow_schema(engine, user_id)
# Initialize default data for new databases
try:

View File

@@ -3,7 +3,10 @@ Task Scheduler Package
Modular, pluggable scheduler for ALwrity tasks.
"""
import os
from sqlalchemy.orm import Session
from apscheduler.triggers.cron import CronTrigger
from .core.scheduler import TaskScheduler
from .core.executor_interface import TaskExecutor, TaskExecutionResult
@@ -32,6 +35,7 @@ from .utils.platform_insights_task_loader import load_due_platform_insights_task
from .utils.advertools_task_loader import load_due_advertools_tasks
from .utils.sif_indexing_task_loader import load_due_sif_indexing_tasks
from .utils.market_trends_task_loader import load_due_market_trends_tasks
from services.today_workflow_service import generate_scheduled_daily_workflows
# Global scheduler instance (initialized on first access)
_scheduler_instance: TaskScheduler = None
@@ -143,6 +147,18 @@ def get_scheduler() -> TaskScheduler:
market_trends_executor,
load_due_market_trends_tasks
)
today_workflow_hour_utc = int(os.getenv('TODAY_WORKFLOW_SCHEDULE_HOUR_UTC', '2'))
today_workflow_minute_utc = int(os.getenv('TODAY_WORKFLOW_SCHEDULE_MINUTE_UTC', '0'))
_scheduler_instance.scheduler.add_job(
generate_scheduled_daily_workflows,
trigger=CronTrigger(hour=today_workflow_hour_utc, minute=today_workflow_minute_utc, timezone='UTC'),
id='generate_daily_workflows',
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=3600,
)
return _scheduler_instance

View File

@@ -8,6 +8,7 @@ from models.daily_workflow_models import DailyWorkflowPlan, DailyWorkflowTask
from models.agent_activity_models import AgentAlert
from services.agent_activity_service import AgentActivityService, build_agent_event_payload
from services.llm_providers.main_text_generation import llm_text_gen
from services.database import get_all_user_ids, get_session_for_user
from loguru import logger
PILLAR_IDS = ["plan", "generate", "publish", "analyze", "engage", "remarket"]
@@ -604,7 +605,12 @@ async def generate_agent_enhanced_plan(
return result
async def get_or_create_daily_workflow_plan(db: Session, user_id: str, date: Optional[str] = None) -> tuple[DailyWorkflowPlan, bool]:
async def get_or_create_daily_workflow_plan(
db: Session,
user_id: str,
date: Optional[str] = None,
creation_source: str = "manual",
) -> tuple[DailyWorkflowPlan, bool]:
from starlette.concurrency import run_in_threadpool
date_str = date or _today_date_str()
@@ -646,7 +652,10 @@ async def get_or_create_daily_workflow_plan(db: Session, user_id: str, date: Opt
plan = DailyWorkflowPlan(
user_id=user_id,
date=date_str,
source="agent",
source=creation_source,
generation_mode=_derive_generation_mode(plan_data),
committee_agent_count=_count_committee_agents(tasks),
fallback_used=_plan_uses_fallback(tasks),
plan_json=plan_data,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
@@ -685,6 +694,80 @@ async def get_or_create_daily_workflow_plan(db: Session, user_id: str, date: Opt
return plan, True
def _derive_generation_mode(plan_data: Dict[str, Any]) -> str:
tasks = plan_data.get("tasks", []) if isinstance(plan_data, dict) else []
source_modes = set()
for task in tasks:
metadata = task.get("metadata") if isinstance(task, dict) else {}
metadata = metadata if isinstance(metadata, dict) else {}
source_agent = str(metadata.get("source_agent") or "").strip()
source = str(metadata.get("source") or "").strip()
if source_agent:
source_modes.add("agent_committee")
elif source in {"controlled_fallback", "llm_pillar_backfill"}:
source_modes.add(source)
if "agent_committee" in source_modes:
return "agent_committee"
if "controlled_fallback" in source_modes:
return "controlled_fallback"
if "llm_pillar_backfill" in source_modes:
return "llm_pillar_backfill"
return "llm_generation"
def _count_committee_agents(tasks: List[Dict[str, Any]]) -> int:
agents = set()
for task in tasks:
metadata = task.get("metadata") if isinstance(task, dict) else {}
metadata = metadata if isinstance(metadata, dict) else {}
source_agent = str(metadata.get("source_agent") or "").strip()
if source_agent:
agents.add(source_agent)
return len(agents)
def _plan_uses_fallback(tasks: List[Dict[str, Any]]) -> bool:
for task in tasks:
metadata = task.get("metadata") if isinstance(task, dict) else {}
metadata = metadata if isinstance(metadata, dict) else {}
source = str(metadata.get("source") or "").strip()
if source in {"controlled_fallback", "llm_pillar_backfill"}:
return True
return False
async def generate_scheduled_daily_workflows() -> Dict[str, int]:
user_ids = get_all_user_ids()
stats = {"users_seen": 0, "created": 0, "existing": 0, "failed": 0}
for user_id in user_ids:
stats["users_seen"] += 1
db = None
try:
db = get_session_for_user(user_id)
plan, created = await get_or_create_daily_workflow_plan(
db,
user_id,
creation_source="scheduled",
)
if created:
stats["created"] += 1
logger.info("Scheduled daily workflow created for user {} date {}", user_id, plan.date)
else:
stats["existing"] += 1
logger.info("Scheduled daily workflow already exists for user {} date {}", user_id, plan.date)
except Exception as e:
stats["failed"] += 1
logger.error("Scheduled daily workflow generation failed for user {}: {}", user_id, e)
finally:
if db:
db.close()
logger.info("Scheduled daily workflow run complete: {}", stats)
return stats
def update_task_status(
db: Session,
user_id: str,

View File

@@ -3,6 +3,7 @@ Video generation operations (text-to-video and image-to-video).
"""
import requests
import json
from typing import Any, Dict, Optional
from fastapi import HTTPException
@@ -12,6 +13,19 @@ from .base import VideoBase
logger = get_service_logger("wavespeed.generators.video.generation")
def _extract_wavespeed_message(response_text: str) -> str:
"""Best-effort extraction of WaveSpeed error message from response payload."""
if not response_text:
return ""
try:
parsed = json.loads(response_text)
if isinstance(parsed, dict):
return str(parsed.get("message") or parsed.get("error") or "")
except (json.JSONDecodeError, TypeError, ValueError):
return ""
return ""
class VideoGeneration(VideoBase):
"""Video generation operations."""
@@ -31,6 +45,25 @@ class VideoGeneration(VideoBase):
response = requests.post(url, headers=self._get_headers(), json=payload, timeout=timeout)
if response.status_code != 200:
logger.error(f"[WaveSpeed] Submission failed: {response.status_code} {response.text}")
error_message = _extract_wavespeed_message(response.text)
if "insufficient credits" in error_message.lower() or "credit" in error_message.lower():
raise HTTPException(
status_code=429,
detail={
"error": "Insufficient WaveSpeed credits",
"message": "Insufficient credits. Please top up to continue video generation.",
"provider": "wavespeed",
"usage_info": {
"provider": "wavespeed",
"type": "credits",
"limit_type": "provider_credits",
"operation_type": "scene_animation",
"action_required": "top_up",
},
},
)
raise HTTPException(
status_code=502,
detail={
@@ -75,6 +108,25 @@ class VideoGeneration(VideoBase):
if response.status_code != 200:
logger.error(f"[WaveSpeed] Text-to-video submission failed: {response.status_code} {response.text}")
error_message = _extract_wavespeed_message(response.text)
if "insufficient credits" in error_message.lower() or "credit" in error_message.lower():
raise HTTPException(
status_code=429,
detail={
"error": "Insufficient WaveSpeed credits",
"message": "Insufficient credits. Please top up to continue video generation.",
"provider": "wavespeed",
"usage_info": {
"provider": "wavespeed",
"type": "credits",
"limit_type": "provider_credits",
"operation_type": "video_generation",
"action_required": "top_up",
},
},
)
raise HTTPException(
status_code=502,
detail={
@@ -174,6 +226,25 @@ class VideoGeneration(VideoBase):
if response.status_code != 200:
logger.error(f"[WaveSpeed] Text-to-video submission failed: {response.status_code} {response.text}")
error_message = _extract_wavespeed_message(response.text)
if "insufficient credits" in error_message.lower() or "credit" in error_message.lower():
raise HTTPException(
status_code=429,
detail={
"error": "Insufficient WaveSpeed credits",
"message": "Insufficient credits. Please top up to continue video generation.",
"provider": "wavespeed",
"usage_info": {
"provider": "wavespeed",
"type": "credits",
"limit_type": "provider_credits",
"operation_type": "video_generation",
"action_required": "top_up",
},
},
)
raise HTTPException(
status_code=502,
detail={