Skill: misaka43fd/mulerouter-skills (version 0.1.0)

--- name: mulerouter description: Generates images and videos using MuleRouter or MuleRun multimodal APIs. Text-to-Image, Image-to-Image, Text-to-Video, Image-to-Video, video editing (VACE, keyframe interpolation). Use when the user wants to generate, edit, or transform images and videos using AI models like Wan2.6, Veo3, Nano Banana Pro, Sora2, Midjourney. compatibility: Requires Python 3.10+, uv (or pip), and network access to api.mulerouter.ai or api.mulerun.com --- # MuleRouter API Generate images and videos using MuleRouter or MuleRun multimodal APIs. ## Configuration Check Before running any commands, verify the environment is configured: ### Step 1: Check for existing configuration ```bash # Check environment variables echo "MULEROUTER_BASE_URL: $MULEROUTER_BASE_URL" echo "MULEROUTER_SITE: $MULEROUTER_SITE" echo "MULEROUTER_API_KEY: ${MULEROUTER_API_KEY:+[SET]}" # Check for .env file ls -la .env 2>/dev/null || echo "No .env file found" ``` ### Step 2: Configure if needed **Option A: Environment variables with custom base URL (highest priority)** ```bash export MULEROUTER_BASE_URL="https://api.mulerouter.ai" # or your custom API endpoint export MULEROUTER_API_KEY="your-api-key" ``` **Option B: Environment variables with site (used if base URL not set)** ```bash export MULEROUTER_SITE="mulerun" # or "mulerouter" export MULEROUTER_API_KEY="your-api-key" ``` **Option C: Create .env file** Create `.env` in the current working directory: ```env # Option 1: Use custom base URL (takes priority over SITE) MULEROUTER_BASE_URL=https://api.mulerouter.ai MULEROUTER_API_KEY=your-api-key # Option 2: Use site (if BASE_URL not set) # MULEROUTER_SITE=mulerun # MULEROUTER_API_KEY=your-api-key ``` **Note:** `MULEROUTER_BASE_URL` takes priority over `MULEROUTER_SITE`. If both are set, `MULEROUTER_BASE_URL` is used. **Note:** The tool only reads `.env` from the current directory. Run scripts from the skill root (`skills/mulerouter-skills/`). ### Step 3: Using `uv` to run scripts The skill uses `uv` for dependency management and execution. Make sure `uv` is installed and available in your PATH. Run `uv sync` to install dependencies. ## Quick Start ### 1. List available models ```bash uv run python scripts/list_models.py ``` ### 2. Check model parameters ```bash uv run python models/alibaba/wan2.6-t2v/generation.py --list-params ``` ### 3. Generate content **Text-to-Video:** ```bash uv run python models/alibaba/wan2.6-t2v/generation.py --prompt "A cat walking through a garden" ``` **Text-to-Image:** ```bash uv run python models/alibaba/wan2.6-t2i/generation.py --prompt "A serene mountain lake" ``` **Image-to-Video:** ```bash uv run python models/alibaba/wan2.6-i2v/generation.py --prompt "Gentle zoom in" --image "https://example.com/photo.jpg" #remote image url ``` ```bash uv run python models/alibaba/wan2.6-i2v/generation.py --prompt "Gentle zoom in" --image "/path/to/local/image.png" #local image path ``` ## Image Input For image parameters (`--image`, `--images`, etc.), **prefer local file paths** over base64. ```bash # Preferred: local file path (auto-converted to base64) --image /tmp/photo.png --images ["/tmp/photo.png"] ``` The skill automatically converts local file paths to base64 before sending to the API. This avoids command-line length limits that occur with raw base64 strings. ## Workflow 1. Check configuration: verify `MULEROUTER_BASE_URL` or `MULEROUTER_SITE`, and `MULEROUTER_API_KEY` are set 2. Install dependencies: run `uv sync` 3. Run `uv run python scripts/list_models.py` to discover available models 4. Run `uv run python models/<path>/<action>.py --list-params` to see parameters 5. Execute with appropriate parameters 6. Parse output URLs from results ## Tips 1. For an image generation model, a suggested timeout is 5 minutes. 2. For a video generation model, a suggested timeout is 15 minutes. ## References - [REFERENCE.md](references/REFERENCE.md) - API configuration and CLI options - [MODELS.md](references/MODELS.md) - Complete model specifications

path: README.md
content: # MuleRouter Skill

A Claude Code skill for generating images and videos using MuleRouter/MuleRun multimodal APIs.

## Features

- Text-to-Image generation
- Text-to-Video generation
- Image-to-Video transformation
- Image-to-Image editing
- Video editing (VACE, keyframe interpolation)

## Requirements

- Python 3.10+
- `uv` package manager
- API key from MuleRouter or MuleRun

## Setup

```bash
# Option 1: Use custom base URL (takes priority)
export MULEROUTER_BASE_URL="https://api.mulerouter.ai"
export MULEROUTER_API_KEY="your-api-key"

# Option 2: Use site (if BASE_URL not set)
export MULEROUTER_SITE="mulerun"  # or "mulerouter"
export MULEROUTER_API_KEY="your-api-key"
```

## Usage

```bash
# List available models
uv run python scripts/list_models.py

# Generate video from text
uv run python models/alibaba/wan2.6-t2v/generation.py --prompt "A cat walking"

# Generate image from text
uv run python models/alibaba/wan2.6-t2i/generation.py --prompt "A mountain lake"
```

See [SKILL.md](SKILL.md) for detailed documentation.


path: core/__init__.py
content: """Core utilities for MuleRouter Agent Skill."""

from .client import APIClient, APIResponse
from .config import Config, Site, get_config_help, get_site_from_env, load_config, load_env_file
from .image import enhance_image_param_description, process_image_params
from .registry import (
    InputType,
    ModelEndpoint,
    ModelParameter,
    ModelRegistry,
    OutputType,
    register_endpoint,
    registry,
)
from .task import (
    TaskResult,
    TaskStatus,
    create_and_poll_task,
    is_success_status,
    is_terminal_status,
    parse_task_response,
    poll_task,
)

__all__ = [
    # Config
    "Config",
    "Site",
    "load_config",
    "load_env_file",
    "get_site_from_env",
    "get_config_help",
    # Client
    "APIClient",
    "APIResponse",
    # Registry
    "ModelRegistry",
    "ModelEndpoint",
    "ModelParameter",
    "InputType",
    "OutputType",
    "registry",
    "register_endpoint",
    # Task
    "TaskStatus",
    "TaskResult",
    "poll_task",
    "create_and_poll_task",
    "parse_task_response",
    "is_terminal_status",
    "is_success_status",
    # Image
    "process_image_params",
    "enhance_image_param_description",
]


path: core/client.py
content: """HTTP client for MuleRouter API with retry logic and proper headers."""

import time
from dataclasses import dataclass
from typing import Any

import httpx

from .config import Config

# User agent identifying requests from Agent Skills
USER_AGENT = "MuleRouter-AgentSkill/1.0.0"


@dataclass
class APIResponse:
    """Standardized API response wrapper.

    Attributes:
        success: Whether the request was successful
        data: Response data (if successful)
        error: Error message (if failed)
        status_code: HTTP status code
        traceparent: Traceparent header for debugging
    """

    success: bool
    data: dict | None = None
    error: str | None = None
    status_code: int = 0
    traceparent: str | None = None


class APIClient:
    """HTTP client for MuleRouter/MuleRun API.

    Features:
    - Automatic retries with exponential backoff
    - Proper authentication headers
    - Agent Skills identification via User-Agent
    - Timeout handling
    """

    def __init__(self, config: Config) -> None:
        """Initialize API client.

        Args:
            config: Configuration instance with API key and endpoint settings
        """
        self.config = config
        self._client: httpx.Client | None = None

    @property
    def client(self) -> httpx.Client:
        """Lazy-initialize and return HTTP client."""
        if self._client is None:
            self._client = httpx.Client(
                base_url=self.config.base_url,
                timeout=httpx.Timeout(self.config.timeout),
                headers={
                    "Authorization": f"Bearer {self.config.api_key}",
                    "Content-Type": "application/json",
                    "User-Agent": USER_AGENT,
                    "X-Agent-Skills": "mulerouter",
                },
            )
        return self._client

    def close(self) -> None:
        """Close the HTTP client."""
        if self._client is not None:
            self._client.close()
            self._client = None

    def __enter__(self) -> "APIClient":
        """Context manager entry."""
        return self

    def __exit__(self, *args: Any) -> None:
        """Context manager exit."""
        self.close()

    def _handle_response(self, response: httpx.Response) -> APIResponse:
        """Handle HTTP response and convert to APIResponse.

        Args:
            response: httpx Response object

        Returns:
            Standardized APIResponse
        """
        traceparent = response.headers.get("traceparent")

        try:
            data = response.json()
        except Exception:
            data = None

        if response.is_success:
            return APIResponse(
                success=True,
                data=data,
                status_code=response.status_code,
                traceparent=traceparent,
            )

        # Extract error message
        error_msg = f"HTTP {response.status_code}"
        if data:
            if "detail" in data:
                error_msg = data["detail"]
            elif "error" in data:
                error_msg = data["error"]
            elif "message" in data:
                error_msg = data["message"]
            elif "task_info" in data and "error" in data["task_info"]:
                err = data["task_info"]["error"]
                error_msg = err.get("detail", err.get("title", str(err)))

        return APIResponse(
            success=False,
            data=data,
            error=error_msg,
            status_code=response.status_code,
            traceparent=traceparent,
        )

    def _should_retry(self, response: httpx.Response) -> bool:
        """Determine if request should be retried.

        Args:
            response: httpx Response object

        Returns:
            True if request should be retried
        """
        # Retry on server errors and rate limiting
        return response.status_code in (429, 500, 502, 503, 504)

    def request(
        self,
        method: str,
        path: str,
        json: dict | None = None,
        params: dict | None = None,
    ) -> APIResponse:
        """Make HTTP request with retry logic.

        Args:
            method: HTTP method (GET, POST, etc.)
            path: API path (e.g., /vendors/google/v1/nano-banana-pro/generation)
            json: JSON body for POST requests
            params: Query parameters

        Returns:
            APIResponse with result or error
        """
        last_response: APIResponse | None = None

        for attempt in range(self.config.max_retries + 1):
            try:
                response = self.client.request(
                    method=method,
                    url=path,
                    json=json,
                    params=params,
                )

                if response.is_success or not self._should_retry(response):
                    return self._handle_response(response)

                last_response = self._handle_response(response)

            except httpx.TimeoutException:
                last_response = APIResponse(
                    success=False,
                    error=f"Request timeout after {self.config.timeout}s",
                    status_code=0,
                )
            except httpx.RequestError as e:
                last_response = APIResponse(
                    success=False,
                    error=f"Request error: {e}",
                    status_code=0,
                )

            # Exponential backoff before retry
            if attempt < self.config.max_retries:
                wait_time = 2**attempt
                time.sleep(wait_time)

        return last_response or APIResponse(
            success=False,
            error="Max retries exceeded",
            status_code=0,
        )

    def post(self, path: str, json: dict | None = None) -> APIResponse:
        """Make POST request.

        Args:
            path: API path
            json: JSON body

        Returns:
            APIResponse with result or error
        """
        return self.request("POST", path, json=json)

    def get(self, path: str, params: dict | None = None) -> APIResponse:
        """Make GET request.

        Args:
            path: API path
            params: Query parameters

        Returns:
            APIResponse with result or error
        """
        return self.request("GET", path, params=params)


path: core/config.py
content: """Configuration management for MuleRouter skill."""

import os
from dataclasses import dataclass
from enum import Enum
from pathlib import Path

from dotenv import load_dotenv


class Site(Enum):
    """Supported API sites."""

    MULEROUTER = "mulerouter"
    MULERUN = "mulerun"


@dataclass
class Config:
    """Configuration for MuleRouter API client.

    Attributes:
        api_key: API key for authentication
        site: API site (mulerouter or mulerun), used when base_url is not set
        base_url: Base URL for the API (explicit or auto-derived from site)
        timeout: Request timeout in seconds
        max_retries: Maximum number of retries for failed requests
    """

    api_key: str
    site: Site | None = None
    base_url: str = ""
    timeout: float = 120.0
    max_retries: int = 3

    def __post_init__(self) -> None:
        """Set base_url based on site if not explicitly provided."""
        if not self.base_url:
            if self.site == Site.MULEROUTER:
                self.base_url = "https://api.mulerouter.ai"
            elif self.site == Site.MULERUN:
                self.base_url = "https://api.mulerun.com"
            else:
                raise ValueError(
                    "Either base_url or site must be provided."
                )


def load_env_file(env_file: Path | None = None) -> None:
    """Load .env file if it exists.

    Args:
        env_file: Path to .env file (defaults to current directory)
    """
    if env_file:
        load_dotenv(env_file)
    else:
        env_path = Path(".env")
        if env_path.exists():
            load_dotenv(env_path)


def get_site_from_env() -> Site | None:
    """Get site from environment variable.

    Returns:
        Site enum if MULEROUTER_SITE is set, None otherwise
    """
    site_str = os.getenv("MULEROUTER_SITE")
    if not site_str:
        return None
    try:
        return Site(site_str.lower())
    except ValueError:
        return None


def load_config(
    api_key: str | None = None,
    site: str | None = None,
    base_url: str | None = None,
    env_file: Path | None = None,
) -> Config:
    """Load configuration from environment or parameters.

    Priority: explicit parameters > environment variables > .env file
    For base_url vs site: MULEROUTER_BASE_URL > MULEROUTER_SITE

    Args:
        [REDACTED_SECRET] API key (overrides environment)
        site: Explicit site name (overrides environment)
        base_url: Explicit base URL (overrides environment and site)
        env_file: Path to .env file (defaults to current directory)

    Returns:
        Configured Config instance

    Raises:
        ValueError: If API key is not found, or neither base_url nor site is provided
    """
    # Load .env file if it exists
    load_env_file(env_file)

    # Resolve base_url first (highest priority)
    resolved_base_url = base_url or os.getenv("MULEROUTER_BASE_URL")

    # Resolve site (only used if base_url is not set)
    resolved_site: Site | None = None
    if not resolved_base_url:
        site_str = site or os.getenv("MULEROUTER_SITE")
        if not site_str:
            raise ValueError(
                "Configuration not specified. Please set either:\n"
                "  - MULEROUTER_BASE_URL environment variable, or\n"
                "  - MULEROUTER_SITE environment variable to 'mulerouter' or 'mulerun'\n"
                "You can also provide --base-url or --site arguments."
            )

        try:
            resolved_site = Site(site_str.lower())
        except ValueError as err:
            raise ValueError(
                f"Invalid site: {site_str}. Must be 'mulerouter' or 'mulerun'."
            ) from err

    # Resolve API key
    resolved_api_key = api_key or os.getenv("MULEROUTER_API_KEY")
    if not resolved_api_key:
        raise ValueError(
            "API key not found. Please set MULEROUTER_API_KEY environment variable, "
            "or provide it via --api-key argument."
        )

    return Config(
        [REDACTED_SECRET],
        site=resolved_site,
        base_url=resolved_base_url or "",
    )


def get_config_help() -> str:
    """Get help text for configuration."""
    return """
Configuration Options:
---------------------
Environment Variables (Required):
  MULEROUTER_API_KEY    API key for authentication (required)

  One of the following (MULEROUTER_BASE_URL takes priority):
  MULEROUTER_BASE_URL   Custom API base URL (e.g., https://api.example.com)
  MULEROUTER_SITE       API site: 'mulerouter' or 'mulerun'

.env File:
  Create a .env file in the current directory with the above variables.

  Example .env (using site):
    MULEROUTER_SITE=mulerun
    MULEROUTER_API_KEY=your-api-key-here

  Example .env (using custom base URL):
    MULEROUTER_BASE_URL=https://api.custom.example.com
    MULEROUTER_API_KEY=your-api-key-here

Command Line:
  --api-key KEY         Override API key
  --base-url URL        Override base URL (takes priority over --site)
  --site SITE           Override site (mulerouter/mulerun)
"""


path: core/image.py
content: """Image utilities for handling local file paths and base64 conversion."""

import base64
import mimetypes
from pathlib import Path
from typing import Any

# Parameter names that accept image input (local path, URL, or base64)
IMAGE_PARAM_NAMES = {
    "image",
    "images",
    "first_frame",
    "last_frame",
    "first_frame_url",
    "last_frame_url",
    "ref_images_url",
    "reference_images",
}

# Hint to add to image parameter descriptions
IMAGE_PARAM_HINT = " **Prefer local file path** (e.g., /tmp/img.png) - auto-converted to base64."


def is_image_param(name: str) -> bool:
    """Check if a parameter name is an image parameter."""
    return name in IMAGE_PARAM_NAMES


def enhance_image_param_description(name: str, description: str) -> str:
    """Enhance image parameter description with local file path hint.

    Args:
        name: Parameter name
        description: Original description

    Returns:
        Enhanced description if it's an image param, otherwise original
    """
    if is_image_param(name):
        return description + IMAGE_PARAM_HINT
    return description


def is_local_file(value: str) -> bool:
    """Check if a string value is a local file path."""
    if not isinstance(value, str):
        return False
    if value.startswith(("http://", "https://", "data:")):
        return False
    path = Path(value)
    return path.exists() and path.is_file()


def file_to_base64(file_path: str) -> str:
    """Convert a local file to base64 data URI.

    Args:
        file_path: Path to the local file

    Returns:
        Base64 data URI string (e.g., "data:image/png;base64,...")
    """
    path = Path(file_path)
    mime_type, _ = mimetypes.guess_type(str(path))
    if mime_type is None:
        mime_type = "image/png"

    with open(path, "rb") as f:
        data = base64.b64encode(f.read()).decode("utf-8")

    return f"data:{mime_type};base64,{data}"


def convert_image_value(value: Any) -> Any:
    """Convert image parameter value, handling local file paths.

    Args:
        value: Parameter value (string or list of strings)

    Returns:
        Converted value with local files as base64 data URIs
    """
    if isinstance(value, str):
        if is_local_file(value):
            return file_to_base64(value)
        return value
    elif isinstance(value, list):
        return [convert_image_value(v) for v in value]
    return value


def process_image_params(body: dict[str, Any]) -> dict[str, Any]:
    """Process request body, converting local file paths to base64.

    Args:
        body: Request body dictionary

    Returns:
        Processed body with image params converted
    """
    result = body.copy()
    for key in IMAGE_PARAM_NAMES:
        if key in result:
            result[key] = convert_image_value(result[key])
    return result


path: core/registry.py
content: """Model registry for tracking available models and their capabilities."""

from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional


class OutputType(Enum):
    """Type of output produced by a model."""

    IMAGE = "image"
    VIDEO = "video"
    TEXT = "text"
    AUDIO = "audio"


class InputType(Enum):
    """Type of input accepted by a model."""

    TEXT = "text"
    IMAGE = "image"
    VIDEO = "video"
    AUDIO = "audio"


@dataclass
class ModelParameter:
    """Parameter definition for a model endpoint.

    Attributes:
        name: Parameter name
        type: Parameter type (string, integer, boolean, etc.)
        description: Parameter description
        required: Whether the parameter is required
        default: Default value
        enum: List of allowed values (if applicable)
    """

    name: str
    type: str
    description: str
    required: bool = False
    default: Any = None
    enum: list[Any] | None = None

    def to_dict(self) -> dict:
        """Convert to dictionary representation."""
        result = {
            "name": self.name,
            "type": self.type,
            "description": self.description,
            "required": self.required,
        }
        if self.default is not None:
            result["default"] = self.default
        if self.enum:
            result["enum"] = self.enum
        return result


@dataclass
class ModelEndpoint:
    """Registration info for a model endpoint.

    Attributes:
        model_id: Unique model identifier (e.g., alibaba/wan2.6-t2v)
        action: Endpoint action (e.g., generation, edit)
        provider: Provider name (e.g., alibaba, google)
        model_name: Model name (e.g., wan2.6-t2v)
        description: Human-readable description
        input_types: Types of inputs accepted
        output_type: Type of output produced
        api_path: RESTful API path
        parameters: List of supported parameters
        available_on: List of endpoints where this model is available
        result_key: Key in response containing results (images/videos)
    """

    model_id: str
    action: str
    provider: str
    model_name: str
    description: str
    input_types: list[InputType]
    output_type: OutputType
    api_path: str
    parameters: list[ModelParameter] = field(default_factory=list)
    available_on: list[str] = field(default_factory=lambda: ["mulerouter", "mulerun"])
    result_key: str = "images"

    def to_dict(self) -> dict:
        """Convert to dictionary representation."""
        return {
            "model_id": self.model_id,
            "action": self.action,
            "provider": self.provider,
            "model_name": self.model_name,
            "description": self.description,
            "input_types": [t.value for t in self.input_types],
            "output_type": self.output_type.value,
            "api_path": self.api_path,
            "available_on": self.available_on,
            "result_key": self.result_key,
        }


class ModelRegistry:
    """Registry for tracking available models and endpoints.

    This is a singleton that models register themselves with at import time.
    """

    _instance: Optional["ModelRegistry"] = None
    _endpoints: dict[str, ModelEndpoint]

    def __new__(cls) -> "ModelRegistry":
        """Create singleton instance."""
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._endpoints = {}
        return cls._instance

    def register(self, endpoint: ModelEndpoint) -> None:
        """Register a model endpoint.

        Args:
            endpoint: ModelEndpoint to register
        """
        key = f"{endpoint.model_id}/{endpoint.action}"
        self._endpoints[key] = endpoint

    def get(self, model_id: str, action: str) -> ModelEndpoint | None:
        """Get a registered endpoint.

        Args:
            model_id: Model identifier
            action: Endpoint action

        Returns:
            ModelEndpoint if found, None otherwise
        """
        key = f"{model_id}/{action}"
        return self._endpoints.get(key)

    def list_all(self) -> list[ModelEndpoint]:
        """List all registered endpoints.

        Returns:
            List of all registered ModelEndpoints
        """
        return list(self._endpoints.values())

    def list_for_endpoint(self, endpoint_name: str) -> list[ModelEndpoint]:
        """List endpoints available on a specific API endpoint.

        Args:
            endpoint_name: 'mulerouter' or 'mulerun'

        Returns:
            List of ModelEndpoints available on that endpoint
        """
        return [e for e in self._endpoints.values() if endpoint_name in e.available_on]

    def list_by_provider(self, provider: str) -> list[ModelEndpoint]:
        """List endpoints for a specific provider.

        Args:
            provider: Provider name (e.g., 'alibaba', 'google')

        Returns:
            List of ModelEndpoints from that provider
        """
        return [e for e in self._endpoints.values() if e.provider == provider]

    def list_by_output_type(self, output_type: OutputType) -> list[ModelEndpoint]:
        """List endpoints by output type.

        Args:
            output_type: Type of output (IMAGE, VIDEO, etc.)

        Returns:
            List of ModelEndpoints producing that output type
        """
        return [e for e in self._endpoints.values() if e.output_type == output_type]

    def get_providers(self) -> list[str]:
        """Get list of unique providers.

        Returns:
            List of provider names
        """
        return sorted(set(e.provider for e in self._endpoints.values()))

    def get_models(self) -> list[str]:
        """Get list of unique model IDs.

        Returns:
            List of model IDs
        """
        return sorted(set(e.model_id for e in self._endpoints.values()))


# Global registry instance
registry = ModelRegistry()


def register_endpoint(endpoint: ModelEndpoint) -> ModelEndpoint:
    """Register an endpoint with the global registry.

    Args:
        endpoint: ModelEndpoint to register

    Returns:
        The registered endpoint (for chaining)
    """
    registry.register(endpoint)
    return endpoint


path: core/task.py
content: """Task polling utilities for asynchronous API operations."""

import time
from dataclasses import dataclass
from enum import Enum

from .client import APIClient


class TaskStatus(Enum):
    """Task status values."""

    PENDING = "pending"
    QUEUED = "queued"
    RUNNING = "running"
    PROCESSING = "processing"
    COMPLETED = "completed"
    SUCCEEDED = "succeeded"
    FAILED = "failed"


@dataclass
class TaskResult:
    """Result of a task operation.

    Attributes:
        task_id: UUID of the task
        status: Current task status
        data: Full response data
        error: Error message if failed
        result_key: Key in data containing the result (e.g., 'images', 'videos')
        results: List of result URLs (images/videos)
    """

    task_id: str
    status: TaskStatus
    data: dict
    error: str | None = None
    result_key: str | None = None
    results: list[str] | None = None


def is_terminal_status(status: TaskStatus) -> bool:
    """Check if task status is terminal (completed or failed).

    Args:
        status: Task status

    Returns:
        True if status is terminal
    """
    return status in (TaskStatus.COMPLETED, TaskStatus.SUCCEEDED, TaskStatus.FAILED)


def is_success_status(status: TaskStatus) -> bool:
    """Check if task status indicates success.

    Args:
        status: Task status

    Returns:
        True if status indicates success
    """
    return status in (TaskStatus.COMPLETED, TaskStatus.SUCCEEDED)


def parse_task_response(response_data: dict, result_key: str = "images") -> TaskResult:
    """Parse task response into TaskResult.

    Args:
        response_data: Raw API response data
        result_key: Key containing the results (images, videos, etc.)

    Returns:
        Parsed TaskResult
    """
    task_info = response_data.get("task_info", {})
    task_id = task_info.get("id", "")
    status_str = task_info.get("status", "unknown")

    try:
        status = TaskStatus(status_str)
    except ValueError:
        status = TaskStatus.PENDING

    error = None
    if status == TaskStatus.FAILED:
        error_info = task_info.get("error", {})
        if isinstance(error_info, dict):
            error = error_info.get("detail") or error_info.get("title") or str(error_info)
        else:
            error = str(error_info)

    results = response_data.get(result_key) if is_success_status(status) else None

    return TaskResult(
        task_id=task_id,
        status=status,
        data=response_data,
        error=error,
        result_key=result_key,
        results=results,
    )


def poll_task(
    client: APIClient,
    task_path: str,
    task_id: str,
    result_key: str = "images",
    interval: float = 20.0,
    max_wait: float = 600.0,
    verbose: bool = True,
) -> TaskResult:
    """Poll a task until completion.

    Args:
        client: API client instance
        task_path: Base path for the task endpoint (e.g., /vendors/google/v1/nano-banana-pro/generation)
        task_id: UUID of the task to poll
        result_key: Key in response containing results
        interval: Polling interval in seconds
        max_wait: Maximum wait time in seconds
        verbose: Print status updates

    Returns:
        TaskResult with final status and results
    """
    start_time = time.time()
    poll_url = f"{task_path}/{task_id}"

    while True:
        elapsed = time.time() - start_time
        if elapsed > max_wait:
            return TaskResult(
                task_id=task_id,
                status=TaskStatus.FAILED,
                data={},
                error=f"Polling timeout after {max_wait}s",
            )

        response = client.get(poll_url)

        if not response.success:
            return TaskResult(
                task_id=task_id,
                status=TaskStatus.FAILED,
                data=response.data or {},
                error=response.error,
            )

        result = parse_task_response(response.data or {}, result_key)

        if verbose:
            print(f"[{elapsed:.1f}s] Task {task_id[:8]}... status: {result.status.value}")

        if is_terminal_status(result.status):
            return result

        time.sleep(interval)


def create_and_poll_task(
    client: APIClient,
    endpoint_path: str,
    request_body: dict,
    result_key: str = "images",
    interval: float = 20.0,
    max_wait: float = 600.0,
    verbose: bool = True,
) -> TaskResult:
    """Create a task and poll until completion.

    Args:
        client: API client instance
        endpoint_path: Path for the creation endpoint
        request_body: Request body for task creation
        result_key: Key in response containing results
        interval: Polling interval in seconds
        max_wait: Maximum wait time in seconds
        verbose: Print status updates

    Returns:
        TaskResult with final status and results
    """
    if verbose:
        print(f"Creating task at {endpoint_path}...")

    response = client.post(endpoint_path, json=request_body)

    if not response.success:
        return TaskResult(
            task_id="",
            status=TaskStatus.FAILED,
            data=response.data or {},
            error=response.error,
        )

    task_info = (response.data or {}).get("task_info", {})
    task_id = task_info.get("id")

    if not task_id:
        return TaskResult(
            task_id="",
            status=TaskStatus.FAILED,
            data=response.data or {},
            error="No task ID returned",
        )

    if verbose:
        print(f"Task created: {task_id}")

    return poll_task(
        client=client,
        task_path=endpoint_path,
        task_id=task_id,
        result_key=result_key,
        interval=interval,
        max_wait=max_wait,
        verbose=verbose,
    )


path: models/__init__.py
content: """Model endpoints for MuleRouter/MuleRun API.

This package provides CLI scripts for each model endpoint.
Import this package to register all models with the global registry.

Usage Examples:
    # List all available models
    python scripts/list_models.py

    # Generate video with Wan2.6 T2V
    python models/alibaba/wan2.6-t2v/generation.py --prompt "A cat walking"

    # Generate image with Nano Banana Pro
    python models/google/nano-banana-pro/generation.py --prompt "A sunset"

    # Show parameters for an endpoint
    python models/alibaba/wan2.6-t2v/generation.py --list-params
"""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_root_dir = _file_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

# Import provider packages to register all endpoints
from models import alibaba, google, midjourney, openai
from models.base import BaseModelEndpoint, create_endpoint_module

__all__ = [
    "alibaba",
    "google",
    "midjourney",
    "openai",
    "BaseModelEndpoint",
    "create_endpoint_module",
]


path: models/alibaba/__init__.py
content: """Alibaba (Wan) models.

This package imports all Alibaba model endpoints to register them with the global registry.
Since directory names contain hyphens (e.g., wan2.6-t2v), we use importlib to import them.
"""

import contextlib
import importlib.util
from pathlib import Path

# Get the directory of this file
_package_dir = Path(__file__).parent

# List of model directories and their scripts to import
_model_files = [
    # Wan 2.6 series
    "wan2.6-t2v/generation.py",
    "wan2.6-i2v/generation.py",
    "wan2.6-t2i/generation.py",
    "wan2.6-image/generation.py",
    # Wan 2.5 preview series
    "wan2.5-t2v-preview/generation.py",
    "wan2.5-i2v-preview/generation.py",
    "wan2.5-t2i-preview/generation.py",
    "wan2.5-i2i-preview/generation.py",
    # Wan 2.2 plus/flash series
    "wan2.2-t2v-plus/generation.py",
    "wan2.2-i2v-plus/generation.py",
    "wan2.2-i2v-flash/generation.py",
    # Wan 2.1 plus series
    "wan2.1-vace-plus/generation.py",
    "wan2.1-kf2v-plus/generation.py",
]


def _import_model_file(model_file: str) -> None:
    """Import a model file to register its endpoints."""
    file_path = _package_dir / model_file

    if not file_path.exists():
        return

    module_name = model_file.replace("/", "_").replace("-", "_").replace(".", "_")
    spec = importlib.util.spec_from_file_location(
        f"models.alibaba.{module_name}",
        file_path,
    )
    if spec and spec.loader:
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)


# Import each model file to register endpoints
for _model_file in _model_files:
    with contextlib.suppress(Exception):
        _import_model_file(_model_file)


path: models/alibaba/wan2.1-kf2v-plus/generation.py
content: """Wan2.1 Keyframe-to-Video Plus generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.1-kf2v-plus",
    action="generation",
    provider="alibaba",
    model_name="wan2.1-kf2v-plus",
    description="Generate videos from first and last frame keyframes using Wan2.1 Keyframe-to-Video Plus model",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.1-kf2v-plus/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Transition and motion description between the first and last frame (max 800 characters)",
            required=True,
        ),
        ModelParameter(
            name="image",
            type="string",
            description="First keyframe image (URL or Base64). Supported formats: JPEG/JPG/PNG/BMP/WEBP, 360-2000px, ≤10MB",
            required=True,
        ),
        ModelParameter(
            name="last_frame",
            type="string",
            description="Last keyframe image (URL or Base64). Same constraints as first frame",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="template",
            type="string",
            description="Optional video effect template name",
            required=False,
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Output resolution (fixed at 720P)",
            required=False,
            default="720P",
            enum=["720P"],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (fixed at 5)",
            required=False,
            enum=[5],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan21KF2VPlusGeneration(BaseModelEndpoint):
    """Wan2.1 Keyframe-to-Video Plus generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan21KF2VPlusGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.1-vace-plus/generation.py
content: """Wan2.1 VACE Plus video editing generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.1-vace-plus",
    action="generation",
    provider="alibaba",
    model_name="wan2.1-vace-plus",
    description="Edit videos using Wan2.1 VACE Plus model with multi-modal inputs (image reference, video repainting, local editing, video extension, video outpainting)",
    input_types=[InputType.VIDEO, InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.1-vace-plus/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="model",
            type="string",
            description="Model name (only wan2.1-vace-plus is supported)",
            required=True,
            enum=["wan2.1-vace-plus"],
        ),
        ModelParameter(
            name="function",
            type="string",
            description="Feature selector: image_reference, video_repainting, video_edit, video_extension, video_outpainting",
            required=True,
            enum=["image_reference", "video_repainting", "video_edit", "video_extension", "video_outpainting"],
        ),
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description for the video (max 800 characters)",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="ref_images_url",
            type="array",
            description="Reference image URLs (1-3 images for image_reference, 1 for video_repainting/video_edit)",
            required=False,
        ),
        ModelParameter(
            name="obj_or_bg",
            type="array",
            description="Label each ref image as 'obj' (entity) or 'bg' (background). Required for multiple images",
            required=False,
        ),
        ModelParameter(
            name="video_url",
            type="string",
            description="Source video URL (MP4, ≤50MB, ≥16 FPS, ≤5s). Required for video_repainting, video_edit, video_outpainting",
            required=False,
        ),
        ModelParameter(
            name="control_condition",
            type="string",
            description="Feature extraction method for video_repainting/video_edit/video_extension",
            required=False,
            enum=["posebodyface", "posebody", "depth", "scribble"],
        ),
        ModelParameter(
            name="strength",
            type="number",
            description="Control strength for video_repainting (0.0-1.0, default 1.0)",
            required=False,
        ),
        ModelParameter(
            name="mask_image_url",
            type="string",
            description="Mask image URL for video_edit (white=edit, black=keep)",
            required=False,
        ),
        ModelParameter(
            name="mask_video_url",
            type="string",
            description="Mask video URL for video_edit (MP4)",
            required=False,
        ),
        ModelParameter(
            name="mask_frame_id",
            type="integer",
            description="1-based frame index where mask applies (default: 1)",
            required=False,
        ),
        ModelParameter(
            name="mask_type",
            type="string",
            description="Mask behavior for video_edit: tracking or fixed",
            required=False,
            enum=["tracking", "fixed"],
        ),
        ModelParameter(
            name="expand_ratio",
            type="number",
            description="Expansion ratio for tracking masks (0.0-1.0, default 0.05)",
            required=False,
        ),
        ModelParameter(
            name="expand_mode",
            type="string",
            description="Shape of tracking mask expansion: hull, bbox, or original",
            required=False,
            enum=["hull", "bbox", "original"],
        ),
        ModelParameter(
            name="first_frame_url",
            type="string",
            description="Starting frame image URL for video_extension",
            required=False,
        ),
        ModelParameter(
            name="last_frame_url",
            type="string",
            description="Ending frame image URL for video_extension",
            required=False,
        ),
        ModelParameter(
            name="first_clip_url",
            type="string",
            description="Opening clip URL for video_extension (MP4, ≤3s)",
            required=False,
        ),
        ModelParameter(
            name="last_clip_url",
            type="string",
            description="Ending clip URL for video_extension (MP4, ≤3s)",
            required=False,
        ),
        ModelParameter(
            name="top_scale",
            type="number",
            description="Upward scaling ratio for video_outpainting (1.0-2.0)",
            required=False,
        ),
        ModelParameter(
            name="bottom_scale",
            type="number",
            description="Downward scaling ratio for video_outpainting (1.0-2.0)",
            required=False,
        ),
        ModelParameter(
            name="left_scale",
            type="number",
            description="Leftward scaling ratio for video_outpainting (1.0-2.0)",
            required=False,
        ),
        ModelParameter(
            name="right_scale",
            type="number",
            description="Rightward scaling ratio for video_outpainting (1.0-2.0)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Output resolution for image_reference/video_edit",
            required=False,
            default="1280*720",
            enum=["1280*720", "720*1280", "960*960", "832*1088", "1088*832"],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Output duration in seconds (fixed at 5)",
            required=False,
            enum=[5],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable prompt rewriting",
            required=False,
            default="false",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan21VacePlusGeneration(BaseModelEndpoint):
    """Wan2.1 VACE Plus video editing endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan21VacePlusGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.2-i2v-flash/generation.py
content: """Wan2.2 Image-to-Video Flash generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.2-i2v-flash",
    action="generation",
    provider="alibaba",
    model_name="wan2.2-i2v-flash",
    description="Generate videos from images using Wan2.2 Image-to-Video Flash model (50% faster)",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.2-i2v-flash/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Motion/story description (max 800 characters)",
            required=True,
        ),
        ModelParameter(
            name="image",
            type="string",
            description="First-frame image (URL or Base64). Supported formats: JPEG/JPG/PNG/BMP/WEBP, 360-2000px, ≤10MB",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Output resolution tier",
            required=False,
            default="720P",
            enum=["480P", "720P"],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (fixed at 5)",
            required=False,
            enum=[5],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan22I2VFlashGeneration(BaseModelEndpoint):
    """Wan2.2 Image-to-Video Flash generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan22I2VFlashGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.2-i2v-plus/generation.py
content: """Wan2.2 Image-to-Video Plus generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.2-i2v-plus",
    action="generation",
    provider="alibaba",
    model_name="wan2.2-i2v-plus",
    description="Generate videos from images using Wan2.2 Image-to-Video Plus model",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.2-i2v-plus/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Motion/story description (max 800 characters)",
            required=True,
        ),
        ModelParameter(
            name="image",
            type="string",
            description="First-frame image (URL or Base64). Supported formats: JPEG/JPG/PNG/BMP/WEBP, 360-2000px, ≤10MB",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Output resolution tier",
            required=False,
            default="1080P",
            enum=["480P", "1080P"],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (fixed at 5)",
            required=False,
            enum=[5],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan22I2VPlusGeneration(BaseModelEndpoint):
    """Wan2.2 Image-to-Video Plus generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan22I2VPlusGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.2-t2v-plus/generation.py
content: """Wan2.2 Text-to-Video Plus generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.2-t2v-plus",
    action="generation",
    provider="alibaba",
    model_name="wan2.2-t2v-plus",
    description="Generate videos from text prompts using Wan2.2 Text-to-Video Plus model",
    input_types=[InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.2-t2v-plus/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text prompt for the video (max 800 characters)",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Output resolution (width*height)",
            required=False,
            default="1920*1080",
            enum=[
                "832*480",
                "480*832",
                "624*624",
                "1920*1080",
                "1080*1920",
                "1440*1440",
                "1632*1248",
                "1248*1632",
            ],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (fixed at 5)",
            required=False,
            enum=[5],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan22T2VPlusGeneration(BaseModelEndpoint):
    """Wan2.2 Text-to-Video Plus generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan22T2VPlusGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.5-i2i-preview/generation.py
content: """Wan2.5 Image-to-Image Preview generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.5-i2i-preview",
    action="generation",
    provider="alibaba",
    model_name="wan2.5-i2i-preview",
    description="Edit images using Wan2.5 Image-to-Image Preview model with reference images",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/alibaba/v1/wan2.5-i2i-preview/generation",
    result_key="images",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description for the desired edit (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="images",
            type="array",
            description="Array of image URLs or Base64 encoded data (max 2 images). Supported formats: JPEG, JPG, PNG, BMP, WEBP. Dimensions: [384, 5000] pixels, Max: 10MB per image",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Output image resolution (width*height). Total pixels: [768*768, 1440*1440], aspect ratio: [1:4, 4:1]",
            required=False,
            default="1280*1280",
        ),
        ModelParameter(
            name="n",
            type="integer",
            description="Number of images to generate (1-4)",
            required=False,
            default=4,
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting (improves short prompts but increases processing time)",
            required=False,
            default="false",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan25I2IPreviewGeneration(BaseModelEndpoint):
    """Wan2.5 Image-to-Image Preview generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan25I2IPreviewGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.5-i2v-preview/generation.py
content: """Wan2.5 Image-to-Video Preview generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.5-i2v-preview",
    action="generation",
    provider="alibaba",
    model_name="wan2.5-i2v-preview",
    description="Generate videos from images using Wan2.5 Image-to-Video Preview model",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.5-i2v-preview/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Motion/story description for the video (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="image",
            type="string",
            description="First-frame image (URL or Base64). Supported formats: JPEG/JPG/PNG/BMP/WEBP, 360-2000px, ≤10MB",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Output resolution tier",
            required=False,
            default="720P",
            enum=["480P", "720P", "1080P"],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (5 or 10)",
            required=False,
            enum=[5, 10],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting for better detail",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="audio",
            type="boolean",
            description="Enable automatic audio generation",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="audio_url",
            type="string",
            description="Custom audio file URL (wav/mp3, 3-30s, ≤15MB)",
            required=False,
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan25I2VPreviewGeneration(BaseModelEndpoint):
    """Wan2.5 Image-to-Video Preview generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan25I2VPreviewGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.5-t2i-preview/generation.py
content: """Wan2.5 Text-to-Image Preview generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.5-t2i-preview",
    action="generation",
    provider="alibaba",
    model_name="wan2.5-t2i-preview",
    description="Generate images from text prompts using Wan2.5 Text-to-Image Preview model",
    input_types=[InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/alibaba/v1/wan2.5-t2i-preview/generation",
    result_key="images",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description for the image (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Image resolution (width*height). Total pixels: [768*768, 1440*1440], aspect ratio: [1:4, 4:1]",
            required=False,
            default="1280*1280",
        ),
        ModelParameter(
            name="n",
            type="integer",
            description="Number of images to generate (1-4)",
            required=False,
            default=4,
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting (improves short prompts but increases processing time)",
            required=False,
            default="false",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan25T2IPreviewGeneration(BaseModelEndpoint):
    """Wan2.5 Text-to-Image Preview generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan25T2IPreviewGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.5-t2v-preview/generation.py
content: """Wan2.5 Text-to-Video Preview generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.5-t2v-preview",
    action="generation",
    provider="alibaba",
    model_name="wan2.5-t2v-preview",
    description="Generate videos from text prompts using Wan2.5 Text-to-Video Preview model",
    input_types=[InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.5-t2v-preview/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description for video content (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Output resolution (width*height)",
            required=False,
            default="1280*720",
            enum=[
                "832*480",
                "480*832",
                "624*624",
                "1280*720",
                "720*1280",
                "960*960",
                "1088*832",
                "832*1088",
                "1920*1080",
                "1080*1920",
                "1440*1440",
                "1632*1248",
                "1248*1632",
            ],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (5 or 10)",
            required=False,
            enum=[5, 10],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting for better detail",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="audio",
            type="boolean",
            description="Enable automatic audio generation",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="audio_url",
            type="string",
            description="Custom audio file URL (wav/mp3, 3-30s, ≤15MB)",
            required=False,
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan25T2VPreviewGeneration(BaseModelEndpoint):
    """Wan2.5 Text-to-Video Preview generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan25T2VPreviewGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.6-i2v/__init__.py
content: """Wan2.6 Image-to-Video model."""

from .generation import ENDPOINT, Wan26I2VGeneration, main

__all__ = ["ENDPOINT", "Wan26I2VGeneration", "main"]


path: models/alibaba/wan2.6-i2v/generation.py
content: """Wan2.6 Image-to-Video generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.6-i2v",
    action="generation",
    provider="alibaba",
    model_name="wan2.6-i2v",
    description="Generate videos from images using Wan2.6 Image-to-Video model",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.6-i2v/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Motion/story description for the video (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="image",
            type="string",
            description="First-frame image (URL or Base64). Supported: JPEG/JPG/PNG/BMP/WEBP, 360-2000px, ≤10MB",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Output resolution tier",
            required=False,
            default="720P",
            enum=["480P", "720P", "1080P"],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (5, 10, or 15)",
            required=False,
            default=5,
            enum=[5, 10, 15],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="multi_shots",
            type="string",
            description="Shot type: single or multi",
            required=False,
            default="single",
            enum=["single", "multi"],
        ),
        ModelParameter(
            name="audio",
            type="boolean",
            description="Enable automatic audio generation",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="audio_url",
            type="string",
            description="Custom audio file URL (wav/mp3, 3-30s, ≤15MB)",
            required=False,
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

register_endpoint(ENDPOINT)


class Wan26I2VGeneration(BaseModelEndpoint):
    """Wan2.6 Image-to-Video generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan26I2VGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.6-image/__init__.py
content: """Wan2.6 Image model."""

from .generation import ENDPOINT, Wan26ImageGeneration, main

__all__ = ["ENDPOINT", "Wan26ImageGeneration", "main"]


path: models/alibaba/wan2.6-image/generation.py
content: """Wan2.6 Image editing/generation endpoint (Image-to-Image)."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.6-image",
    action="generation",
    provider="alibaba",
    model_name="wan2.6-image",
    description="Edit/generate images from input images using Wan2.6 Image model",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/alibaba/v1/wan2.6-image/generation",
    result_key="images",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description for the image editing (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="images",
            type="array",
            description="Input images (URLs or Base64). Supported: JPEG/JPG/PNG/BMP/WEBP",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Output resolution (width*height)",
            required=False,
            default="1280*1280",
        ),
        ModelParameter(
            name="n",
            type="integer",
            description="Number of images to generate (1-4)",
            required=False,
            default=1,
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

register_endpoint(ENDPOINT)


class Wan26ImageGeneration(BaseModelEndpoint):
    """Wan2.6 Image editing endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan26ImageGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.6-t2i/__init__.py
content: """Wan2.6 Text-to-Image model."""

from .generation import ENDPOINT, Wan26T2IGeneration, main

__all__ = ["ENDPOINT", "Wan26T2IGeneration", "main"]


path: models/alibaba/wan2.6-t2i/generation.py
content: """Wan2.6 Text-to-Image generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.6-t2i",
    action="generation",
    provider="alibaba",
    model_name="wan2.6-t2i",
    description="Generate images from text prompts using Wan2.6 Text-to-Image model",
    input_types=[InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/alibaba/v1/wan2.6-t2i/generation",
    result_key="images",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description for the image (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Image resolution (width*height). Range: 768*768 to 1440*1440, aspect ratio 1:4 to 4:1",
            required=False,
            default="1280*1280",
        ),
        ModelParameter(
            name="n",
            type="integer",
            description="Number of images to generate (1-4)",
            required=False,
            default=1,
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting",
            required=False,
            default="false",
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

register_endpoint(ENDPOINT)


class Wan26T2IGeneration(BaseModelEndpoint):
    """Wan2.6 Text-to-Image generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan26T2IGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/alibaba/wan2.6-t2v/__init__.py
content: """Wan2.6 Text-to-Video model."""

from .generation import ENDPOINT, Wan26T2VGeneration, main

__all__ = ["ENDPOINT", "Wan26T2VGeneration", "main"]


path: models/alibaba/wan2.6-t2v/generation.py
content: """Wan2.6 Text-to-Video generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="alibaba/wan2.6-t2v",
    action="generation",
    provider="alibaba",
    model_name="wan2.6-t2v",
    description="Generate videos from text prompts using Wan2.6 Text-to-Video model",
    input_types=[InputType.TEXT],
    output_type=OutputType.VIDEO,
    api_path="/vendors/alibaba/v1/wan2.6-t2v/generation",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description for video content (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Negative prompt describing unwanted content (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Output resolution (width*height)",
            required=False,
            default="1280*720",
            enum=[
                "832*480",
                "480*832",
                "624*624",
                "1280*720",
                "720*1280",
                "960*960",
                "1088*832",
                "832*1088",
                "1920*1080",
                "1080*1920",
                "1440*1440",
                "1632*1248",
                "1248*1632",
            ],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (5, 10, or 15)",
            required=False,
            default=5,
            enum=[5, 10, 15],
        ),
        ModelParameter(
            name="prompt_extend",
            type="boolean",
            description="Enable intelligent prompt rewriting for better detail",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="multi_shots",
            type="string",
            description="Shot type: single (default) or multi",
            required=False,
            default="single",
            enum=["single", "multi"],
        ),
        ModelParameter(
            name="audio",
            type="boolean",
            description="Enable automatic audio generation",
            required=False,
            default="true",
        ),
        ModelParameter(
            name="audio_url",
            type="string",
            description="Custom audio file URL (wav/mp3, 3-30s, ≤15MB)",
            required=False,
        ),
        ModelParameter(
            name="seed",
            type="integer",
            description="Random seed for reproducibility (0-2147483647)",
            required=False,
        ),
    ],
)

# Register with global registry
register_endpoint(ENDPOINT)


class Wan26T2VGeneration(BaseModelEndpoint):
    """Wan2.6 Text-to-Video generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Wan26T2VGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/base.py
content: """Base module for model endpoint implementations."""

import argparse
import json
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_root_dir = _file_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import (
    APIClient,
    ModelEndpoint,
    TaskResult,
    create_and_poll_task,
    enhance_image_param_description,
    load_config,
    process_image_params,
    register_endpoint,
)


class BaseModelEndpoint(ABC):
    """Base class for model endpoint implementations.

    Provides common functionality for:
    - CLI argument parsing
    - Configuration loading
    - Parameter listing
    - Task execution and polling
    """

    @property
    @abstractmethod
    def endpoint_info(self) -> ModelEndpoint:
        """Return the ModelEndpoint registration info."""
        pass

    def get_parser(self) -> argparse.ArgumentParser:
        """Create argument parser for this endpoint.

        Returns:
            Configured ArgumentParser
        """
        info = self.endpoint_info
        parser = argparse.ArgumentParser(
            description=info.description,
            formatter_class=argparse.RawDescriptionHelpFormatter,
        )

        # Meta options
        parser.add_argument(
            "--list-params",
            action="store_true",
            help="List supported parameters and exit",
        )
        parser.add_argument(
            "--json",
            action="store_true",
            help="Output results as JSON",
        )

        # Config options
        parser.add_argument("--api-key", help="API key (overrides environment)")
        parser.add_argument(
            "--base-url",
            help="API base URL (overrides environment and site)",
        )
        parser.add_argument(
            "--site",
            choices=["mulerouter", "mulerun"],
            help="API site (overrides environment, ignored if --base-url is set)",
        )

        # Task options
        parser.add_argument(
            "--no-wait",
            action="store_true",
            help="Don't wait for task completion, return task ID immediately",
        )
        parser.add_argument(
            "--poll-interval",
            type=float,
            default=20.0,
            help="Polling interval in seconds (default: 5.0)",
        )
        parser.add_argument(
            "--max-wait",
            type=float,
            default=600.0,
            help="Maximum wait time in seconds (default: 600.0)",
        )
        parser.add_argument(
            "--quiet",
            action="store_true",
            help="Suppress progress output",
        )

        # Add model-specific parameters
        self._add_model_parameters(parser)

        # Allow arbitrary extra parameters for flexibility
        parser.add_argument(
            "--extra",
            type=str,
            action="append",
            metavar="KEY=VALUE",
            help="Additional parameters as key=value pairs",
        )

        return parser

    def _add_model_parameters(self, parser: argparse.ArgumentParser) -> None:
        """Add model-specific parameters to parser.

        Override in subclasses to add custom parameters.

        Args:
            parser: ArgumentParser to add parameters to
        """
        info = self.endpoint_info
        for param in info.parameters:
            arg_name = f"--{param.name.replace('_', '-')}"
            kwargs: dict[str, Any] = {"help": param.description}

            if param.type == "boolean":
                if param.default is True:
                    parser.add_argument(
                        f"--no-{param.name.replace('_', '-')}",
                        dest=param.name,
                        action="store_false",
                        help=f"Disable {param.name}",
                    )
                else:
                    parser.add_argument(
                        arg_name,
                        action="store_true",
                        help=param.description,
                    )
            else:
                if param.type == "integer":
                    kwargs["type"] = int
                elif param.type == "number":
                    kwargs["type"] = float
                elif param.type == "array":
                    # For array types, accept string and parse as JSON later
                    kwargs["type"] = str

                if param.enum:
                    kwargs["choices"] = param.enum
                if param.default is not None:
                    kwargs["default"] = param.default
                # Note: Don't set required=True here - we validate manually in run()
                # This allows --list-params to work without providing required params

                parser.add_argument(arg_name, **kwargs)

    def list_parameters(self, as_json: bool = False) -> str:
        """Get formatted parameter documentation.

        Args:
            as_json: Return as JSON if True

        Returns:
            Formatted parameter documentation
        """
        info = self.endpoint_info
        params = [p.to_dict() for p in info.parameters]

        if as_json:
            return json.dumps(
                {
                    "model_id": info.model_id,
                    "action": info.action,
                    "api_path": info.api_path,
                    "description": info.description,
                    "parameters": params,
                },
                indent=2,
            )

        lines = [
            f"Model: {info.model_id}",
            f"Action: {info.action}",
            f"API Path: {info.api_path}",
            f"Description: {info.description}",
            "",
            "Parameters:",
            "-" * 60,
        ]

        for p in info.parameters:
            req = "(required)" if p.required else "(optional)"
            default = f" [default: {p.default}]" if p.default is not None else ""
            lines.append(f"  --{p.name.replace('_', '-')} {req}{default}")
            lines.append(f"      Type: {p.type}")
            # Enhance image param descriptions with local file path hint
            desc = enhance_image_param_description(p.name, p.description)
            lines.append(f"      {desc}")
            if p.enum:
                lines.append(f"      Choices: {', '.join(str(e) for e in p.enum)}")
            lines.append("")

        return "\n".join(lines)

    def build_request_body(self, args: argparse.Namespace) -> dict:
        """Build request body from parsed arguments.

        Args:
            args: Parsed arguments

        Returns:
            Request body dictionary
        """
        info = self.endpoint_info
        body: dict[str, Any] = {}

        # Add parameters from model definition
        for param in info.parameters:
            value = getattr(args, param.name, None)
            if value is not None:
                # Parse array types as JSON
                if param.type == "array" and isinstance(value, str):
                    try:
                        value = json.loads(value)
                    except json.JSONDecodeError as e:
                        raise ValueError(f"Invalid JSON for parameter '{param.name}': {e}")
                body[param.name] = value

        # Add extra parameters
        if args.extra:
            for extra in args.extra:
                if "=" in extra:
                    key, value = extra.split("=", 1)
                    # Try to parse as JSON for complex types
                    try:
                        body[key] = json.loads(value)
                    except json.JSONDecodeError:
                        body[key] = value

        return body

    def format_result(self, result: TaskResult, as_json: bool = False) -> str:
        """Format task result for output.

        Args:
            result: TaskResult to format
            as_json: Output as JSON if True

        Returns:
            Formatted result string
        """
        if as_json:
            return json.dumps(
                {
                    "task_id": result.task_id,
                    "status": result.status.value,
                    "results": result.results,
                    "error": result.error,
                },
                indent=2,
            )

        lines = [f"Task ID: {result.task_id}", f"Status: {result.status.value}"]

        if result.error:
            lines.append(f"Error: {result.error}")

        if result.results:
            lines.append(f"\nResults ({result.result_key}):")
            for i, url in enumerate(result.results, 1):
                lines.append(f"  {i}. {url}")

        return "\n".join(lines)

    def run(self, args: list[str] | None = None) -> int:
        """Run the endpoint CLI.

        Args:
            args: Command line arguments (defaults to sys.argv[1:])

        Returns:
            Exit code (0 for success, 1 for failure)
        """
        parser = self.get_parser()
        parsed = parser.parse_args(args)

        # Handle --list-params
        if parsed.list_params:
            print(self.list_parameters(as_json=parsed.json))
            return 0

        # Load configuration
        try:
            config = load_config(
                [REDACTED_SECRET],
                site=parsed.site,
                base_url=getattr(parsed, "base_url", None),
            )
        except ValueError as e:
            print(f"Configuration error: {e}", file=sys.stderr)
            return 1

        # Build request body
        body = self.build_request_body(parsed)

        # Convert local file paths to base64 for image parameters
        body = process_image_params(body)

        # Validate required parameters
        info = self.endpoint_info
        for param in info.parameters:
            if param.required and param.name not in body:
                print(f"Error: --{param.name.replace('_', '-')} is required", file=sys.stderr)
                return 1

        # Execute request
        with APIClient(config) as client:
            if parsed.no_wait:
                # Just create task and return ID
                response = client.post(info.api_path, json=body)
                if not response.success:
                    print(f"Error: {response.error}", file=sys.stderr)
                    return 1

                task_id = (response.data or {}).get("task_info", {}).get("id", "")
                if parsed.json:
                    print(json.dumps({"task_id": task_id}))
                else:
                    print(f"Task ID: {task_id}")
                return 0

            # Create and poll task
            result = create_and_poll_task(
                client=client,
                endpoint_path=info.api_path,
                request_body=body,
                result_key=info.result_key,
                interval=parsed.poll_interval,
                max_wait=parsed.max_wait,
                verbose=not parsed.quiet,
            )

            print(self.format_result(result, as_json=parsed.json))

            return 0 if result.results else 1


def create_endpoint_module(endpoint: ModelEndpoint) -> type[BaseModelEndpoint]:
    """Create a model endpoint class from ModelEndpoint definition.

    This is a factory function that creates endpoint classes dynamically.

    Args:
        endpoint: ModelEndpoint definition

    Returns:
        Endpoint class
    """
    # Register with global registry
    register_endpoint(endpoint)

    class GeneratedEndpoint(BaseModelEndpoint):
        @property
        def endpoint_info(self) -> ModelEndpoint:
            return endpoint

    return GeneratedEndpoint


path: models/google/__init__.py
content: """Google models.

This package imports all Google model endpoints to register them with the global registry.
"""

import contextlib
import importlib.util
from pathlib import Path

# Get the directory of this file
_package_dir = Path(__file__).parent

# List of model files to import
_model_files = [
    "nano-banana-pro/generation.py",
    "nano-banana-pro/edit.py",
    "nano-banana/generation.py",
    "nano-banana/edit.py",
    "veo3/generation.py",
]


def _import_model_file(model_file: str) -> None:
    """Import a model file to register its endpoints."""
    file_path = _package_dir / model_file

    if not file_path.exists():
        return

    module_name = model_file.replace("/", "_").replace("-", "_").replace(".", "_")
    spec = importlib.util.spec_from_file_location(
        f"models.google.{module_name}",
        file_path,
    )
    if spec and spec.loader:
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)


# Import each model file to register endpoints
for _model_file in _model_files:
    with contextlib.suppress(Exception):
        _import_model_file(_model_file)


path: models/google/nano-banana-pro/__init__.py
content: """Nano Banana Pro model endpoints."""

from .edit import ENDPOINT as EDIT_ENDPOINT
from .edit import NanoBananaProEdit
from .generation import ENDPOINT as GENERATION_ENDPOINT
from .generation import NanoBananaProGeneration

__all__ = [
    "GENERATION_ENDPOINT",
    "EDIT_ENDPOINT",
    "NanoBananaProGeneration",
    "NanoBananaProEdit",
]


path: models/google/nano-banana-pro/edit.py
content: """Nano Banana Pro Image-to-Image edit endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="google/nano-banana-pro",
    action="edit",
    provider="google",
    model_name="nano-banana-pro",
    description="Edit images based on text prompts using Gemini Nano-Banana Pro model",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/google/v1/nano-banana-pro/edit",
    result_key="images",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text prompt for image editing",
            required=True,
        ),
        ModelParameter(
            name="images",
            type="array",
            description="List of images to edit (URLs or base64). Min 1, max 10 images",
            required=True,
        ),
        ModelParameter(
            name="aspect_ratio",
            type="string",
            description="Aspect ratio of the edited image",
            required=False,
            default="1:1",
            enum=[
                "1:1",
                "2:3",
                "3:2",
                "3:4",
                "4:3",
                "4:5",
                "5:4",
                "9:16",
                "16:9",
                "21:9",
            ],
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Output resolution preset (longest side in pixels)",
            required=False,
            default="2K",
            enum=["1K", "2K"],
        ),
    ],
)

register_endpoint(ENDPOINT)


class NanoBananaProEdit(BaseModelEndpoint):
    """Nano Banana Pro image editing endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return NanoBananaProEdit().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/google/nano-banana-pro/generation.py
content: """Nano Banana Pro Text-to-Image generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="google/nano-banana-pro",
    action="generation",
    provider="google",
    model_name="nano-banana-pro",
    description="Generate ultra-sharp images from text prompts using Gemini Nano-Banana Pro model",
    input_types=[InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/google/v1/nano-banana-pro/generation",
    result_key="images",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text prompt for image generation",
            required=True,
        ),
        ModelParameter(
            name="aspect_ratio",
            type="string",
            description="Aspect ratio of the generated image",
            required=False,
            default="1:1",
            enum=[
                "1:1",
                "2:3",
                "3:2",
                "3:4",
                "4:3",
                "4:5",
                "5:4",
                "9:16",
                "16:9",
                "21:9",
            ],
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Output resolution preset (longest side in pixels)",
            required=False,
            default="2K",
            enum=["1K", "2K"],
        ),
    ],
)

register_endpoint(ENDPOINT)


class NanoBananaProGeneration(BaseModelEndpoint):
    """Nano Banana Pro text-to-image generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return NanoBananaProGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/google/nano-banana/__init__.py
content: """Nano Banana model endpoints."""

from .edit import ENDPOINT as EDIT_ENDPOINT
from .edit import NanoBananaEdit
from .generation import ENDPOINT as GENERATION_ENDPOINT
from .generation import NanoBananaGeneration

__all__ = [
    "GENERATION_ENDPOINT",
    "EDIT_ENDPOINT",
    "NanoBananaGeneration",
    "NanoBananaEdit",
]


path: models/google/nano-banana/edit.py
content: """Nano Banana Image-to-Image edit endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="google/nano-banana",
    action="edit",
    provider="google",
    model_name="nano-banana",
    description="Edit images based on text prompts using Gemini Nano-Banana model",
    input_types=[InputType.IMAGE, InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/google/v1/nano-banana/edit",
    result_key="images",
    available_on=["mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text prompt for image editing",
            required=True,
        ),
        ModelParameter(
            name="images",
            type="array",
            description="List of images to edit (URLs or base64). Min 1, max 10 images",
            required=True,
        ),
        ModelParameter(
            name="aspect_ratio",
            type="string",
            description="Aspect ratio of the edited image",
            required=False,
            default="1:1",
            enum=[
                "1:1",
                "2:3",
                "3:2",
                "3:4",
                "4:3",
                "4:5",
                "5:4",
                "9:16",
                "16:9",
            ],
        ),
    ],
)

register_endpoint(ENDPOINT)


class NanoBananaEdit(BaseModelEndpoint):
    """Nano Banana image editing endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return NanoBananaEdit().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/google/nano-banana/generation.py
content: """Nano Banana Text-to-Image generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="google/nano-banana",
    action="generation",
    provider="google",
    model_name="nano-banana",
    description="Generate images from text prompts using Gemini Nano-Banana model",
    input_types=[InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/google/v1/nano-banana/generation",
    result_key="images",
    available_on=["mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text prompt for image generation",
            required=True,
        ),
        ModelParameter(
            name="aspect_ratio",
            type="string",
            description="Aspect ratio of the generated image",
            required=False,
            default="1:1",
            enum=[
                "1:1",
                "2:3",
                "3:2",
                "3:4",
                "4:3",
                "4:5",
                "5:4",
                "9:16",
                "16:9",
            ],
        ),
    ],
)

register_endpoint(ENDPOINT)


class NanoBananaGeneration(BaseModelEndpoint):
    """Nano Banana text-to-image generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return NanoBananaGeneration().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/google/veo3/generation.py
content: """Google Veo 3 Text/Image-to-Video generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="google/veo3",
    action="generation",
    provider="google",
    model_name="veo3",
    description="Generate videos from text prompts or images using Google Veo 3 model",
    input_types=[InputType.TEXT, InputType.IMAGE],
    output_type=OutputType.VIDEO,
    api_path="/vendors/google/v1/veo/generation",
    result_key="videos",
    available_on=["mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Video description prompt (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="negative_prompt",
            type="string",
            description="Content to avoid in generation (max 500 characters)",
            required=False,
        ),
        ModelParameter(
            name="image",
            type="string",
            description="Initial frame image (URL or local path) for image-to-video generation",
            required=False,
        ),
        ModelParameter(
            name="last_frame",
            type="string",
            description="Last frame image (URL or local path) for keyframe interpolation",
            required=False,
        ),
        ModelParameter(
            name="reference_images",
            type="array",
            description="Reference images (URLs or local paths, max 3). Only supported by Veo 3.1",
            required=False,
        ),
        ModelParameter(
            name="model",
            type="string",
            description="Model version to use",
            required=False,
            default="veo-3.1",
            enum=["veo-3.1", "veo-3.1-fast", "veo-3"],
        ),
        ModelParameter(
            name="aspect_ratio",
            type="string",
            description="Video aspect ratio",
            required=False,
            default="16:9",
            enum=["16:9", "9:16"],
        ),
        ModelParameter(
            name="resolution",
            type="string",
            description="Video resolution",
            required=False,
            default="720p",
            enum=["720p", "1080p"],
        ),
        ModelParameter(
            name="duration",
            type="integer",
            description="Video duration in seconds (must be 8 when using reference_images)",
            required=False,
            default=8,
            enum=[4, 6, 8],
        ),
    ],
)

register_endpoint(ENDPOINT)


class Veo3Generation(BaseModelEndpoint):
    """Google Veo 3 video generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Veo3Generation().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/midjourney/__init__.py
content: """Midjourney models.

This package imports all Midjourney model endpoints to register them with the global registry.
"""

import contextlib
import importlib.util
from pathlib import Path

# Get the directory of this file
_package_dir = Path(__file__).parent

# List of model files to import
_model_files = [
    "diffusion/generation.py",
    "video/generation.py",
]


def _import_model_file(model_file: str) -> None:
    """Import a model file to register its endpoints."""
    file_path = _package_dir / model_file

    if not file_path.exists():
        return

    module_name = model_file.replace("/", "_").replace("-", "_").replace(".", "_")
    spec = importlib.util.spec_from_file_location(
        f"models.midjourney.{module_name}",
        file_path,
    )
    if spec and spec.loader:
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)


# Import each model file to register endpoints
for _model_file in _model_files:
    with contextlib.suppress(Exception):
        _import_model_file(_model_file)


path: models/midjourney/diffusion/generation.py
content: """Midjourney Text-to-Image generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="midjourney/diffusion",
    action="generation",
    provider="midjourney",
    model_name="diffusion",
    description="Generate images from text prompts using Midjourney",
    input_types=[InputType.TEXT],
    output_type=OutputType.IMAGE,
    api_path="/vendors/midjourney/v1/tob/diffusion",
    result_key="images",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Image description prompt in Midjourney format (max 8192 characters)",
            required=True,
        ),
    ],
)

register_endpoint(ENDPOINT)


class MidjourneyDiffusion(BaseModelEndpoint):
    """Midjourney image generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return MidjourneyDiffusion().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/midjourney/video/generation.py
content: """Midjourney Text/Image-to-Video generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="midjourney/video",
    action="generation",
    provider="midjourney",
    model_name="video",
    description="Generate videos from image with text description using Midjourney. Prompt must contain both text description and image URL (e.g., 'a cat running https://example.com/image.jpg')",
    input_types=[InputType.TEXT, InputType.IMAGE],
    output_type=OutputType.VIDEO,
    api_path="/vendors/midjourney/v1/tob/video-diffusion",
    result_key="videos",
    available_on=["mulerouter", "mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Text description with image URL (required). Format: 'description text https://example.com/image.jpg'. Max 8192 characters",
            required=True,
        ),
        ModelParameter(
            name="video_type",
            type="integer",
            description="Video quality: 0=480p, 1=720p",
            required=False,
            default=0,
            enum=[0, 1],
        ),
    ],
)

register_endpoint(ENDPOINT)


class MidjourneyVideo(BaseModelEndpoint):
    """Midjourney video generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return MidjourneyVideo().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: models/openai/__init__.py
content: """OpenAI models.

This package imports all OpenAI model endpoints to register them with the global registry.
"""

import contextlib
import importlib.util
from pathlib import Path

# Get the directory of this file
_package_dir = Path(__file__).parent

# List of model files to import
_model_files = [
    "sora2/generation.py",
]


def _import_model_file(model_file: str) -> None:
    """Import a model file to register its endpoints."""
    file_path = _package_dir / model_file

    if not file_path.exists():
        return

    module_name = model_file.replace("/", "_").replace("-", "_").replace(".", "_")
    spec = importlib.util.spec_from_file_location(
        f"models.openai.{module_name}",
        file_path,
    )
    if spec and spec.loader:
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)


# Import each model file to register endpoints
for _model_file in _model_files:
    with contextlib.suppress(Exception):
        _import_model_file(_model_file)


path: models/openai/sora2/generation.py
content: """OpenAI Sora 2 Text/Image-to-Video generation endpoint."""

import sys
from pathlib import Path

# Support both direct execution and package import
_file_dir = Path(__file__).parent
_models_dir = _file_dir.parent.parent
_root_dir = _models_dir.parent

if str(_root_dir) not in sys.path:
    sys.path.insert(0, str(_root_dir))

from core import InputType, ModelEndpoint, ModelParameter, OutputType, register_endpoint
from models.base import BaseModelEndpoint

# Define the endpoint
ENDPOINT = ModelEndpoint(
    model_id="openai/sora2",
    action="generation",
    provider="openai",
    model_name="sora2",
    description="Generate videos from text prompts or images using OpenAI Sora 2 model",
    input_types=[InputType.TEXT, InputType.IMAGE],
    output_type=OutputType.VIDEO,
    api_path="/vendors/openai/v1/sora/generation",
    result_key="videos",
    available_on=["mulerun"],
    parameters=[
        ModelParameter(
            name="prompt",
            type="string",
            description="Video description prompt (max 2000 characters)",
            required=True,
        ),
        ModelParameter(
            name="image",
            type="string",
            description="Initial frame image (URL or local path). Size must match the size parameter (1280x720 or 720x1280). Optional for text-to-video",
            required=False,
        ),
        ModelParameter(
            name="model",
            type="string",
            description="Model version to use",
            required=False,
            default="sora-2",
            enum=["sora-2"],
        ),
        ModelParameter(
            name="seconds",
            type="string",
            description="Video duration in seconds",
            required=False,
            default="8",
            enum=["4", "8", "12"],
        ),
        ModelParameter(
            name="size",
            type="string",
            description="Video size (1280x720=landscape, 720x1280=portrait)",
            required=False,
            default="1280x720",
            enum=["1280x720", "720x1280"],
        ),
    ],
)

register_endpoint(ENDPOINT)


class Sora2Generation(BaseModelEndpoint):
    """OpenAI Sora 2 video generation endpoint."""

    @property
    def endpoint_info(self) -> ModelEndpoint:
        return ENDPOINT


def main() -> int:
    """CLI entry point."""
    return Sora2Generation().run()


if __name__ == "__main__":
    raise SystemExit(main())


path: pyproject.toml
content: [project]
name = "mulerouter-skill"
version = "1.0.0"
description = "Agent Skill for MuleRouter / MuleRun multimodal API"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
    "httpx>=0.27.0",
    "python-dotenv>=1.0.0",
    "pydantic>=2.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0.0",
    "pytest-asyncio>=0.24.0",
    "ruff>=0.8.0",
    "respx>=0.22.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["core", "tests"]

[tool.ruff]
line-length = 100
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
# E402: Module level import not at top of file (needed for sys.path manipulation)
"models/**/*.py" = ["E402"]

[tool.ruff.format]
quote-style = "double"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]


path: references/MODELS.md
content: # Model Reference

## Discovering Models

Run `python scripts/list_models.py` to see available models:

```bash
# List all models
python scripts/list_models.py

# Filter by provider
python scripts/list_models.py --provider alibaba

# Filter by output type
python scripts/list_models.py --output-type video

# JSON output
python scripts/list_models.py --json
```

## Model Categories

### Text-to-Image (T2I)

| Model | Path | Description |
|-------|------|-------------|
| Wan2.6 T2I | `models/alibaba/wan2.6-t2i/generation.py` | Flagship image generation |
| Wan2.5 T2I Preview | `models/alibaba/wan2.5-t2i-preview/generation.py` | Preview model |
| Nano Banana Pro | `models/google/nano-banana-pro/generation.py` | High-resolution images |
| Midjourney Diffusion | `models/midjourney/diffusion/generation.py` | Midjourney image generation |

### Text-to-Video (T2V)

| Model | Path | Description |
|-------|------|-------------|
| Wan2.6 T2V | `models/alibaba/wan2.6-t2v/generation.py` | Flagship video generation |
| Wan2.5 T2V Preview | `models/alibaba/wan2.5-t2v-preview/generation.py` | Preview model |
| Wan2.2 T2V Plus | `models/alibaba/wan2.2-t2v-plus/generation.py` | Previous generation |
| Veo 3 | `models/google/veo3/generation.py` | Google Veo 3 video generation (mulerun only) |
| Sora 2 | `models/openai/sora2/generation.py` | OpenAI Sora 2 video generation (mulerun only) |

### Image-to-Video (I2V)

| Model | Path | Description |
|-------|------|-------------|
| Wan2.6 I2V | `models/alibaba/wan2.6-i2v/generation.py` | Flagship image animation |
| Wan2.5 I2V Preview | `models/alibaba/wan2.5-i2v-preview/generation.py` | Preview model |
| Wan2.2 I2V Plus | `models/alibaba/wan2.2-i2v-plus/generation.py` | High quality |
| Wan2.2 I2V Flash | `models/alibaba/wan2.2-i2v-flash/generation.py` | 50% faster |
| Veo 3 | `models/google/veo3/generation.py` | Google Veo 3 with image input (mulerun only) |
| Sora 2 | `models/openai/sora2/generation.py` | OpenAI Sora 2 with image input (mulerun only) |
| Midjourney Video | `models/midjourney/video/generation.py` | Image-to-video (prompt must include image URL) |

### Image-to-Image (I2I)

| Model | Path | Description |
|-------|------|-------------|
| Wan2.5 I2I Preview | `models/alibaba/wan2.5-i2i-preview/generation.py` | Image editing |
| Nano Banana Pro Edit | `models/google/nano-banana-pro/edit.py` | Image editing |

### Advanced Video Editing

| Model | Path | Description |
|-------|------|-------------|
| Wan2.6 Image | `models/alibaba/wan2.6-image/generation.py` | Image processing |
| Wan2.1 VACE Plus | `models/alibaba/wan2.1-vace-plus/generation.py` | Video outpainting |
| Wan2.1 KF2V Plus | `models/alibaba/wan2.1-kf2v-plus/generation.py` | Keyframe interpolation |

## Checking Parameters

Before calling a model, use `--list-params`:

```bash
python models/alibaba/wan2.6-t2v/generation.py --list-params
```

Example output:

```
Parameters for alibaba/wan2.6-t2v generation:

Required:
  --prompt TEXT          Text description for video content (max 2000 chars)

Optional:
  --negative-prompt TEXT Unwanted content description
  --size TEXT            Resolution: 1280*720 (default), 1920*1080, etc.
  --duration INT         Duration: 5 (default), 10, or 15 seconds
  --seed INT             Random seed for reproducibility
```

## Common Parameters

| Parameter | Type | Models | Description |
|-----------|------|--------|-------------|
| `--prompt` | string | All | Content description (required) |
| `--negative-prompt` | string | Most | Unwanted content |
| `--size` | string | Video | Resolution (width*height) |
| `--resolution` | string | Some | 720P, 1080P, 2K |
| `--duration` | int | Video | Length in seconds |
| `--seed` | int | All | Reproducibility seed |
| `--image` | string | I2V/I2I | Input image URL |


path: references/REFERENCE.md
content: # MuleRouter API Reference

## Configuration

### Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `MULEROUTER_API_KEY` | Yes | API key for authentication |
| `MULEROUTER_BASE_URL` | No* | Custom API base URL (takes priority over SITE) |
| `MULEROUTER_SITE` | No* | API site: `mulerouter` or `mulerun` |

*Either `MULEROUTER_BASE_URL` or `MULEROUTER_SITE` must be set.

### .env File Example

```env
# Option 1: Use custom base URL (takes priority)
MULEROUTER_BASE_URL=https://api.mulerouter.ai
MULEROUTER_API_KEY=your-api-key

# Option 2: Use site (if BASE_URL not set)
# MULEROUTER_SITE=mulerun
# MULEROUTER_API_KEY=your-api-key
```

## CLI Options

All model scripts support these options:

| Option | Description |
|--------|-------------|
| `--list-params` | Show available parameters and exit |
| `--json` | Output results as JSON |
| `--no-wait` | Return task ID immediately without polling |
| `--poll-interval N` | Polling interval in seconds (default: 5) |
| `--max-wait N` | Maximum wait time in seconds (default: 600) |
| `--quiet` | Suppress progress output |
| `--base-url URL` | Override API base URL (takes priority over --site) |
| `--site SITE` | Override API site (mulerouter/mulerun) |
| `--api-key KEY` | Override API key |

## Task Workflow

All generation tasks are asynchronous:

1. **Create Task**: POST request returns a task ID
2. **Poll Status**: GET request checks task status (pending -> processing -> completed/failed)
3. **Get Results**: Completed tasks include URLs to generated images/videos

The scripts handle polling automatically. Use `--no-wait` for manual control.

## API Sites

| Site | Base URL | Notes |
|------|----------|-------|
| MuleRouter | `api.mulerouter.ai` | Full model catalog |
| MuleRun | `api.mulerun.com` | Full model catalog |

Both sites share the same API format. Model availability may differ between sites.

## Error Handling

Common error responses:

| Code | Meaning | Solution |
|------|---------|----------|
| 401 | Invalid API key | Check MULEROUTER_API_KEY |
| 400 | Invalid parameters | Run `--list-params` to see valid options |
| 429 | Rate limited | Wait and retry |
| 500 | Server error | Retry after a few seconds |

## Output Format

### JSON Output (--json)

```json
{
  "task_id": "2227246C-760C-4167-906C-DD727D7BBBEC",
  "status": "completed",
  "videos": ["https://..."],
  "images": ["https://..."]
}
```

### Default Output

```
Task ID: 2227246C-760C-4167-906C-DD727D7BBBEC
Status: completed
Result URLs:
  - https://...
```


path: scripts/list_models.py
content: """List available models from the registry.

Usage:
    python scripts/list_models.py [--provider PROVIDER] [--output-type TYPE] [--json]

Note: Models are automatically filtered based on MULEROUTER_SITE environment variable.
"""

import argparse
import importlib.util
import json
import sys
from pathlib import Path

# Add parent directory to path for imports
_script_dir = Path(__file__).parent
_root_dir = _script_dir.parent
_models_dir = _root_dir / "models"
sys.path.insert(0, str(_root_dir))

from core import OutputType, get_site_from_env, load_env_file, registry


def _import_provider_models() -> None:
    """Import all provider model packages to register endpoints."""
    providers = ["alibaba", "google", "midjourney", "openai"]

    for provider in providers:
        provider_dir = _models_dir / provider
        init_file = provider_dir / "__init__.py"

        if not init_file.exists():
            continue

        spec = importlib.util.spec_from_file_location(
            f"models.{provider}",
            init_file,
        )
        if spec and spec.loader:
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)


# Import all models to ensure they're registered
_import_provider_models()


def list_models(
    site: str | None = None,
    provider: str | None = None,
    output_type: str | None = None,
) -> list[dict]:
    """List available models with optional filtering.

    Args:
        site: Filter by API site (mulerouter/mulerun)
        provider: Filter by provider (alibaba/google)
        output_type: Filter by output type (image/video/text)

    Returns:
        List of model endpoint dictionaries
    """
    endpoints = registry.list_all()

    # Apply filters
    if site:
        endpoints = [e for e in endpoints if site in e.available_on]

    if provider:
        endpoints = [e for e in endpoints if e.provider == provider]

    if output_type:
        try:
            ot = OutputType(output_type)
            endpoints = [e for e in endpoints if e.output_type == ot]
        except ValueError:
            pass

    return [e.to_dict() for e in endpoints]


def format_models_text(models: list[dict], site: str | None = None) -> str:
    """Format models as human-readable text.

    Args:
        models: List of model dictionaries
        site: Current site for display

    Returns:
        Formatted text output
    """
    if not models:
        return "No models found."

    header = "Available Models"
    if site:
        header += f" ({site})"

    lines = [
        header,
        "=" * 60,
    ]

    # Group by provider
    by_provider: dict[str, list[dict]] = {}
    for m in models:
        provider = m["provider"]
        if provider not in by_provider:
            by_provider[provider] = []
        by_provider[provider].append(m)

    for provider, provider_models in sorted(by_provider.items()):
        lines.append(f"\n[{provider.upper()}]")
        lines.append("-" * 40)

        for m in sorted(provider_models, key=lambda x: (x["model_id"], x["action"])):
            lines.append(f"\n  {m['model_id']}/{m['action']}")
            lines.append(f"    Description: {m['description']}")
            lines.append(f"    Output: {m['output_type']}")

    lines.append("")
    lines.append(f"Total: {len(models)} endpoint(s)")

    return "\n".join(lines)


def main() -> int:
    """CLI entry point."""
    # Load environment first
    load_env_file()

    parser = argparse.ArgumentParser(
        description="List available MuleRouter models",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--provider",
        help="Filter by provider (e.g., alibaba, google)",
    )
    parser.add_argument(
        "--output-type",
        choices=["image", "video", "text", "audio"],
        help="Filter by output type",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Output as JSON",
    )
    parser.add_argument(
        "--providers",
        action="store_true",
        help="List available providers only",
    )

    args = parser.parse_args()

    # Get site from environment
    site_enum = get_site_from_env()
    site = site_enum.value if site_enum else None

    if not site:
        print(
            "Warning: MULEROUTER_SITE not set. Showing all models.\n"
            "Set MULEROUTER_SITE=mulerouter or MULEROUTER_SITE=mulerun to filter.\n",
            file=sys.stderr,
        )

    # If providers flag, just list providers
    if args.providers:
        providers = registry.get_providers()
        if args.json:
            print(json.dumps({"providers": providers}, indent=2))
        else:
            print("Available Providers:")
            for p in providers:
                print(f"  - {p}")
        return 0

    # List models (filtered by site if set)
    models = list_models(
        site=site,
        provider=args.provider,
        output_type=args.output_type,
    )

    if args.json:
        print(json.dumps({"models": models, "site": site}, indent=2))
    else:
        print(format_models_text(models, site))

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: tests/__init__.py
content: """Test package for MuleRouter skill."""


path: tests/test_client.py
content: """Tests for core.client module."""

import httpx
import pytest
import respx

from core.client import APIClient, APIResponse
from core.config import Config, Site


@pytest.fixture
def config() -> Config:
    """Create test config."""
    return Config([REDACTED_SECRET]", site=Site.MULEROUTER)


@pytest.fixture
def client(config: Config) -> APIClient:
    """Create test API client."""
    return APIClient(config)


class TestAPIResponse:
    """Tests for APIResponse dataclass."""

    def test_success_response(self) -> None:
        """Test successful response."""
        response = APIResponse(
            success=True,
            data={"task_info": {"id": "123"}},
            status_code=200,
        )
        assert response.success is True
        assert response.data == {"task_info": {"id": "123"}}
        assert response.error is None

    def test_error_response(self) -> None:
        """Test error response."""
        response = APIResponse(
            success=False,
            error="Not found",
            status_code=404,
        )
        assert response.success is False
        assert response.error == "Not found"


class TestAPIClient:
    """Tests for APIClient class."""

    def test_client_initialization(self, client: APIClient, config: Config) -> None:
        """Test client initialization."""
        assert client.config == config
        assert client._client is None  # Lazy initialization

    def test_client_headers(self, client: APIClient) -> None:
        """Test that client has proper headers."""
        http_client = client.client
        assert "Authorization" in http_client.headers
        assert http_client.headers["Authorization"] == "Bearer test-api-key"
        assert "User-Agent" in http_client.headers
        assert "MuleRouter-AgentSkill" in http_client.headers["User-Agent"]
        assert http_client.headers["X-Agent-Skills"] == "mulerouter"

    def test_client_context_manager(self, config: Config) -> None:
        """Test client context manager."""
        with APIClient(config) as client:
            assert client._client is None
            _ = client.client  # Initialize
            assert client._client is not None
        assert client._client is None

    @respx.mock
    def test_post_success(self, client: APIClient) -> None:
        """Test successful POST request."""
        respx.post("https://api.mulerouter.ai/test").mock(
            return_value=httpx.Response(
                200,
                json={"task_info": {"id": "123", "status": "pending"}},
            )
        )

        response = client.post("/test", json={"prompt": "test"})
        assert response.success is True
        assert response.data["task_info"]["id"] == "123"

    @respx.mock
    def test_get_success(self, client: APIClient) -> None:
        """Test successful GET request."""
        respx.get("https://api.mulerouter.ai/test/123").mock(
            return_value=httpx.Response(
                200,
                json={"task_info": {"id": "123", "status": "completed"}},
            )
        )

        response = client.get("/test/123")
        assert response.success is True
        assert response.data["task_info"]["status"] == "completed"

    @respx.mock
    def test_error_response_handling(self, client: APIClient) -> None:
        """Test error response handling."""
        respx.post("https://api.mulerouter.ai/test").mock(
            return_value=httpx.Response(
                400,
                json={"detail": "Invalid prompt"},
            )
        )

        response = client.post("/test", json={})
        assert response.success is False
        assert response.error == "Invalid prompt"
        assert response.status_code == 400

    @respx.mock
    def test_traceparent_header(self, client: APIClient) -> None:
        """Test traceparent header extraction."""
        respx.get("https://api.mulerouter.ai/test").mock(
            return_value=httpx.Response(
                200,
                json={},
                headers={"traceparent": "00-trace-id-123"},
            )
        )

        response = client.get("/test")
        assert response.traceparent == "00-trace-id-123"


path: tests/test_config.py
content: """Tests for core.config module."""

import os
from unittest.mock import patch

import pytest

from core.config import Config, Site, get_config_help, load_config


class TestConfig:
    """Tests for Config dataclass."""

    def test_config_mulerouter_site(self) -> None:
        """Test Config with mulerouter site."""
        config = Config([REDACTED_SECRET]", site=Site.MULEROUTER)
        assert config.base_url == "https://api.mulerouter.ai"
        assert config.api_key == "test-key"
        assert config.site == Site.MULEROUTER

    def test_config_mulerun_site(self) -> None:
        """Test Config with mulerun site."""
        config = Config([REDACTED_SECRET]", site=Site.MULERUN)
        assert config.base_url == "https://api.mulerun.com"
        assert config.site == Site.MULERUN

    def test_config_explicit_base_url(self) -> None:
        """Test Config with explicit base_url (takes priority over site)."""
        config = Config([REDACTED_SECRET]", base_url="https://custom.api.com")
        assert config.base_url == "https://custom.api.com"
        assert config.site is None

    def test_config_base_url_overrides_site(self) -> None:
        """Test that explicit base_url overrides site."""
        config = Config([REDACTED_SECRET]", site=Site.MULEROUTER, base_url="https://custom.api.com")
        assert config.base_url == "https://custom.api.com"

    def test_config_defaults(self) -> None:
        """Test Config default values."""
        config = Config([REDACTED_SECRET]", site=Site.MULEROUTER)
        assert config.timeout == 120.0
        assert config.max_retries == 3

    def test_config_missing_both_site_and_base_url(self) -> None:
        """Test Config raises error when both site and base_url are missing."""
        with pytest.raises(ValueError, match="Either base_url or site must be provided"):
            Config([REDACTED_SECRET]")


class TestLoadConfig:
    """Tests for load_config function."""

    def test_load_config_explicit_api_key(self) -> None:
        """Test loading config with explicit API key."""
        config = load_config([REDACTED_SECRET]", site="mulerouter")
        assert config.api_key == "explicit-key"

    def test_load_config_from_env(self) -> None:
        """Test loading config from environment variables."""
        with patch.dict(
            os.environ,
            {"MULEROUTER_API_KEY": "env-key", "MULEROUTER_SITE": "mulerouter"},
            clear=True,
        ):
            config = load_config()
            assert config.api_key == "env-key"
            assert config.site == Site.MULEROUTER

    def test_load_config_explicit_site(self) -> None:
        """Test loading config with explicit site."""
        config = load_config(api_key="key", site="mulerun")
        assert config.site == Site.MULERUN

    def test_load_config_env_site(self) -> None:
        """Test loading config from MULEROUTER_SITE env var."""
        with patch.dict(
            os.environ,
            {"MULEROUTER_API_KEY": "key", "MULEROUTER_SITE": "mulerun"},
            clear=True,
        ):
            config = load_config()
            assert config.site == Site.MULERUN

    def test_load_config_explicit_base_url(self) -> None:
        """Test loading config with explicit base_url."""
        config = load_config(api_key="key", base_url="https://custom.api.com")
        assert config.base_url == "https://custom.api.com"
        assert config.site is None

    def test_load_config_env_base_url(self) -> None:
        """Test loading config from MULEROUTER_BASE_URL env var."""
        with patch.dict(
            os.environ,
            {"MULEROUTER_API_KEY": "key", "MULEROUTER_BASE_URL": "https://custom.api.com"},
            clear=True,
        ):
            config = load_config()
            assert config.base_url == "https://custom.api.com"
            assert config.site is None

    def test_load_config_base_url_priority_over_site(self) -> None:
        """Test that MULEROUTER_BASE_URL takes priority over MULEROUTER_SITE."""
        with patch.dict(
            os.environ,
            {
                "MULEROUTER_API_KEY": "key",
                "MULEROUTER_BASE_URL": "https://custom.api.com",
                "MULEROUTER_SITE": "mulerun",
            },
            clear=True,
        ):
            config = load_config()
            assert config.base_url == "https://custom.api.com"
            # site should be None because base_url takes priority
            assert config.site is None

    def test_load_config_missing_api_key(self) -> None:
        """Test that missing API key raises ValueError."""
        with (
            patch.dict(os.environ, {"MULEROUTER_SITE": "mulerouter"}, clear=True),
            pytest.raises(ValueError, match="API key not found"),
        ):
            load_config()

    def test_load_config_missing_both_site_and_base_url(self) -> None:
        """Test that missing both site and base_url raises ValueError."""
        with (
            patch.dict(os.environ, {"MULEROUTER_API_KEY": "key"}, clear=True),
            pytest.raises(ValueError, match="Configuration not specified"),
        ):
            load_config()

    def test_load_config_invalid_site(self) -> None:
        """Test that invalid site raises ValueError."""
        with pytest.raises(ValueError, match="Invalid site"):
            load_config(api_key="key", site="invalid")


class TestGetConfigHelp:
    """Tests for get_config_help function."""

    def test_get_config_help_content(self) -> None:
        """Test that config help contains expected content."""
        help_text = get_config_help()
        assert "MULEROUTER_API_KEY" in help_text
        assert "MULEROUTER_SITE" in help_text
        assert "MULEROUTER_BASE_URL" in help_text
        assert ".env" in help_text


path: tests/test_registry.py
content: """Tests for core.registry module."""

import pytest

from core.registry import (
    InputType,
    ModelEndpoint,
    ModelParameter,
    ModelRegistry,
    OutputType,
    register_endpoint,
    registry,
)


@pytest.fixture
def sample_endpoint() -> ModelEndpoint:
    """Create a sample endpoint for testing."""
    return ModelEndpoint(
        model_id="test/test-model",
        action="generation",
        provider="test",
        model_name="test-model",
        description="Test model for unit testing",
        input_types=[InputType.TEXT],
        output_type=OutputType.IMAGE,
        api_path="/vendors/test/v1/test-model/generation",
        result_key="images",
        available_on=["mulerouter"],
        parameters=[
            ModelParameter(
                name="prompt",
                type="string",
                description="Test prompt",
                required=True,
            ),
            ModelParameter(
                name="size",
                type="string",
                description="Output size",
                required=False,
                default="1024x1024",
                enum=["512x512", "1024x1024"],
            ),
        ],
    )


class TestModelParameter:
    """Tests for ModelParameter dataclass."""

    def test_to_dict(self) -> None:
        """Test parameter to_dict conversion."""
        param = ModelParameter(
            name="test_param",
            type="string",
            description="A test parameter",
            required=True,
            default="default_value",
            enum=["a", "b", "c"],
        )

        result = param.to_dict()
        assert result["name"] == "test_param"
        assert result["type"] == "string"
        assert result["description"] == "A test parameter"
        assert result["required"] is True
        assert result["default"] == "default_value"
        assert result["enum"] == ["a", "b", "c"]

    def test_to_dict_minimal(self) -> None:
        """Test parameter to_dict with minimal fields."""
        param = ModelParameter(
            name="simple",
            type="integer",
            description="Simple param",
        )

        result = param.to_dict()
        assert "default" not in result
        assert "enum" not in result


class TestModelEndpoint:
    """Tests for ModelEndpoint dataclass."""

    def test_to_dict(self, sample_endpoint: ModelEndpoint) -> None:
        """Test endpoint to_dict conversion."""
        result = sample_endpoint.to_dict()
        assert result["model_id"] == "test/test-model"
        assert result["action"] == "generation"
        assert result["provider"] == "test"
        assert result["output_type"] == "image"
        assert result["input_types"] == ["text"]
        assert result["api_path"] == "/vendors/test/v1/test-model/generation"


class TestModelRegistry:
    """Tests for ModelRegistry class."""

    def test_singleton(self) -> None:
        """Test registry is a singleton."""
        reg1 = ModelRegistry()
        reg2 = ModelRegistry()
        assert reg1 is reg2

    def test_register_and_get(self, sample_endpoint: ModelEndpoint) -> None:
        """Test registering and retrieving endpoint."""
        reg = ModelRegistry()
        reg.register(sample_endpoint)

        result = reg.get("test/test-model", "generation")
        assert result is sample_endpoint

    def test_get_not_found(self) -> None:
        """Test get returns None for unknown endpoint."""
        reg = ModelRegistry()
        result = reg.get("unknown/model", "unknown")
        assert result is None

    def test_list_all(self, sample_endpoint: ModelEndpoint) -> None:
        """Test list_all returns registered endpoints."""
        reg = ModelRegistry()
        reg.register(sample_endpoint)

        all_endpoints = reg.list_all()
        assert sample_endpoint in all_endpoints

    def test_list_for_endpoint(self, sample_endpoint: ModelEndpoint) -> None:
        """Test filtering by API endpoint."""
        reg = ModelRegistry()
        reg.register(sample_endpoint)

        # Should be found for mulerouter
        mulerouter_endpoints = reg.list_for_endpoint("mulerouter")
        assert sample_endpoint in mulerouter_endpoints

        # Should not be found for mulerun (not in available_on)
        mulerun_endpoints = reg.list_for_endpoint("mulerun")
        assert sample_endpoint not in mulerun_endpoints

    def test_list_by_provider(self, sample_endpoint: ModelEndpoint) -> None:
        """Test filtering by provider."""
        reg = ModelRegistry()
        reg.register(sample_endpoint)

        test_endpoints = reg.list_by_provider("test")
        assert sample_endpoint in test_endpoints

        other_endpoints = reg.list_by_provider("other")
        assert sample_endpoint not in other_endpoints

    def test_list_by_output_type(self, sample_endpoint: ModelEndpoint) -> None:
        """Test filtering by output type."""
        reg = ModelRegistry()
        reg.register(sample_endpoint)

        image_endpoints = reg.list_by_output_type(OutputType.IMAGE)
        assert sample_endpoint in image_endpoints

        video_endpoints = reg.list_by_output_type(OutputType.VIDEO)
        assert sample_endpoint not in video_endpoints


class TestRegisterEndpoint:
    """Tests for register_endpoint function."""

    def test_register_endpoint_function(self, sample_endpoint: ModelEndpoint) -> None:
        """Test register_endpoint helper function."""
        result = register_endpoint(sample_endpoint)
        assert result is sample_endpoint

        # Should be in global registry
        assert registry.get("test/test-model", "generation") is sample_endpoint


path: tests/test_task.py
content: """Tests for core.task module."""

from core.task import (
    TaskResult,
    TaskStatus,
    is_success_status,
    is_terminal_status,
    parse_task_response,
)


class TestTaskStatus:
    """Tests for TaskStatus enum."""

    def test_status_values(self) -> None:
        """Test status enum values."""
        assert TaskStatus.PENDING.value == "pending"
        assert TaskStatus.PROCESSING.value == "processing"
        assert TaskStatus.COMPLETED.value == "completed"
        assert TaskStatus.FAILED.value == "failed"


class TestIsTerminalStatus:
    """Tests for is_terminal_status function."""

    def test_terminal_statuses(self) -> None:
        """Test terminal statuses return True."""
        assert is_terminal_status(TaskStatus.COMPLETED) is True
        assert is_terminal_status(TaskStatus.SUCCEEDED) is True
        assert is_terminal_status(TaskStatus.FAILED) is True

    def test_non_terminal_statuses(self) -> None:
        """Test non-terminal statuses return False."""
        assert is_terminal_status(TaskStatus.PENDING) is False
        assert is_terminal_status(TaskStatus.PROCESSING) is False
        assert is_terminal_status(TaskStatus.RUNNING) is False
        assert is_terminal_status(TaskStatus.QUEUED) is False


class TestIsSuccessStatus:
    """Tests for is_success_status function."""

    def test_success_statuses(self) -> None:
        """Test success statuses return True."""
        assert is_success_status(TaskStatus.COMPLETED) is True
        assert is_success_status(TaskStatus.SUCCEEDED) is True

    def test_non_success_statuses(self) -> None:
        """Test non-success statuses return False."""
        assert is_success_status(TaskStatus.FAILED) is False
        assert is_success_status(TaskStatus.PENDING) is False


class TestParseTaskResponse:
    """Tests for parse_task_response function."""

    def test_parse_pending_task(self) -> None:
        """Test parsing a pending task response."""
        response = {
            "task_info": {
                "id": "123e4567-e89b-12d3-a456-426614174000",
                "status": "pending",
                "created_at": "2025-01-01T00:00:00Z",
                "updated_at": "2025-01-01T00:00:00Z",
            }
        }

        result = parse_task_response(response)
        assert result.task_id == "123e4567-e89b-12d3-a456-426614174000"
        assert result.status == TaskStatus.PENDING
        assert result.results is None
        assert result.error is None

    def test_parse_completed_task_with_images(self) -> None:
        """Test parsing a completed task with images."""
        response = {
            "task_info": {
                "id": "123",
                "status": "completed",
                "created_at": "2025-01-01T00:00:00Z",
                "updated_at": "2025-01-01T00:00:00Z",
            },
            "images": ["https://example.com/image1.png", "https://example.com/image2.png"],
        }

        result = parse_task_response(response, result_key="images")
        assert result.status == TaskStatus.COMPLETED
        assert result.results == [
            "https://example.com/image1.png",
            "https://example.com/image2.png",
        ]

    def test_parse_completed_task_with_videos(self) -> None:
        """Test parsing a completed task with videos."""
        response = {
            "task_info": {
                "id": "456",
                "status": "succeeded",
                "created_at": "2025-01-01T00:00:00Z",
                "updated_at": "2025-01-01T00:00:00Z",
            },
            "videos": ["https://example.com/video.mp4"],
        }

        result = parse_task_response(response, result_key="videos")
        assert result.status == TaskStatus.SUCCEEDED
        assert result.results == ["https://example.com/video.mp4"]

    def test_parse_failed_task(self) -> None:
        """Test parsing a failed task with error."""
        response = {
            "task_info": {
                "id": "789",
                "status": "failed",
                "created_at": "2025-01-01T00:00:00Z",
                "updated_at": "2025-01-01T00:00:00Z",
                "error": {
                    "code": 3001,
                    "title": "Content Error",
                    "detail": "The prompt contains prohibited content",
                },
            }
        }

        result = parse_task_response(response)
        assert result.status == TaskStatus.FAILED
        assert result.error == "The prompt contains prohibited content"
        assert result.results is None

    def test_parse_unknown_status(self) -> None:
        """Test parsing response with unknown status defaults to PENDING."""
        response = {
            "task_info": {
                "id": "abc",
                "status": "unknown_status",
            }
        }

        result = parse_task_response(response)
        assert result.status == TaskStatus.PENDING


class TestTaskResult:
    """Tests for TaskResult dataclass."""

    def test_task_result_creation(self) -> None:
        """Test TaskResult creation."""
        result = TaskResult(
            task_id="test-123",
            status=TaskStatus.COMPLETED,
            data={"task_info": {"id": "test-123"}},
            results=["https://example.com/result.png"],
            result_key="images",
        )

        assert result.task_id == "test-123"
        assert result.status == TaskStatus.COMPLETED
        assert result.results == ["https://example.com/result.png"]
        assert result.error is None


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)