The file /home/ubuntu/pipecat/src/core/redis.py has been updated successfully.

def _get_pod_name() -> Optional[str]:
    """Get pod name from POD_NAME environment variable."""
    return os.getenv("POD_NAME")

def _get_pod_ip() -> Optional[str]:
    """Get pod IP from POD_IP environment variable."""
    return os.getenv("POD_IP")

async def set_session_pod(session_id: str) -> bool:
    """Store session_id → pod_ip mapping in Redis for cross-pod routing."""
    if not _redis_available:
        return False
    pod_ip = _get_pod_ip()
    if not pod_ip:
        logger.warning("REDIS_SESSION_POD_SKIP: POD_IP not set")
        return False
    try:
        key = _key(f"session_pod:{session_id}")
        await _redis_pool.set(key, pod_ip, ex=600)  # 10 min TTL
        logger.info(f"REDIS_SESSION_POD_SET: session={session_id} pod_ip={pod_ip}")
        return True
    except Exception as e:
        logger.error(f"REDIS_SESSION_POD_ERROR: session={session_id} error={e}")
        return False

async def get_session_pod(session_id: str) -> Optional[str]:
    """Look up which pod IP owns a session."""
    if not _redis_available:
        return None
    try:
        key = _key(f"session_pod:{session_id}")
        pod_ip = await _redis_pool.get(key)
        return pod_ip.decode() if pod_ip else None
    except Exception as e:
        logger.error(f"REDIS_SESSION_POD_LOOKUP_ERROR: session={session_id} error={e}")
        return None

async def delete_session_pod(session_id: str):
    """Remove session-pod mapping from Redis."""
    if not _redis_available:
        return
    try:
        key = _key(f"session_pod:{session_id}")
        await _redis_pool.delete(key)
    except Exception:
        pass

"""Shared Redis client for cross-pod caching (ElastiCache).

All pods connect to the same Redis instance. This eliminates redundant
DB reads and makes config/device data available cluster-wide.

Usage:
    from src.core.redis import get_redis, redis_available

    if redis_available():
        value = await get_redis().get("maya:some_key")
"""

import json
import os
import time
from typing import Any, Dict, List, Optional

from loguru import logger

from src.config import constants

# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_redis_pool = None
_redis_available = False
_init_attempted = False

async def init_redis() -> bool:
    """Initialize the async Redis connection pool. Safe to call multiple times."""
    global _redis_pool, _redis_available, _init_attempted

    if _init_attempted:
        return _redis_available

    _init_attempted = True

    if not constants.REDIS_ENABLED or not constants.REDIS_URL:
        logger.info("REDIS_DISABLED: reason=not_configured")
        return False

    try:
        import redis.asyncio as aioredis

        _redis_pool = aioredis.from_url(
            constants.REDIS_URL,
            socket_connect_timeout=constants.REDIS_CONNECT_TIMEOUT,
            socket_timeout=constants.REDIS_SOCKET_TIMEOUT,
            max_connections=constants.REDIS_MAX_CONNECTIONS,
            decode_responses=True,  # Return str instead of bytes
            retry_on_timeout=True,
        )

        # Verify connectivity
        await _redis_pool.ping()
        _redis_available = True
        logger.info(f"REDIS_CONNECTED: url={constants.REDIS_URL.split('@')[-1] if '@' in constants.REDIS_URL else constants.REDIS_URL.replace('redis://', '')}")
        return True

    except ImportError:
        logger.warning("REDIS_DISABLED: reason=redis_package_not_installed")
        return False
    except Exception as e:
        logger.error(f"REDIS_CONNECT_FAILED: error={e} error_type={type(e).__name__}")
        _redis_available = False
        return False

def redis_available() -> bool:
    """Check if Redis is connected and available."""
    return _redis_available

def get_redis():
    """Get the Redis connection pool. Returns None if Redis is unavailable."""
    return _redis_pool if _redis_available else None

def _key(name: str) -> str:
    """Build a namespaced Redis key: maya:<name>"""
    return f"{constants.REDIS_KEY_PREFIX}{name}"

# ---------------------------------------------------------------------------
# Core cache operations (all fire-and-forget safe)
# ---------------------------------------------------------------------------

async def cache_get(key: str) -> Optional[str]:
    """Get a value from Redis. Returns None on miss or error."""
    if not _redis_available:
        return None
    try:
        return await _redis_pool.get(_key(key))
    except Exception as e:
        logger.debug(f"REDIS_GET_ERROR: key={key} error={e}")
        return None

async def cache_get_json(key: str) -> Optional[Any]:
    """Get and JSON-decode a value from Redis."""
    raw = await cache_get(key)
    if raw is None:
        return None
    try:
        return json.loads(raw)
    except (json.JSONDecodeError, TypeError):
        return None

async def cache_set(key: str, value: str, ttl: int = None) -> bool:
    """Set a string value in Redis with optional TTL (seconds)."""
    if not _redis_available:
        return False
    try:
        ttl = ttl or constants.REDIS_DEFAULT_TTL
        await _redis_pool.set(_key(key), value, ex=ttl)
        return True
    except Exception as e:
        logger.debug(f"REDIS_SET_ERROR: key={key} error={e}")
        return False

async def cache_set_json(key: str, value: Any, ttl: int = None) -> bool:
    """JSON-encode and set a value in Redis."""
    try:
        return await cache_set(key, json.dumps(value, default=str), ttl)
    except (TypeError, ValueError) as e:
        logger.debug(f"REDIS_SET_JSON_ERROR: key={key} error={e}")
        return False

async def cache_delete(key: str) -> bool:
    """Delete a key from Redis (cache invalidation)."""
    if not _redis_available:
        return False
    try:
        await _redis_pool.delete(_key(key))
        return True
    except Exception as e:
        logger.debug(f"REDIS_DELETE_ERROR: key={key} error={e}")
        return False

async def cache_delete_pattern(pattern: str) -> int:
    """Delete all keys matching a pattern (e.g. 'device:ABC*'). Returns count deleted."""
    if not _redis_available:
        return 0
    try:
        full_pattern = _key(pattern)
        deleted = 0
        async for k in _redis_pool.scan_iter(match=full_pattern, count=100):
            await _redis_pool.delete(k)
            deleted += 1
        return deleted
    except Exception as e:
        logger.debug(f"REDIS_DELETE_PATTERN_ERROR: pattern={pattern} error={e}")
        return 0

async def cache_exists(key: str) -> bool:
    """Check if a key exists in Redis."""
    if not _redis_available:
        return False
    try:
        return bool(await _redis_pool.exists(_key(key)))
    except Exception:
        return False

async def cache_ttl(key: str) -> int:
    """Get remaining TTL for a key. Returns -2 if key doesn't exist, -1 if no TTL."""
    if not _redis_available:
        return -2
    try:
        return await _redis_pool.ttl(_key(key))
    except Exception:
        return -2

# ---------------------------------------------------------------------------
# Health & metrics
# ---------------------------------------------------------------------------

async def redis_health() -> dict:
    """Return Redis health info for /health endpoint."""
    if not _redis_available:
        return {"status": "disabled", "connected": False}
    try:
        start = time.time()
        await _redis_pool.ping()
        latency_ms = (time.time() - start) * 1000
        # Fetch both memory and clients sections (connected_clients is NOT in memory section)
        mem_info = await _redis_pool.info(section="memory")
        client_info = await _redis_pool.info(section="clients")
        return {
            "status": "healthy",
            "connected": True,
            "latency_ms": round(latency_ms, 2),
            "used_memory_mb": round(int(mem_info.get("used_memory", 0)) / 1024 / 1024, 2),
            "connected_clients": client_info.get("connected_clients", "?"),
        }
    except Exception as e:
        return {"status": "unhealthy", "connected": False, "error": str(e)}

async def close_redis():
    """Gracefully close the Redis connection pool."""
    global _redis_pool, _redis_available, _init_attempted
    if _redis_pool:
        try:
            await _redis_pool.aclose()
            logger.info("REDIS_CLOSED: connection pool closed")
        except Exception as e:
            logger.error(f"REDIS_CLOSE_ERROR: {e}")
    _redis_pool = None
    _redis_available = False
    _init_attempted = False

# ---------------------------------------------------------------------------
# Session tracking (pod-level)
# ---------------------------------------------------------------------------

def _get_pod_name() -> Optional[str]:
    """Get pod name from POD_NAME environment variable."""
    return os.getenv("POD_NAME")

async def add_active_session(
    room_name: str,
    device_id: Optional[str] = None,
    character_id: Optional[str] = None
) -> bool:
    """Add an active session to Redis (pod-level tracking).

    Args:
        room_name: Session identifier
        device_id: Optional device ID
        character_id: Optional character ID

    Returns:
        True if added successfully, False otherwise
    """
    if not _redis_available:
        return False

    pod_name = _get_pod_name()
    if not pod_name:
        logger.warning("REDIS_SESSION_ADD_FAILED: pod_name not available")
        return False

    try:
        # Add room_name to pod's SET
        pod_set_key = _key(f"sessions:pod:{pod_name}")
        await _redis_pool.sadd(pod_set_key, room_name)

        # Store session metadata in Hash
        session_hash_key = _key(f"session:{room_name}")
        start_time = int(time.time())
        session_data = {
            "start_time": str(start_time),
            "pod_name": pod_name,
        }
        if device_id:
            session_data["device_id"] = device_id
        if character_id:
            session_data["character_id"] = character_id

        await _redis_pool.hset(session_hash_key, mapping=session_data)

        logger.info(f"REDIS_SESSION_ADDED: pod={pod_name} room={room_name}")
        return True
    except Exception as e:
        logger.error(f"REDIS_SESSION_ADD_ERROR: pod={pod_name} room={room_name} error={e}")
        return False

async def remove_active_session(room_name: str) -> bool:
    """Remove an active session from Redis.

    Args:
        room_name: Session identifier to remove

    Returns:
        True if removed successfully, False otherwise
    """
    if not _redis_available:
        return False

    try:
        # Get pod_name from session metadata first
        session_hash_key = _key(f"session:{room_name}")
        pod_name = await _redis_pool.hget(session_hash_key, "pod_name")

        if pod_name:
            # Remove from pod's SET
            pod_set_key = _key(f"sessions:pod:{pod_name}")
            await _redis_pool.srem(pod_set_key, room_name)

        # Delete session metadata Hash
        await _redis_pool.delete(session_hash_key)

        logger.info(f"REDIS_SESSION_REMOVED: room={room_name}")
        return True
    except Exception as e:
        logger.error(f"REDIS_SESSION_REMOVE_ERROR: room={room_name} error={e}")
        return False

async def get_pod_session_count(pod_name: str) -> int:
    """Get count of active sessions for a specific pod.

    Args:
        pod_name: Pod name to query

    Returns:
        Number of active sessions for the pod
    """
    if not _redis_available:
        return 0

    try:
        pod_set_key = _key(f"sessions:pod:{pod_name}")
        count = await _redis_pool.scard(pod_set_key)
        return count
    except Exception as e:
        logger.debug(f"REDIS_SESSION_COUNT_ERROR: pod={pod_name} error={e}")
        return 0

async def get_pod_sessions(pod_name: str) -> List[Dict[str, Any]]:
    """Get all sessions with metadata for a specific pod.

    Args:
        pod_name: Pod name to query

    Returns:
        List of session dictionaries with metadata
    """
    if not _redis_available:
        return []

    try:
        pod_set_key = _key(f"sessions:pod:{pod_name}")
        room_names = await _redis_pool.smembers(pod_set_key)

        sessions = []
        current_time = int(time.time())

        for room_name in room_names:
            session_hash_key = _key(f"session:{room_name}")
            metadata = await _redis_pool.hgetall(session_hash_key)

            if metadata:
                start_time = int(metadata.get("start_time", current_time))
                duration_seconds = current_time - start_time

                session_info = {
                    "room_name": room_name,
                    "start_time": start_time,
                    "duration_seconds": duration_seconds,
                    "pod_name": metadata.get("pod_name", pod_name),
                }

                if "device_id" in metadata:
                    session_info["device_id"] = metadata["device_id"]
                if "character_id" in metadata:
                    session_info["character_id"] = metadata["character_id"]

                sessions.append(session_info)

        return sessions
    except Exception as e:
        logger.error(f"REDIS_SESSION_GET_ERROR: pod={pod_name} error={e}")
        return []

async def get_all_pod_sessions() -> Dict[str, List[Dict[str, Any]]]:
    """Get all pods and their sessions with metadata.

    Returns:
        Dictionary mapping pod_name -> list of sessions
    """
    if not _redis_available:
        return {}

    try:
        # Find all pod session SETs
        pattern = _key("sessions:pod:*")
        pod_keys = []
        async for key in _redis_pool.scan_iter(match=pattern, count=100):
            pod_keys.append(key)

        result = {}
        for pod_key in pod_keys:
            # Extract pod_name from key (maya:sessions:pod:<pod_name>)
            pod_name = pod_key.split(":")[-1]
            sessions = await get_pod_sessions(pod_name)
            if sessions:
                result[pod_name] = sessions

        return result
    except Exception as e:
        logger.error(f"REDIS_ALL_SESSIONS_ERROR: error={e}")
        return {}

async def cleanup_pod_sessions(pod_name: str) -> int:
    """Clean up all sessions for a specific pod.

    This removes the pod's SET and all associated session metadata Hashes.
    Used when a pod is shutting down or has died.

    Args:
        pod_name: Pod name to clean up

    Returns:
        Number of sessions cleaned up
    """
    if not _redis_available:
        return 0

    try:
        pod_set_key = _key(f"sessions:pod:{pod_name}")

        # Get all room_names for this pod
        room_names = await _redis_pool.smembers(pod_set_key)
        session_count = len(room_names)

        # Delete all session metadata Hashes
        for room_name in room_names:
            session_hash_key = _key(f"session:{room_name}")
            await _redis_pool.delete(session_hash_key)

        # Delete pod's SET
        await _redis_pool.delete(pod_set_key)

        logger.info(f"REDIS_POD_CLEANUP: pod={pod_name} sessions={session_count}")
        return session_count
    except Exception as e:
        logger.error(f"REDIS_POD_CLEANUP_ERROR: pod={pod_name} error={e}")
        return 0

+    """Get pod IP from POD_IP environment variable."""

+async def set_session_pod(session_id: str) -> bool:

+    """Store session_id → pod_ip mapping in Redis for cross-pod routing."""

+        logger.warning("REDIS_SESSION_POD_SKIP: POD_IP not set")

+        key = _key(f"session_pod:{session_id}")

+        await _redis_pool.set(key, pod_ip, ex=600)  # 10 min TTL

+        logger.info(f"REDIS_SESSION_POD_SET: session={session_id} pod_ip={pod_ip}")

+        logger.error(f"REDIS_SESSION_POD_ERROR: session={session_id} error={e}")

+async def get_session_pod(session_id: str) -> Optional[str]:

+    """Look up which pod IP owns a session."""

+        pod_ip = await _redis_pool.get(key)

+        return pod_ip.decode() if pod_ip else None

+        logger.error(f"REDIS_SESSION_POD_LOOKUP_ERROR: session={session_id} error={e}")

+async def delete_session_pod(session_id: str):

+    """Remove session-pod mapping from Redis."""


Now update `bot.py` to store session-pod mapping and proxy requests:


Edit

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import uuid
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import add_active_session, remove_active_session, cleanup_pod_sessions, redis_available, _get_pod_name
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import os
import uuid
from typing import Any, Dict, List, Optional

import aiohttp
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import (
    add_active_session, remove_active_session, cleanup_pod_sessions,
    redis_available, _get_pod_name,
    set_session_pod, get_session_pod, delete_session_pod,
)
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest


The file /home/ubuntu/pipecat/src/routes/bot.py has been updated successfully.

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import os
import uuid
from typing import Any, Dict, List, Optional

import aiohttp
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import (
    add_active_session, remove_active_session, cleanup_pod_sessions,
    redis_available, _get_pod_name,
    set_session_pod, get_session_pod, delete_session_pod,
)
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import uuid
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import add_active_session, remove_active_session, cleanup_pod_sessions, redis_available, _get_pod_name
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest

def _get_ice_servers() -> list[IceServer]:
    """Build ICE server list from config. TURN is required for cloud/NAT traversal."""
    servers = [IceServer(urls="stun:bn-turn1.xirsys.com")]
    if constants.TURN_USERNAME and constants.TURN_PASSWORD:
        turn_urls = [
            "turn:bn-turn1.xirsys.com:80?transport=udp",
            "turn:bn-turn1.xirsys.com:3478?transport=udp",
            "turn:bn-turn1.xirsys.com:80?transport=tcp",
            "turn:bn-turn1.xirsys.com:3478?transport=tcp",
            "turns:bn-turn1.xirsys.com:443?transport=tcp",
            "turns:bn-turn1.xirsys.com:5349?transport=tcp",
        ]
        servers.append(IceServer(
            urls=turn_urls,
            username=constants.TURN_USERNAME,
            credential=constants.TURN_PASSWORD,
        ))
        logger.info("TURN_SERVER_CONFIGURED: xirsys")
    else:
        logger.warning("NO_TURN_SERVER: WebRTC will fail behind NAT/K8s without TURN")
    return servers

def _get_transport_mode() -> str:
    """Get current transport mode from DB config (synced periodically)."""
    from src.repository.config_repository import config_repo
    return config_repo.get_transport_mode()

router = APIRouter(tags=["bot"])

# Track active sessions: {task: session_id}
active_sessions: dict[asyncio.Task, str] = {}

# SmallWebRTC request handlers per session: {session_id: handler}
session_handlers: dict[str, SmallWebRTCRequestHandler] = {}

# Session params stored between /connect and /offer: {session_id: params_dict}
session_params: dict[str, dict] = {}

def get_active_sessions() -> dict:
    """Get reference to active sessions for cleanup."""
    return active_sessions

async def cleanup_active_sessions():
    """Cleanup active sessions on shutdown."""
    if active_sessions:
        logger.info(f"SHUTDOWN_CLEANUP: active_sessions={len(active_sessions)}")

        # Cancel all bot tasks
        for task in list(active_sessions.keys()):
            task.cancel()
        await asyncio.gather(*active_sessions.keys(), return_exceptions=True)

        # Close all SmallWebRTC handlers
        for sid, handler in list(session_handlers.items()):
            try:
                await handler.close()
            except Exception as e:
                logger.error(f"HANDLER_CLOSE_FAILED: session={sid} error={e}")
        session_handlers.clear()
        session_params.clear()

        logger.info(f"SHUTDOWN_CLEANUP_DONE: sessions={len(active_sessions)}")

    # Cleanup Redis sessions for this pod
    if redis_available():
        pod_name = _get_pod_name()
        if pod_name:
            cleaned_count = await cleanup_pod_sessions(pod_name)
            logger.info(f"REDIS_SHUTDOWN_CLEANUP: pod={pod_name} sessions_cleaned={cleaned_count}")

def _register_bot_task(task: asyncio.Task, session_id: str, params: dict):
    """Register a bot task for tracking and cleanup (shared by both transports)."""
    active_sessions[task] = session_id
    transport = params.get("transport", "unknown")

    # Add to Redis session tracking
    if redis_available():
        asyncio.create_task(add_active_session(session_id, params.get("device_id"), params.get("persona_id")))

    def cleanup_session(t):
        sid = active_sessions.pop(t, None)
        remaining = len(active_sessions)
        logger.info(f"TASK_COMPLETE: session={sid} transport={transport} remaining_active_sessions={remaining}")
        on_session_complete(active_sessions)
        # Cleanup handler and params
        session_handlers.pop(sid, None)
        session_params.pop(sid, None)
        if sid and redis_available():
            try:
                loop = asyncio.get_event_loop()
                if loop.is_running():
                    asyncio.create_task(remove_active_session(sid))
                    logger.info(f"REDIS_CLEANUP_SCHEDULED: session={sid}")
                else:
                    logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=loop_not_running")
            except RuntimeError:
                logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=no_event_loop")
            except Exception as e:
                logger.error(f"REDIS_CLEANUP_CALLBACK_ERROR: session={sid} error={e}")

    task.add_done_callback(cleanup_session)

@router.get("/transport-type")
async def get_transport_type():
    """Lightweight endpoint — returns current transport mode without creating a session."""
    mode = _get_transport_mode()
    logger.info(f"TRANSPORT_TYPE_CHECK: mode={mode}")
    return {"transport": mode}

@router.post("/start")
async def bot_connect(data: BotConnectRequest) -> Dict[Any, Any]:
    """Unified connection endpoint — returns transport-specific response.

    Transport mode is controlled by DB config (transport_config.mode).
    - SmallWebRTC: returns {"transport": "smallwebrtc", "sessionId": "..."}
    - Daily: returns {"transport": "daily", "room_url": "...", "token": "..."}
    """
    logger.info(f"CONNECT_INPUT_BODY: {data.model_dump()}")
    device_id = data.device_id
    version = data.version
    persona_id = data.persona_id or data.character_id
    app_key = data.app_key
    language = data.language
    source = data.source

    transport_mode = _get_transport_mode()

    try:
        logger.info(f"SESSION_START: device_id={device_id or 'common'} version={version} persona_id={persona_id or 'default'} app_key={app_key or 'legacy'} language={language or 'default'} source={source or 'app'} transport_mode={transport_mode}")

        session_id = str(uuid.uuid4())

        params = {
            "device_id": device_id,
            "version": version,
            "persona_id": persona_id,
            "app_key": app_key,
            "language": language,
            "source": source,
        }

        if transport_mode == "daily":
            return await _start_daily_session(session_id, params)
        else:
            return await _start_smallwebrtc_session(session_id, params)

    except Exception as e:
        log_error("SESSION_START_FAILED", e, device_id=device_id or "common", transport=transport_mode)
        raise HTTPException(status_code=500, detail=str(e))

async def _start_smallwebrtc_session(session_id: str, params: dict) -> dict:
    """SmallWebRTC flow: return sessionId, client posts SDP offer next."""
    handler = SmallWebRTCRequestHandler(ice_servers=_get_ice_servers())
    session_handlers[session_id] = handler
    params["transport"] = "smallwebrtc"
    session_params[session_id] = params

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Return ICE servers so client can configure RTCPeerConnection with TURN
    ice_servers_response = []
    for server in _get_ice_servers():
        entry = {"urls": server.urls if isinstance(server.urls, list) else [server.urls]}
        if server.username:
            entry["username"] = server.username
        if server.credential:
            entry["credential"] = server.credential
        ice_servers_response.append(entry)

    return {"transport": "smallwebrtc", "sessionId": session_id, "iceConfig": {"iceServers": ice_servers_response}}

async def _start_daily_session(session_id: str, params: dict) -> dict:
    """Daily flow: create room + token, start bot immediately, return credentials."""
    from src.core.daily_client import DailyClient, DailyAPIError

    params["transport"] = "daily"
    daily_client = DailyClient()
    room_url, room_name, token = await daily_client.create_room_with_token()

    # Start bot task immediately (Daily bot joins room and waits for participant)
    task = asyncio.create_task(run_bot_webrtc_global_agent(
        None,  # no webrtc_connection for Daily
        device_id=params.get("device_id"),
        session_id=session_id,
        version=params.get("version"),
        app_key=params.get("app_key"),
        language=params.get("language"),
        source=params.get("source"),
        persona_id=params.get("persona_id"),
        transport_type="daily",
        room_url=room_url,
        token=token,
    ))
    _register_bot_task(task, session_id, params)

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=daily room={room_name} device_id={params.get('device_id') or 'common'}")

    return {"transport": "daily", "room_url": room_url, "token": token}

# ---- SmallWebRTC SDP signaling endpoints ----

class OfferRequest(BaseModel):
    sdp: str
    type: str
    pc_id: Optional[str] = None
    restart_pc: Optional[bool] = None

class PatchCandidateItem(BaseModel):
    candidate: str
    sdpMid: str
    sdpMLineIndex: int

class PatchRequest(BaseModel):
    pc_id: str
    candidates: List[PatchCandidateItem]

@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        raise HTTPException(status_code=404, detail="Session not found")

    params = session_params.get(session_id, {})

    async def on_connection(webrtc_connection):
        """Called when a new WebRTC connection is established."""
        task = asyncio.create_task(run_bot_webrtc_global_agent(
            webrtc_connection,
            device_id=params.get("device_id"),
            session_id=session_id,
            version=params.get("version"),
            app_key=params.get("app_key"),
            language=params.get("language"),
            source=params.get("source"),
            persona_id=params.get("persona_id"),
            transport_type="smallwebrtc",
        ))
        _register_bot_task(task, session_id, params)

    try:
        request = SmallWebRTCRequest(
            sdp=data.sdp,
            type=data.type,
            pc_id=data.pc_id,
            restart_pc=data.restart_pc,
        )
        answer = await handler.handle_web_request(request, on_connection)
        if answer:
            return answer
        raise HTTPException(status_code=500, detail="Failed to create SDP answer")
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"OFFER_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

@router.patch("/sessions/{session_id}/api/offer")
async def handle_ice_candidates(session_id: str, request: Request):
    """Handle ICE candidate trickle from client."""
    raw_body = await request.json()
    logger.info(f"ICE_PATCH_RAW: session={session_id} body={raw_body}")

    handler = session_handlers.get(session_id)
    if not handler:
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")

    try:
        # Parse flexibly - handle both camelCase and snake_case
        pc_id = raw_body.get("pc_id") or raw_body.get("pcId")
        candidates_raw = raw_body.get("candidates", [])

        candidates = []
        for c in candidates_raw:
            candidates.append(IceCandidate(
                candidate=c.get("candidate", ""),
                sdp_mid=c.get("sdpMid") or c.get("sdp_mid", ""),
                sdp_mline_index=c.get("sdpMLineIndex") or c.get("sdp_mline_index", 0),
            ))

        patch_request = SmallWebRTCPatchRequest(
            pc_id=pc_id,
            candidates=candidates,
        )
        await handler.handle_patch_request(patch_request)
        return {"status": "ok"}
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"ICE_PATCH_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

-from src.core.redis import add_active_session, remove_active_session, cleanup_pod_sessions, redis_available, _get_pod_name

+    add_active_session, remove_active_session, cleanup_pod_sessions,

+    set_session_pod, get_session_pod, delete_session_pod,


Now add the proxy helper and update `/start` to store session-pod mapping:


router = APIRouter(tags=["bot"])

# Port the app listens on inside each pod
_POD_PORT = 3008

async def _proxy_to_pod(pod_ip: str, method: str, path: str, body: dict = None) -> dict:
    """Proxy a request to another pod internally (pod-to-pod within cluster)."""
    url = f"http://{pod_ip}:{_POD_PORT}{path}"
    logger.info(f"PROXY_REQUEST: method={method} url={url}")
    try:
        async with aiohttp.ClientSession() as session:
            if method == "POST":
                async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
            elif method == "PATCH":
                async with session.patch(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"PROXY_FAILED: url={url} error={e}")
        raise HTTPException(status_code=502, detail=f"Proxy to session pod failed: {e}")


router = APIRouter(tags=["bot"])

# Port the app listens on inside each pod
_POD_PORT = 3008

async def _proxy_to_pod(pod_ip: str, method: str, path: str, body: dict = None) -> dict:
    """Proxy a request to another pod internally (pod-to-pod within cluster)."""
    url = f"http://{pod_ip}:{_POD_PORT}{path}"
    logger.info(f"PROXY_REQUEST: method={method} url={url}")
    try:
        async with aiohttp.ClientSession() as session:
            if method == "POST":
                async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
            elif method == "PATCH":
                async with session.patch(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"PROXY_FAILED: url={url} error={e}")
        raise HTTPException(status_code=502, detail=f"Proxy to session pod failed: {e}")

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import os
import uuid
from typing import Any, Dict, List, Optional

import aiohttp
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import (
    add_active_session, remove_active_session, cleanup_pod_sessions,
    redis_available, _get_pod_name,
    set_session_pod, get_session_pod, delete_session_pod,
)
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest

def _get_ice_servers() -> list[IceServer]:
    """Build ICE server list from config. TURN is required for cloud/NAT traversal."""
    servers = [IceServer(urls="stun:bn-turn1.xirsys.com")]
    if constants.TURN_USERNAME and constants.TURN_PASSWORD:
        turn_urls = [
            "turn:bn-turn1.xirsys.com:80?transport=udp",
            "turn:bn-turn1.xirsys.com:3478?transport=udp",
            "turn:bn-turn1.xirsys.com:80?transport=tcp",
            "turn:bn-turn1.xirsys.com:3478?transport=tcp",
            "turns:bn-turn1.xirsys.com:443?transport=tcp",
            "turns:bn-turn1.xirsys.com:5349?transport=tcp",
        ]
        servers.append(IceServer(
            urls=turn_urls,
            username=constants.TURN_USERNAME,
            credential=constants.TURN_PASSWORD,
        ))
        logger.info("TURN_SERVER_CONFIGURED: xirsys")
    else:
        logger.warning("NO_TURN_SERVER: WebRTC will fail behind NAT/K8s without TURN")
    return servers

def _get_transport_mode() -> str:
    """Get current transport mode from DB config (synced periodically)."""
    from src.repository.config_repository import config_repo
    return config_repo.get_transport_mode()

router = APIRouter(tags=["bot"])

# Track active sessions: {task: session_id}
active_sessions: dict[asyncio.Task, str] = {}

# SmallWebRTC request handlers per session: {session_id: handler}
session_handlers: dict[str, SmallWebRTCRequestHandler] = {}

# Session params stored between /connect and /offer: {session_id: params_dict}
session_params: dict[str, dict] = {}

def get_active_sessions() -> dict:
    """Get reference to active sessions for cleanup."""
    return active_sessions

async def cleanup_active_sessions():
    """Cleanup active sessions on shutdown."""
    if active_sessions:
        logger.info(f"SHUTDOWN_CLEANUP: active_sessions={len(active_sessions)}")

        # Cancel all bot tasks
        for task in list(active_sessions.keys()):
            task.cancel()
        await asyncio.gather(*active_sessions.keys(), return_exceptions=True)

        # Close all SmallWebRTC handlers
        for sid, handler in list(session_handlers.items()):
            try:
                await handler.close()
            except Exception as e:
                logger.error(f"HANDLER_CLOSE_FAILED: session={sid} error={e}")
        session_handlers.clear()
        session_params.clear()

        logger.info(f"SHUTDOWN_CLEANUP_DONE: sessions={len(active_sessions)}")

    # Cleanup Redis sessions for this pod
    if redis_available():
        pod_name = _get_pod_name()
        if pod_name:
            cleaned_count = await cleanup_pod_sessions(pod_name)
            logger.info(f"REDIS_SHUTDOWN_CLEANUP: pod={pod_name} sessions_cleaned={cleaned_count}")

def _register_bot_task(task: asyncio.Task, session_id: str, params: dict):
    """Register a bot task for tracking and cleanup (shared by both transports)."""
    active_sessions[task] = session_id
    transport = params.get("transport", "unknown")

    # Add to Redis session tracking
    if redis_available():
        asyncio.create_task(add_active_session(session_id, params.get("device_id"), params.get("persona_id")))

    def cleanup_session(t):
        sid = active_sessions.pop(t, None)
        remaining = len(active_sessions)
        logger.info(f"TASK_COMPLETE: session={sid} transport={transport} remaining_active_sessions={remaining}")
        on_session_complete(active_sessions)
        # Cleanup handler and params
        session_handlers.pop(sid, None)
        session_params.pop(sid, None)
        if sid and redis_available():
            try:
                loop = asyncio.get_event_loop()
                if loop.is_running():
                    asyncio.create_task(remove_active_session(sid))
                    logger.info(f"REDIS_CLEANUP_SCHEDULED: session={sid}")
                else:
                    logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=loop_not_running")
            except RuntimeError:
                logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=no_event_loop")
            except Exception as e:
                logger.error(f"REDIS_CLEANUP_CALLBACK_ERROR: session={sid} error={e}")

    task.add_done_callback(cleanup_session)

@router.get("/transport-type")
async def get_transport_type():
    """Lightweight endpoint — returns current transport mode without creating a session."""
    mode = _get_transport_mode()
    logger.info(f"TRANSPORT_TYPE_CHECK: mode={mode}")
    return {"transport": mode}

@router.post("/start")
async def bot_connect(data: BotConnectRequest) -> Dict[Any, Any]:
    """Unified connection endpoint — returns transport-specific response.

    Transport mode is controlled by DB config (transport_config.mode).
    - SmallWebRTC: returns {"transport": "smallwebrtc", "sessionId": "..."}
    - Daily: returns {"transport": "daily", "room_url": "...", "token": "..."}
    """
    logger.info(f"CONNECT_INPUT_BODY: {data.model_dump()}")
    device_id = data.device_id
    version = data.version
    persona_id = data.persona_id or data.character_id
    app_key = data.app_key
    language = data.language
    source = data.source

    transport_mode = _get_transport_mode()

    try:
        logger.info(f"SESSION_START: device_id={device_id or 'common'} version={version} persona_id={persona_id or 'default'} app_key={app_key or 'legacy'} language={language or 'default'} source={source or 'app'} transport_mode={transport_mode}")

        session_id = str(uuid.uuid4())

        params = {
            "device_id": device_id,
            "version": version,
            "persona_id": persona_id,
            "app_key": app_key,
            "language": language,
            "source": source,
        }

        if transport_mode == "daily":
            return await _start_daily_session(session_id, params)
        else:
            return await _start_smallwebrtc_session(session_id, params)

    except Exception as e:
        log_error("SESSION_START_FAILED", e, device_id=device_id or "common", transport=transport_mode)
        raise HTTPException(status_code=500, detail=str(e))

async def _start_smallwebrtc_session(session_id: str, params: dict) -> dict:
    """SmallWebRTC flow: return sessionId, client posts SDP offer next."""
    handler = SmallWebRTCRequestHandler(ice_servers=_get_ice_servers())
    session_handlers[session_id] = handler
    params["transport"] = "smallwebrtc"
    session_params[session_id] = params

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Return ICE servers so client can configure RTCPeerConnection with TURN
    ice_servers_response = []
    for server in _get_ice_servers():
        entry = {"urls": server.urls if isinstance(server.urls, list) else [server.urls]}
        if server.username:
            entry["username"] = server.username
        if server.credential:
            entry["credential"] = server.credential
        ice_servers_response.append(entry)

    return {"transport": "smallwebrtc", "sessionId": session_id, "iceConfig": {"iceServers": ice_servers_response}}

async def _start_daily_session(session_id: str, params: dict) -> dict:
    """Daily flow: create room + token, start bot immediately, return credentials."""
    from src.core.daily_client import DailyClient, DailyAPIError

    params["transport"] = "daily"
    daily_client = DailyClient()
    room_url, room_name, token = await daily_client.create_room_with_token()

    # Start bot task immediately (Daily bot joins room and waits for participant)
    task = asyncio.create_task(run_bot_webrtc_global_agent(
        None,  # no webrtc_connection for Daily
        device_id=params.get("device_id"),
        session_id=session_id,
        version=params.get("version"),
        app_key=params.get("app_key"),
        language=params.get("language"),
        source=params.get("source"),
        persona_id=params.get("persona_id"),
        transport_type="daily",
        room_url=room_url,
        token=token,
    ))
    _register_bot_task(task, session_id, params)

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=daily room={room_name} device_id={params.get('device_id') or 'common'}")

    return {"transport": "daily", "room_url": room_url, "token": token}

# ---- SmallWebRTC SDP signaling endpoints ----

class OfferRequest(BaseModel):
    sdp: str
    type: str
    pc_id: Optional[str] = None
    restart_pc: Optional[bool] = None

class PatchCandidateItem(BaseModel):
    candidate: str
    sdpMid: str
    sdpMLineIndex: int

class PatchRequest(BaseModel):
    pc_id: str
    candidates: List[PatchCandidateItem]

@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        raise HTTPException(status_code=404, detail="Session not found")

    params = session_params.get(session_id, {})

    async def on_connection(webrtc_connection):
        """Called when a new WebRTC connection is established."""
        task = asyncio.create_task(run_bot_webrtc_global_agent(
            webrtc_connection,
            device_id=params.get("device_id"),
            session_id=session_id,
            version=params.get("version"),
            app_key=params.get("app_key"),
            language=params.get("language"),
            source=params.get("source"),
            persona_id=params.get("persona_id"),
            transport_type="smallwebrtc",
        ))
        _register_bot_task(task, session_id, params)

    try:
        request = SmallWebRTCRequest(
            sdp=data.sdp,
            type=data.type,
            pc_id=data.pc_id,
            restart_pc=data.restart_pc,
        )
        answer = await handler.handle_web_request(request, on_connection)
        if answer:
            return answer
        raise HTTPException(status_code=500, detail="Failed to create SDP answer")
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"OFFER_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

@router.patch("/sessions/{session_id}/api/offer")
async def handle_ice_candidates(session_id: str, request: Request):
    """Handle ICE candidate trickle from client."""
    raw_body = await request.json()
    logger.info(f"ICE_PATCH_RAW: session={session_id} body={raw_body}")

    handler = session_handlers.get(session_id)
    if not handler:
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")

    try:
        # Parse flexibly - handle both camelCase and snake_case
        pc_id = raw_body.get("pc_id") or raw_body.get("pcId")
        candidates_raw = raw_body.get("candidates", [])

        candidates = []
        for c in candidates_raw:
            candidates.append(IceCandidate(
                candidate=c.get("candidate", ""),
                sdp_mid=c.get("sdpMid") or c.get("sdp_mid", ""),
                sdp_mline_index=c.get("sdpMLineIndex") or c.get("sdp_mline_index", 0),
            ))

        patch_request = SmallWebRTCPatchRequest(
            pc_id=pc_id,
            candidates=candidates,
        )
        await handler.handle_patch_request(patch_request)
        return {"status": "ok"}
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"ICE_PATCH_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

+# Port the app listens on inside each pod

+async def _proxy_to_pod(pod_ip: str, method: str, path: str, body: dict = None) -> dict:

+    """Proxy a request to another pod internally (pod-to-pod within cluster)."""

+    url = f"http://{pod_ip}:{_POD_PORT}{path}"

+    logger.info(f"PROXY_REQUEST: method={method} url={url}")

+        async with aiohttp.ClientSession() as session:

+                async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:

+                    if resp.status >= 400:

+                        text = await resp.text()

+                        raise HTTPException(status_code=resp.status, detail=text)

+                    return await resp.json()

+                async with session.patch(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:

+        logger.error(f"PROXY_FAILED: url={url} error={e}")

+        raise HTTPException(status_code=502, detail=f"Proxy to session pod failed: {e}")


Now update `_start_smallwebrtc_session` to store session-pod mapping in Redis:


    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Return ICE servers so client can configure RTCPeerConnection with TURN

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Store session → pod_ip mapping in Redis for cross-pod routing
    if redis_available():
        await set_session_pod(session_id)

    # Return ICE servers so client can configure RTCPeerConnection with TURN


    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Store session → pod_ip mapping in Redis for cross-pod routing
    if redis_available():
        await set_session_pod(session_id)

    # Return ICE servers so client can configure RTCPeerConnection with TURN

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import os
import uuid
from typing import Any, Dict, List, Optional

import aiohttp
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import (
    add_active_session, remove_active_session, cleanup_pod_sessions,
    redis_available, _get_pod_name,
    set_session_pod, get_session_pod, delete_session_pod,
)
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest

def _get_ice_servers() -> list[IceServer]:
    """Build ICE server list from config. TURN is required for cloud/NAT traversal."""
    servers = [IceServer(urls="stun:bn-turn1.xirsys.com")]
    if constants.TURN_USERNAME and constants.TURN_PASSWORD:
        turn_urls = [
            "turn:bn-turn1.xirsys.com:80?transport=udp",
            "turn:bn-turn1.xirsys.com:3478?transport=udp",
            "turn:bn-turn1.xirsys.com:80?transport=tcp",
            "turn:bn-turn1.xirsys.com:3478?transport=tcp",
            "turns:bn-turn1.xirsys.com:443?transport=tcp",
            "turns:bn-turn1.xirsys.com:5349?transport=tcp",
        ]
        servers.append(IceServer(
            urls=turn_urls,
            username=constants.TURN_USERNAME,
            credential=constants.TURN_PASSWORD,
        ))
        logger.info("TURN_SERVER_CONFIGURED: xirsys")
    else:
        logger.warning("NO_TURN_SERVER: WebRTC will fail behind NAT/K8s without TURN")
    return servers

def _get_transport_mode() -> str:
    """Get current transport mode from DB config (synced periodically)."""
    from src.repository.config_repository import config_repo
    return config_repo.get_transport_mode()

router = APIRouter(tags=["bot"])

# Port the app listens on inside each pod
_POD_PORT = 3008

async def _proxy_to_pod(pod_ip: str, method: str, path: str, body: dict = None) -> dict:
    """Proxy a request to another pod internally (pod-to-pod within cluster)."""
    url = f"http://{pod_ip}:{_POD_PORT}{path}"
    logger.info(f"PROXY_REQUEST: method={method} url={url}")
    try:
        async with aiohttp.ClientSession() as session:
            if method == "POST":
                async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
            elif method == "PATCH":
                async with session.patch(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"PROXY_FAILED: url={url} error={e}")
        raise HTTPException(status_code=502, detail=f"Proxy to session pod failed: {e}")

# Track active sessions: {task: session_id}
active_sessions: dict[asyncio.Task, str] = {}

# SmallWebRTC request handlers per session: {session_id: handler}
session_handlers: dict[str, SmallWebRTCRequestHandler] = {}

# Session params stored between /connect and /offer: {session_id: params_dict}
session_params: dict[str, dict] = {}

def get_active_sessions() -> dict:
    """Get reference to active sessions for cleanup."""
    return active_sessions

async def cleanup_active_sessions():
    """Cleanup active sessions on shutdown."""
    if active_sessions:
        logger.info(f"SHUTDOWN_CLEANUP: active_sessions={len(active_sessions)}")

        # Cancel all bot tasks
        for task in list(active_sessions.keys()):
            task.cancel()
        await asyncio.gather(*active_sessions.keys(), return_exceptions=True)

        # Close all SmallWebRTC handlers
        for sid, handler in list(session_handlers.items()):
            try:
                await handler.close()
            except Exception as e:
                logger.error(f"HANDLER_CLOSE_FAILED: session={sid} error={e}")
        session_handlers.clear()
        session_params.clear()

        logger.info(f"SHUTDOWN_CLEANUP_DONE: sessions={len(active_sessions)}")

    # Cleanup Redis sessions for this pod
    if redis_available():
        pod_name = _get_pod_name()
        if pod_name:
            cleaned_count = await cleanup_pod_sessions(pod_name)
            logger.info(f"REDIS_SHUTDOWN_CLEANUP: pod={pod_name} sessions_cleaned={cleaned_count}")

def _register_bot_task(task: asyncio.Task, session_id: str, params: dict):
    """Register a bot task for tracking and cleanup (shared by both transports)."""
    active_sessions[task] = session_id
    transport = params.get("transport", "unknown")

    # Add to Redis session tracking
    if redis_available():
        asyncio.create_task(add_active_session(session_id, params.get("device_id"), params.get("persona_id")))

    def cleanup_session(t):
        sid = active_sessions.pop(t, None)
        remaining = len(active_sessions)
        logger.info(f"TASK_COMPLETE: session={sid} transport={transport} remaining_active_sessions={remaining}")
        on_session_complete(active_sessions)
        # Cleanup handler and params
        session_handlers.pop(sid, None)
        session_params.pop(sid, None)
        if sid and redis_available():
            try:
                loop = asyncio.get_event_loop()
                if loop.is_running():
                    asyncio.create_task(remove_active_session(sid))
                    logger.info(f"REDIS_CLEANUP_SCHEDULED: session={sid}")
                else:
                    logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=loop_not_running")
            except RuntimeError:
                logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=no_event_loop")
            except Exception as e:
                logger.error(f"REDIS_CLEANUP_CALLBACK_ERROR: session={sid} error={e}")

    task.add_done_callback(cleanup_session)

@router.get("/transport-type")
async def get_transport_type():
    """Lightweight endpoint — returns current transport mode without creating a session."""
    mode = _get_transport_mode()
    logger.info(f"TRANSPORT_TYPE_CHECK: mode={mode}")
    return {"transport": mode}

@router.post("/start")
async def bot_connect(data: BotConnectRequest) -> Dict[Any, Any]:
    """Unified connection endpoint — returns transport-specific response.

    Transport mode is controlled by DB config (transport_config.mode).
    - SmallWebRTC: returns {"transport": "smallwebrtc", "sessionId": "..."}
    - Daily: returns {"transport": "daily", "room_url": "...", "token": "..."}
    """
    logger.info(f"CONNECT_INPUT_BODY: {data.model_dump()}")
    device_id = data.device_id
    version = data.version
    persona_id = data.persona_id or data.character_id
    app_key = data.app_key
    language = data.language
    source = data.source

    transport_mode = _get_transport_mode()

    try:
        logger.info(f"SESSION_START: device_id={device_id or 'common'} version={version} persona_id={persona_id or 'default'} app_key={app_key or 'legacy'} language={language or 'default'} source={source or 'app'} transport_mode={transport_mode}")

        session_id = str(uuid.uuid4())

        params = {
            "device_id": device_id,
            "version": version,
            "persona_id": persona_id,
            "app_key": app_key,
            "language": language,
            "source": source,
        }

        if transport_mode == "daily":
            return await _start_daily_session(session_id, params)
        else:
            return await _start_smallwebrtc_session(session_id, params)

    except Exception as e:
        log_error("SESSION_START_FAILED", e, device_id=device_id or "common", transport=transport_mode)
        raise HTTPException(status_code=500, detail=str(e))

async def _start_smallwebrtc_session(session_id: str, params: dict) -> dict:
    """SmallWebRTC flow: return sessionId, client posts SDP offer next."""
    handler = SmallWebRTCRequestHandler(ice_servers=_get_ice_servers())
    session_handlers[session_id] = handler
    params["transport"] = "smallwebrtc"
    session_params[session_id] = params

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Return ICE servers so client can configure RTCPeerConnection with TURN
    ice_servers_response = []
    for server in _get_ice_servers():
        entry = {"urls": server.urls if isinstance(server.urls, list) else [server.urls]}
        if server.username:
            entry["username"] = server.username
        if server.credential:
            entry["credential"] = server.credential
        ice_servers_response.append(entry)

    return {"transport": "smallwebrtc", "sessionId": session_id, "iceConfig": {"iceServers": ice_servers_response}}

async def _start_daily_session(session_id: str, params: dict) -> dict:
    """Daily flow: create room + token, start bot immediately, return credentials."""
    from src.core.daily_client import DailyClient, DailyAPIError

    params["transport"] = "daily"
    daily_client = DailyClient()
    room_url, room_name, token = await daily_client.create_room_with_token()

    # Start bot task immediately (Daily bot joins room and waits for participant)
    task = asyncio.create_task(run_bot_webrtc_global_agent(
        None,  # no webrtc_connection for Daily
        device_id=params.get("device_id"),
        session_id=session_id,
        version=params.get("version"),
        app_key=params.get("app_key"),
        language=params.get("language"),
        source=params.get("source"),
        persona_id=params.get("persona_id"),
        transport_type="daily",
        room_url=room_url,
        token=token,
    ))
    _register_bot_task(task, session_id, params)

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=daily room={room_name} device_id={params.get('device_id') or 'common'}")

    return {"transport": "daily", "room_url": room_url, "token": token}

# ---- SmallWebRTC SDP signaling endpoints ----

class OfferRequest(BaseModel):
    sdp: str
    type: str
    pc_id: Optional[str] = None
    restart_pc: Optional[bool] = None

class PatchCandidateItem(BaseModel):
    candidate: str
    sdpMid: str
    sdpMLineIndex: int

class PatchRequest(BaseModel):
    pc_id: str
    candidates: List[PatchCandidateItem]

@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        raise HTTPException(status_code=404, detail="Session not found")

    params = session_params.get(session_id, {})

    async def on_connection(webrtc_connection):
        """Called when a new WebRTC connection is established."""
        task = asyncio.create_task(run_bot_webrtc_global_agent(
            webrtc_connection,
            device_id=params.get("device_id"),
            session_id=session_id,
            version=params.get("version"),
            app_key=params.get("app_key"),
            language=params.get("language"),
            source=params.get("source"),
            persona_id=params.get("persona_id"),
            transport_type="smallwebrtc",
        ))
        _register_bot_task(task, session_id, params)

    try:
        request = SmallWebRTCRequest(
            sdp=data.sdp,
            type=data.type,
            pc_id=data.pc_id,
            restart_pc=data.restart_pc,
        )
        answer = await handler.handle_web_request(request, on_connection)
        if answer:
            return answer
        raise HTTPException(status_code=500, detail="Failed to create SDP answer")
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"OFFER_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

@router.patch("/sessions/{session_id}/api/offer")
async def handle_ice_candidates(session_id: str, request: Request):
    """Handle ICE candidate trickle from client."""
    raw_body = await request.json()
    logger.info(f"ICE_PATCH_RAW: session={session_id} body={raw_body}")

    handler = session_handlers.get(session_id)
    if not handler:
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")

    try:
        # Parse flexibly - handle both camelCase and snake_case
        pc_id = raw_body.get("pc_id") or raw_body.get("pcId")
        candidates_raw = raw_body.get("candidates", [])

        candidates = []
        for c in candidates_raw:
            candidates.append(IceCandidate(
                candidate=c.get("candidate", ""),
                sdp_mid=c.get("sdpMid") or c.get("sdp_mid", ""),
                sdp_mline_index=c.get("sdpMLineIndex") or c.get("sdp_mline_index", 0),
            ))

        patch_request = SmallWebRTCPatchRequest(
            pc_id=pc_id,
            candidates=candidates,
        )
        await handler.handle_patch_request(patch_request)
        return {"status": "ok"}
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"ICE_PATCH_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

+    # Store session → pod_ip mapping in Redis for cross-pod routing

+        await set_session_pod(session_id)


Now update the `/offer` endpoint to proxy if session not found locally:


@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        raise HTTPException(status_code=404, detail="Session not found")

@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        # Session not on this pod — check Redis for the correct pod and proxy
        pod_ip = await get_session_pod(session_id) if redis_available() else None
        my_ip = os.environ.get("POD_IP")
        if pod_ip and pod_ip != my_ip:
            logger.info(f"OFFER_PROXY: session={session_id} from={my_ip} to={pod_ip}")
            return await _proxy_to_pod(pod_ip, "POST", f"/api/v4/sessions/{session_id}/api/offer", data.model_dump())
        raise HTTPException(status_code=404, detail="Session not found")


@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        # Session not on this pod — check Redis for the correct pod and proxy
        pod_ip = await get_session_pod(session_id) if redis_available() else None
        my_ip = os.environ.get("POD_IP")
        if pod_ip and pod_ip != my_ip:
            logger.info(f"OFFER_PROXY: session={session_id} from={my_ip} to={pod_ip}")
            return await _proxy_to_pod(pod_ip, "POST", f"/api/v4/sessions/{session_id}/api/offer", data.model_dump())
        raise HTTPException(status_code=404, detail="Session not found")

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import os
import uuid
from typing import Any, Dict, List, Optional

import aiohttp
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import (
    add_active_session, remove_active_session, cleanup_pod_sessions,
    redis_available, _get_pod_name,
    set_session_pod, get_session_pod, delete_session_pod,
)
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest

def _get_ice_servers() -> list[IceServer]:
    """Build ICE server list from config. TURN is required for cloud/NAT traversal."""
    servers = [IceServer(urls="stun:bn-turn1.xirsys.com")]
    if constants.TURN_USERNAME and constants.TURN_PASSWORD:
        turn_urls = [
            "turn:bn-turn1.xirsys.com:80?transport=udp",
            "turn:bn-turn1.xirsys.com:3478?transport=udp",
            "turn:bn-turn1.xirsys.com:80?transport=tcp",
            "turn:bn-turn1.xirsys.com:3478?transport=tcp",
            "turns:bn-turn1.xirsys.com:443?transport=tcp",
            "turns:bn-turn1.xirsys.com:5349?transport=tcp",
        ]
        servers.append(IceServer(
            urls=turn_urls,
            username=constants.TURN_USERNAME,
            credential=constants.TURN_PASSWORD,
        ))
        logger.info("TURN_SERVER_CONFIGURED: xirsys")
    else:
        logger.warning("NO_TURN_SERVER: WebRTC will fail behind NAT/K8s without TURN")
    return servers

def _get_transport_mode() -> str:
    """Get current transport mode from DB config (synced periodically)."""
    from src.repository.config_repository import config_repo
    return config_repo.get_transport_mode()

router = APIRouter(tags=["bot"])

# Port the app listens on inside each pod
_POD_PORT = 3008

async def _proxy_to_pod(pod_ip: str, method: str, path: str, body: dict = None) -> dict:
    """Proxy a request to another pod internally (pod-to-pod within cluster)."""
    url = f"http://{pod_ip}:{_POD_PORT}{path}"
    logger.info(f"PROXY_REQUEST: method={method} url={url}")
    try:
        async with aiohttp.ClientSession() as session:
            if method == "POST":
                async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
            elif method == "PATCH":
                async with session.patch(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"PROXY_FAILED: url={url} error={e}")
        raise HTTPException(status_code=502, detail=f"Proxy to session pod failed: {e}")

# Track active sessions: {task: session_id}
active_sessions: dict[asyncio.Task, str] = {}

# SmallWebRTC request handlers per session: {session_id: handler}
session_handlers: dict[str, SmallWebRTCRequestHandler] = {}

# Session params stored between /connect and /offer: {session_id: params_dict}
session_params: dict[str, dict] = {}

def get_active_sessions() -> dict:
    """Get reference to active sessions for cleanup."""
    return active_sessions

async def cleanup_active_sessions():
    """Cleanup active sessions on shutdown."""
    if active_sessions:
        logger.info(f"SHUTDOWN_CLEANUP: active_sessions={len(active_sessions)}")

        # Cancel all bot tasks
        for task in list(active_sessions.keys()):
            task.cancel()
        await asyncio.gather(*active_sessions.keys(), return_exceptions=True)

        # Close all SmallWebRTC handlers
        for sid, handler in list(session_handlers.items()):
            try:
                await handler.close()
            except Exception as e:
                logger.error(f"HANDLER_CLOSE_FAILED: session={sid} error={e}")
        session_handlers.clear()
        session_params.clear()

        logger.info(f"SHUTDOWN_CLEANUP_DONE: sessions={len(active_sessions)}")

    # Cleanup Redis sessions for this pod
    if redis_available():
        pod_name = _get_pod_name()
        if pod_name:
            cleaned_count = await cleanup_pod_sessions(pod_name)
            logger.info(f"REDIS_SHUTDOWN_CLEANUP: pod={pod_name} sessions_cleaned={cleaned_count}")

def _register_bot_task(task: asyncio.Task, session_id: str, params: dict):
    """Register a bot task for tracking and cleanup (shared by both transports)."""
    active_sessions[task] = session_id
    transport = params.get("transport", "unknown")

    # Add to Redis session tracking
    if redis_available():
        asyncio.create_task(add_active_session(session_id, params.get("device_id"), params.get("persona_id")))

    def cleanup_session(t):
        sid = active_sessions.pop(t, None)
        remaining = len(active_sessions)
        logger.info(f"TASK_COMPLETE: session={sid} transport={transport} remaining_active_sessions={remaining}")
        on_session_complete(active_sessions)
        # Cleanup handler and params
        session_handlers.pop(sid, None)
        session_params.pop(sid, None)
        if sid and redis_available():
            try:
                loop = asyncio.get_event_loop()
                if loop.is_running():
                    asyncio.create_task(remove_active_session(sid))
                    logger.info(f"REDIS_CLEANUP_SCHEDULED: session={sid}")
                else:
                    logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=loop_not_running")
            except RuntimeError:
                logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=no_event_loop")
            except Exception as e:
                logger.error(f"REDIS_CLEANUP_CALLBACK_ERROR: session={sid} error={e}")

    task.add_done_callback(cleanup_session)

@router.get("/transport-type")
async def get_transport_type():
    """Lightweight endpoint — returns current transport mode without creating a session."""
    mode = _get_transport_mode()
    logger.info(f"TRANSPORT_TYPE_CHECK: mode={mode}")
    return {"transport": mode}

@router.post("/start")
async def bot_connect(data: BotConnectRequest) -> Dict[Any, Any]:
    """Unified connection endpoint — returns transport-specific response.

    Transport mode is controlled by DB config (transport_config.mode).
    - SmallWebRTC: returns {"transport": "smallwebrtc", "sessionId": "..."}
    - Daily: returns {"transport": "daily", "room_url": "...", "token": "..."}
    """
    logger.info(f"CONNECT_INPUT_BODY: {data.model_dump()}")
    device_id = data.device_id
    version = data.version
    persona_id = data.persona_id or data.character_id
    app_key = data.app_key
    language = data.language
    source = data.source

    transport_mode = _get_transport_mode()

    try:
        logger.info(f"SESSION_START: device_id={device_id or 'common'} version={version} persona_id={persona_id or 'default'} app_key={app_key or 'legacy'} language={language or 'default'} source={source or 'app'} transport_mode={transport_mode}")

        session_id = str(uuid.uuid4())

        params = {
            "device_id": device_id,
            "version": version,
            "persona_id": persona_id,
            "app_key": app_key,
            "language": language,
            "source": source,
        }

        if transport_mode == "daily":
            return await _start_daily_session(session_id, params)
        else:
            return await _start_smallwebrtc_session(session_id, params)

    except Exception as e:
        log_error("SESSION_START_FAILED", e, device_id=device_id or "common", transport=transport_mode)
        raise HTTPException(status_code=500, detail=str(e))

async def _start_smallwebrtc_session(session_id: str, params: dict) -> dict:
    """SmallWebRTC flow: return sessionId, client posts SDP offer next."""
    handler = SmallWebRTCRequestHandler(ice_servers=_get_ice_servers())
    session_handlers[session_id] = handler
    params["transport"] = "smallwebrtc"
    session_params[session_id] = params

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Store session → pod_ip mapping in Redis for cross-pod routing
    if redis_available():
        await set_session_pod(session_id)

    # Return ICE servers so client can configure RTCPeerConnection with TURN
    ice_servers_response = []
    for server in _get_ice_servers():
        entry = {"urls": server.urls if isinstance(server.urls, list) else [server.urls]}
        if server.username:
            entry["username"] = server.username
        if server.credential:
            entry["credential"] = server.credential
        ice_servers_response.append(entry)

    return {"transport": "smallwebrtc", "sessionId": session_id, "iceConfig": {"iceServers": ice_servers_response}}

async def _start_daily_session(session_id: str, params: dict) -> dict:
    """Daily flow: create room + token, start bot immediately, return credentials."""
    from src.core.daily_client import DailyClient, DailyAPIError

    params["transport"] = "daily"
    daily_client = DailyClient()
    room_url, room_name, token = await daily_client.create_room_with_token()

    # Start bot task immediately (Daily bot joins room and waits for participant)
    task = asyncio.create_task(run_bot_webrtc_global_agent(
        None,  # no webrtc_connection for Daily
        device_id=params.get("device_id"),
        session_id=session_id,
        version=params.get("version"),
        app_key=params.get("app_key"),
        language=params.get("language"),
        source=params.get("source"),
        persona_id=params.get("persona_id"),
        transport_type="daily",
        room_url=room_url,
        token=token,
    ))
    _register_bot_task(task, session_id, params)

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=daily room={room_name} device_id={params.get('device_id') or 'common'}")

    return {"transport": "daily", "room_url": room_url, "token": token}

# ---- SmallWebRTC SDP signaling endpoints ----

class OfferRequest(BaseModel):
    sdp: str
    type: str
    pc_id: Optional[str] = None
    restart_pc: Optional[bool] = None

class PatchCandidateItem(BaseModel):
    candidate: str
    sdpMid: str
    sdpMLineIndex: int

class PatchRequest(BaseModel):
    pc_id: str
    candidates: List[PatchCandidateItem]

@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        raise HTTPException(status_code=404, detail="Session not found")

    params = session_params.get(session_id, {})

    async def on_connection(webrtc_connection):
        """Called when a new WebRTC connection is established."""
        task = asyncio.create_task(run_bot_webrtc_global_agent(
            webrtc_connection,
            device_id=params.get("device_id"),
            session_id=session_id,
            version=params.get("version"),
            app_key=params.get("app_key"),
            language=params.get("language"),
            source=params.get("source"),
            persona_id=params.get("persona_id"),
            transport_type="smallwebrtc",
        ))
        _register_bot_task(task, session_id, params)

    try:
        request = SmallWebRTCRequest(
            sdp=data.sdp,
            type=data.type,
            pc_id=data.pc_id,
            restart_pc=data.restart_pc,
        )
        answer = await handler.handle_web_request(request, on_connection)
        if answer:
            return answer
        raise HTTPException(status_code=500, detail="Failed to create SDP answer")
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"OFFER_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

@router.patch("/sessions/{session_id}/api/offer")
async def handle_ice_candidates(session_id: str, request: Request):
    """Handle ICE candidate trickle from client."""
    raw_body = await request.json()
    logger.info(f"ICE_PATCH_RAW: session={session_id} body={raw_body}")

    handler = session_handlers.get(session_id)
    if not handler:
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")

    try:
        # Parse flexibly - handle both camelCase and snake_case
        pc_id = raw_body.get("pc_id") or raw_body.get("pcId")
        candidates_raw = raw_body.get("candidates", [])

        candidates = []
        for c in candidates_raw:
            candidates.append(IceCandidate(
                candidate=c.get("candidate", ""),
                sdp_mid=c.get("sdpMid") or c.get("sdp_mid", ""),
                sdp_mline_index=c.get("sdpMLineIndex") or c.get("sdp_mline_index", 0),
            ))

        patch_request = SmallWebRTCPatchRequest(
            pc_id=pc_id,
            candidates=candidates,
        )
        await handler.handle_patch_request(patch_request)
        return {"status": "ok"}
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"ICE_PATCH_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

+        # Session not on this pod — check Redis for the correct pod and proxy

+        pod_ip = await get_session_pod(session_id) if redis_available() else None

+        my_ip = os.environ.get("POD_IP")

+            logger.info(f"OFFER_PROXY: session={session_id} from={my_ip} to={pod_ip}")

+            return await _proxy_to_pod(pod_ip, "POST", f"/api/v4/sessions/{session_id}/api/offer", data.model_dump())


Now update the `/patch` endpoint too:


    handler = session_handlers.get(session_id)
    if not handler:
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")

    handler = session_handlers.get(session_id)
    if not handler:
        # Session not on this pod — proxy to the correct pod
        pod_ip = await get_session_pod(session_id) if redis_available() else None
        my_ip = os.environ.get("POD_IP")
        if pod_ip and pod_ip != my_ip:
            logger.info(f"ICE_PATCH_PROXY: session={session_id} from={my_ip} to={pod_ip}")
            return await _proxy_to_pod(pod_ip, "PATCH", f"/api/v4/sessions/{session_id}/api/offer", raw_body)
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")


    handler = session_handlers.get(session_id)
    if not handler:
        # Session not on this pod — proxy to the correct pod
        pod_ip = await get_session_pod(session_id) if redis_available() else None
        my_ip = os.environ.get("POD_IP")
        if pod_ip and pod_ip != my_ip:
            logger.info(f"ICE_PATCH_PROXY: session={session_id} from={my_ip} to={pod_ip}")
            return await _proxy_to_pod(pod_ip, "PATCH", f"/api/v4/sessions/{session_id}/api/offer", raw_body)
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")

"""Bot connection API endpoints (SmallWebRTC + Daily dual transport)."""

import asyncio
import os
import uuid
from typing import Any, Dict, List, Optional

import aiohttp
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pydantic import BaseModel

from pipecat.transports.smallwebrtc.connection import IceServer
from pipecat.transports.smallwebrtc.request_handler import (
    SmallWebRTCRequestHandler,
    SmallWebRTCRequest,
    SmallWebRTCPatchRequest,
    IceCandidate,
)

from src.bot import run_bot_webrtc_global_agent
from src.config import constants
from src.core.logging import log_error
from src.core.redis import (
    add_active_session, remove_active_session, cleanup_pod_sessions,
    redis_available, _get_pod_name,
    set_session_pod, get_session_pod, delete_session_pod,
)
from src.core.pod_lifecycle import on_session_complete
from src.dto.common import BotConnectRequest

def _get_ice_servers() -> list[IceServer]:
    """Build ICE server list from config. TURN is required for cloud/NAT traversal."""
    servers = [IceServer(urls="stun:bn-turn1.xirsys.com")]
    if constants.TURN_USERNAME and constants.TURN_PASSWORD:
        turn_urls = [
            "turn:bn-turn1.xirsys.com:80?transport=udp",
            "turn:bn-turn1.xirsys.com:3478?transport=udp",
            "turn:bn-turn1.xirsys.com:80?transport=tcp",
            "turn:bn-turn1.xirsys.com:3478?transport=tcp",
            "turns:bn-turn1.xirsys.com:443?transport=tcp",
            "turns:bn-turn1.xirsys.com:5349?transport=tcp",
        ]
        servers.append(IceServer(
            urls=turn_urls,
            username=constants.TURN_USERNAME,
            credential=constants.TURN_PASSWORD,
        ))
        logger.info("TURN_SERVER_CONFIGURED: xirsys")
    else:
        logger.warning("NO_TURN_SERVER: WebRTC will fail behind NAT/K8s without TURN")
    return servers

def _get_transport_mode() -> str:
    """Get current transport mode from DB config (synced periodically)."""
    from src.repository.config_repository import config_repo
    return config_repo.get_transport_mode()

router = APIRouter(tags=["bot"])

# Port the app listens on inside each pod
_POD_PORT = 3008

async def _proxy_to_pod(pod_ip: str, method: str, path: str, body: dict = None) -> dict:
    """Proxy a request to another pod internally (pod-to-pod within cluster)."""
    url = f"http://{pod_ip}:{_POD_PORT}{path}"
    logger.info(f"PROXY_REQUEST: method={method} url={url}")
    try:
        async with aiohttp.ClientSession() as session:
            if method == "POST":
                async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
            elif method == "PATCH":
                async with session.patch(url, json=body, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    if resp.status >= 400:
                        text = await resp.text()
                        raise HTTPException(status_code=resp.status, detail=text)
                    return await resp.json()
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"PROXY_FAILED: url={url} error={e}")
        raise HTTPException(status_code=502, detail=f"Proxy to session pod failed: {e}")

# Track active sessions: {task: session_id}
active_sessions: dict[asyncio.Task, str] = {}

# SmallWebRTC request handlers per session: {session_id: handler}
session_handlers: dict[str, SmallWebRTCRequestHandler] = {}

# Session params stored between /connect and /offer: {session_id: params_dict}
session_params: dict[str, dict] = {}

def get_active_sessions() -> dict:
    """Get reference to active sessions for cleanup."""
    return active_sessions

async def cleanup_active_sessions():
    """Cleanup active sessions on shutdown."""
    if active_sessions:
        logger.info(f"SHUTDOWN_CLEANUP: active_sessions={len(active_sessions)}")

        # Cancel all bot tasks
        for task in list(active_sessions.keys()):
            task.cancel()
        await asyncio.gather(*active_sessions.keys(), return_exceptions=True)

        # Close all SmallWebRTC handlers
        for sid, handler in list(session_handlers.items()):
            try:
                await handler.close()
            except Exception as e:
                logger.error(f"HANDLER_CLOSE_FAILED: session={sid} error={e}")
        session_handlers.clear()
        session_params.clear()

        logger.info(f"SHUTDOWN_CLEANUP_DONE: sessions={len(active_sessions)}")

    # Cleanup Redis sessions for this pod
    if redis_available():
        pod_name = _get_pod_name()
        if pod_name:
            cleaned_count = await cleanup_pod_sessions(pod_name)
            logger.info(f"REDIS_SHUTDOWN_CLEANUP: pod={pod_name} sessions_cleaned={cleaned_count}")

def _register_bot_task(task: asyncio.Task, session_id: str, params: dict):
    """Register a bot task for tracking and cleanup (shared by both transports)."""
    active_sessions[task] = session_id
    transport = params.get("transport", "unknown")

    # Add to Redis session tracking
    if redis_available():
        asyncio.create_task(add_active_session(session_id, params.get("device_id"), params.get("persona_id")))

    def cleanup_session(t):
        sid = active_sessions.pop(t, None)
        remaining = len(active_sessions)
        logger.info(f"TASK_COMPLETE: session={sid} transport={transport} remaining_active_sessions={remaining}")
        on_session_complete(active_sessions)
        # Cleanup handler and params
        session_handlers.pop(sid, None)
        session_params.pop(sid, None)
        if sid and redis_available():
            try:
                loop = asyncio.get_event_loop()
                if loop.is_running():
                    asyncio.create_task(remove_active_session(sid))
                    logger.info(f"REDIS_CLEANUP_SCHEDULED: session={sid}")
                else:
                    logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=loop_not_running")
            except RuntimeError:
                logger.warning(f"REDIS_CLEANUP_SKIPPED: session={sid} reason=no_event_loop")
            except Exception as e:
                logger.error(f"REDIS_CLEANUP_CALLBACK_ERROR: session={sid} error={e}")

    task.add_done_callback(cleanup_session)

@router.get("/transport-type")
async def get_transport_type():
    """Lightweight endpoint — returns current transport mode without creating a session."""
    mode = _get_transport_mode()
    logger.info(f"TRANSPORT_TYPE_CHECK: mode={mode}")
    return {"transport": mode}

@router.post("/start")
async def bot_connect(data: BotConnectRequest) -> Dict[Any, Any]:
    """Unified connection endpoint — returns transport-specific response.

    Transport mode is controlled by DB config (transport_config.mode).
    - SmallWebRTC: returns {"transport": "smallwebrtc", "sessionId": "..."}
    - Daily: returns {"transport": "daily", "room_url": "...", "token": "..."}
    """
    logger.info(f"CONNECT_INPUT_BODY: {data.model_dump()}")
    device_id = data.device_id
    version = data.version
    persona_id = data.persona_id or data.character_id
    app_key = data.app_key
    language = data.language
    source = data.source

    transport_mode = _get_transport_mode()

    try:
        logger.info(f"SESSION_START: device_id={device_id or 'common'} version={version} persona_id={persona_id or 'default'} app_key={app_key or 'legacy'} language={language or 'default'} source={source or 'app'} transport_mode={transport_mode}")

        session_id = str(uuid.uuid4())

        params = {
            "device_id": device_id,
            "version": version,
            "persona_id": persona_id,
            "app_key": app_key,
            "language": language,
            "source": source,
        }

        if transport_mode == "daily":
            return await _start_daily_session(session_id, params)
        else:
            return await _start_smallwebrtc_session(session_id, params)

    except Exception as e:
        log_error("SESSION_START_FAILED", e, device_id=device_id or "common", transport=transport_mode)
        raise HTTPException(status_code=500, detail=str(e))

async def _start_smallwebrtc_session(session_id: str, params: dict) -> dict:
    """SmallWebRTC flow: return sessionId, client posts SDP offer next."""
    handler = SmallWebRTCRequestHandler(ice_servers=_get_ice_servers())
    session_handlers[session_id] = handler
    params["transport"] = "smallwebrtc"
    session_params[session_id] = params

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=smallwebrtc device_id={params.get('device_id') or 'common'}")

    # Store session → pod_ip mapping in Redis for cross-pod routing
    if redis_available():
        await set_session_pod(session_id)

    # Return ICE servers so client can configure RTCPeerConnection with TURN
    ice_servers_response = []
    for server in _get_ice_servers():
        entry = {"urls": server.urls if isinstance(server.urls, list) else [server.urls]}
        if server.username:
            entry["username"] = server.username
        if server.credential:
            entry["credential"] = server.credential
        ice_servers_response.append(entry)

    return {"transport": "smallwebrtc", "sessionId": session_id, "iceConfig": {"iceServers": ice_servers_response}}

async def _start_daily_session(session_id: str, params: dict) -> dict:
    """Daily flow: create room + token, start bot immediately, return credentials."""
    from src.core.daily_client import DailyClient, DailyAPIError

    params["transport"] = "daily"
    daily_client = DailyClient()
    room_url, room_name, token = await daily_client.create_room_with_token()

    # Start bot task immediately (Daily bot joins room and waits for participant)
    task = asyncio.create_task(run_bot_webrtc_global_agent(
        None,  # no webrtc_connection for Daily
        device_id=params.get("device_id"),
        session_id=session_id,
        version=params.get("version"),
        app_key=params.get("app_key"),
        language=params.get("language"),
        source=params.get("source"),
        persona_id=params.get("persona_id"),
        transport_type="daily",
        room_url=room_url,
        token=token,
    ))
    _register_bot_task(task, session_id, params)

    logger.info(f"SESSION_CREATED: session_id={session_id} transport=daily room={room_name} device_id={params.get('device_id') or 'common'}")

    return {"transport": "daily", "room_url": room_url, "token": token}

# ---- SmallWebRTC SDP signaling endpoints ----

class OfferRequest(BaseModel):
    sdp: str
    type: str
    pc_id: Optional[str] = None
    restart_pc: Optional[bool] = None

class PatchCandidateItem(BaseModel):
    candidate: str
    sdpMid: str
    sdpMLineIndex: int

class PatchRequest(BaseModel):
    pc_id: str
    candidates: List[PatchCandidateItem]

@router.post("/sessions/{session_id}/api/offer")
async def handle_offer(session_id: str, data: OfferRequest) -> Dict[str, Any]:
    """Handle SDP offer from client — creates WebRTC connection and starts bot pipeline."""
    handler = session_handlers.get(session_id)
    if not handler:
        # Session not on this pod — check Redis for the correct pod and proxy
        pod_ip = await get_session_pod(session_id) if redis_available() else None
        my_ip = os.environ.get("POD_IP")
        if pod_ip and pod_ip != my_ip:
            logger.info(f"OFFER_PROXY: session={session_id} from={my_ip} to={pod_ip}")
            return await _proxy_to_pod(pod_ip, "POST", f"/api/v4/sessions/{session_id}/api/offer", data.model_dump())
        raise HTTPException(status_code=404, detail="Session not found")

    params = session_params.get(session_id, {})

    async def on_connection(webrtc_connection):
        """Called when a new WebRTC connection is established."""
        task = asyncio.create_task(run_bot_webrtc_global_agent(
            webrtc_connection,
            device_id=params.get("device_id"),
            session_id=session_id,
            version=params.get("version"),
            app_key=params.get("app_key"),
            language=params.get("language"),
            source=params.get("source"),
            persona_id=params.get("persona_id"),
            transport_type="smallwebrtc",
        ))
        _register_bot_task(task, session_id, params)

    try:
        request = SmallWebRTCRequest(
            sdp=data.sdp,
            type=data.type,
            pc_id=data.pc_id,
            restart_pc=data.restart_pc,
        )
        answer = await handler.handle_web_request(request, on_connection)
        if answer:
            return answer
        raise HTTPException(status_code=500, detail="Failed to create SDP answer")
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"OFFER_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

@router.patch("/sessions/{session_id}/api/offer")
async def handle_ice_candidates(session_id: str, request: Request):
    """Handle ICE candidate trickle from client."""
    raw_body = await request.json()
    logger.info(f"ICE_PATCH_RAW: session={session_id} body={raw_body}")

    handler = session_handlers.get(session_id)
    if not handler:
        logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}")
        raise HTTPException(status_code=404, detail="Session not found")

    try:
        # Parse flexibly - handle both camelCase and snake_case
        pc_id = raw_body.get("pc_id") or raw_body.get("pcId")
        candidates_raw = raw_body.get("candidates", [])

        candidates = []
        for c in candidates_raw:
            candidates.append(IceCandidate(
                candidate=c.get("candidate", ""),
                sdp_mid=c.get("sdpMid") or c.get("sdp_mid", ""),
                sdp_mline_index=c.get("sdpMLineIndex") or c.get("sdp_mline_index", 0),
            ))

        patch_request = SmallWebRTCPatchRequest(
            pc_id=pc_id,
            candidates=candidates,
        )
        await handler.handle_patch_request(patch_request)
        return {"status": "ok"}
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"ICE_PATCH_FAILED: session={session_id} error={e}")
        raise HTTPException(status_code=500, detail=str(e))

+        # Session not on this pod — proxy to the correct pod

+            logger.info(f"ICE_PATCH_PROXY: session={session_id} from={my_ip} to={pod_ip}")

+            return await _proxy_to_pod(pod_ip, "PATCH", f"/api/v4/sessions/{session_id}/api/offer", raw_body)


Now add cleanup of session-pod mapping when session ends:


                    asyncio.create_task(remove_active_session(sid))
                    logger.info(f"REDIS_CLEANUP_SCHEDULED: session={sid}")

                    asyncio.create_task(remove_active_session(sid))
                    asyncio.create_task(delete_session_pod(sid))
                    logger.info(f"REDIS_CLEANUP_SCHEDULED: session={sid}")