     1→"""Shared Redis client for cross-pod caching (ElastiCache).
     2→
     3→All pods connect to the same Redis instance. This eliminates redundant
     4→DB reads and makes config/device data available cluster-wide.
     5→
     6→Usage:
     7→    from utils.redis_client import get_redis, redis_available
     8→
     9→    if redis_available():
    10→        value = await get_redis().get("maya:some_key")
    11→"""
    12→
    13→import asyncio
    14→import json
    15→import os
    16→import time
    17→from typing import Any, Dict, List, Optional
    18→
    19→from loguru import logger
    20→
    21→from utils import constants
    22→
    23→
    24→# ---------------------------------------------------------------------------
    25→# Module-level singleton
    26→# ---------------------------------------------------------------------------
    27→_redis_pool = None
    28→_redis_available = False
    29→_init_attempted = False
    30→
    31→
    32→async def init_redis() -> bool:
    33→    """Initialize the async Redis connection pool. Safe to call multiple times."""
    34→    global _redis_pool, _redis_available, _init_attempted
    35→
    36→    if _init_attempted:
    37→        return _redis_available
    38→
    39→    _init_attempted = True
    40→
    41→    if not constants.REDIS_ENABLED or not constants.REDIS_URL:
    42→        logger.info("REDIS_DISABLED: reason=not_configured")
    43→        return False
    44→
    45→    try:
    46→        import redis.asyncio as aioredis
    47→
    48→        _redis_pool = aioredis.from_url(
    49→            constants.REDIS_URL,
    50→            socket_connect_timeout=constants.REDIS_CONNECT_TIMEOUT,
    51→            socket_timeout=constants.REDIS_SOCKET_TIMEOUT,
    52→            max_connections=constants.REDIS_MAX_CONNECTIONS,
    53→            decode_responses=True,  # Return str instead of bytes
    54→            retry_on_timeout=True,
    55→        )
    56→
    57→        # Verify connectivity
    58→        await _redis_pool.ping()
    59→        _redis_available = True
    60→        logger.info(f"REDIS_CONNECTED: url={constants.REDIS_URL.split('@')[-1] if '@' in constants.REDIS_URL else constants.REDIS_URL.replace('redis://', '')}")
    61→        return True
    62→
    63→    except ImportError:
    64→        logger.warning("REDIS_DISABLED: reason=redis_package_not_installed")
    65→        return False
    66→    except Exception as e:
    67→        logger.error(f"REDIS_CONNECT_FAILED: error={e} error_type={type(e).__name__}")
    68→        _redis_available = False
    69→        return False
    70→
    71→
    72→def redis_available() -> bool:
    73→    """Check if Redis is connected and available."""
    74→    return _redis_available
    75→
    76→
    77→def get_redis():
    78→    """Get the Redis connection pool. Returns None if Redis is unavailable."""
    79→    return _redis_pool if _redis_available else None
    80→
    81→
    82→def _key(name: str) -> str:
    83→    """Build a namespaced Redis key: maya:<name>"""
    84→    return f"{constants.REDIS_KEY_PREFIX}{name}"
    85→
    86→
    87→# ---------------------------------------------------------------------------
    88→# Core cache operations (all fire-and-forget safe)
    89→# ---------------------------------------------------------------------------
    90→
    91→async def cache_get(key: str) -> Optional[str]:
    92→    """Get a value from Redis. Returns None on miss or error."""
    93→    if not _redis_available:
    94→        return None
    95→    try:
    96→        return await _redis_pool.get(_key(key))
    97→    except Exception as e:
    98→        logger.debug(f"REDIS_GET_ERROR: key={key} error={e}")
    99→        return None
   100→
   101→
   102→async def cache_get_json(key: str) -> Optional[Any]:
   103→    """Get and JSON-decode a value from Redis."""
   104→    raw = await cache_get(key)
   105→    if raw is None:
   106→        return None
   107→    try:
   108→        return json.loads(raw)
   109→    except (json.JSONDecodeError, TypeError):
   110→        return None
   111→
   112→
   113→async def cache_set(key: str, value: str, ttl: int = None) -> bool:
   114→    """Set a string value in Redis with optional TTL (seconds)."""
   115→    if not _redis_available:
   116→        return False
   117→    try:
   118→        ttl = ttl or constants.REDIS_DEFAULT_TTL
   119→        await _redis_pool.set(_key(key), value, ex=ttl)
   120→        return True
   121→    except Exception as e:
   122→        logger.debug(f"REDIS_SET_ERROR: key={key} error={e}")
   123→        return False
   124→
   125→
   126→async def cache_set_json(key: str, value: Any, ttl: int = None) -> bool:
   127→    """JSON-encode and set a value in Redis."""
   128→    try:
   129→        return await cache_set(key, json.dumps(value, default=str), ttl)
   130→    except (TypeError, ValueError) as e:
   131→        logger.debug(f"REDIS_SET_JSON_ERROR: key={key} error={e}")
   132→        return False
   133→
   134→
   135→async def cache_delete(key: str) -> bool:
   136→    """Delete a key from Redis (cache invalidation)."""
   137→    if not _redis_available:
   138→        return False
   139→    try:
   140→        await _redis_pool.delete(_key(key))
   141→        return True
   142→    except Exception as e:
   143→        logger.debug(f"REDIS_DELETE_ERROR: key={key} error={e}")
   144→        return False
   145→
   146→
   147→async def cache_delete_pattern(pattern: str) -> int:
   148→    """Delete all keys matching a pattern (e.g. 'device:ABC*'). Returns count deleted."""
   149→    if not _redis_available:
   150→        return 0


routes/explore.py:16:        if not turso_client._is_explore_cache_valid():
routes/explore.py:17:            await turso_client.refresh_explore_cache()
routes/explore.py:49:        if not turso_client._is_explore_cache_valid():
routes/explore.py:50:            await turso_client.refresh_explore_cache()
utils/config_sync.py:44:            asyncio.create_task(self._sync_explore_cache()),
utils/config_sync.py:46:            asyncio.create_task(self._sync_cf_flux_config()),
utils/config_sync.py:334:    async def _sync_explore_cache(self):
utils/config_sync.py:335:        from utils.turso_client import refresh_explore_cache, get_cached_explore, set_cached_explore
utils/config_sync.py:338:            refresh_explore_cache, get_cached_explore, set_cached_explore,
utils/config_sync.py:350:    async def _sync_cf_flux_config(self):
utils/config_sync.py:351:        from utils.turso_client import refresh_cf_flux_config, get_cached_cf_flux_config, set_cached_cf_flux_config
utils/config_sync.py:354:            refresh_cf_flux_config, get_cached_cf_flux_config, set_cached_cf_flux_config,
utils/turso_client.py:2313:_explore_cache = {
utils/turso_client.py:2323:async def refresh_explore_cache():
utils/turso_client.py:2325:    global _explore_cache
utils/turso_client.py:2342:        }, "refresh_explore_cache_categories")
utils/turso_client.py:2370:        }, "refresh_explore_cache_items")
utils/turso_client.py:2396:        _explore_cache = {
utils/turso_client.py:2412:def _is_explore_cache_valid() -> bool:
utils/turso_client.py:2414:    if not _explore_cache["is_loaded"]:
utils/turso_client.py:2416:    if time.time() - _explore_cache["last_refresh"] > EXPLORE_CACHE_TTL:
utils/turso_client.py:2423:    if not _is_explore_cache_valid():
utils/turso_client.py:2429:        cat for cat in _explore_cache["categories"]
utils/turso_client.py:2430:        if len(_explore_cache["items_by_category"].get(cat["id"], [])) > 0
utils/turso_client.py:2457:    if not _is_explore_cache_valid():
utils/turso_client.py:2476:        for cat in _explore_cache["categories"]:
utils/turso_client.py:2492:        items = _explore_cache["items_by_category"].get(category_id, [])
utils/turso_client.py:2494:        items = _explore_cache["all_items"]
utils/turso_client.py:2518:    if not _is_explore_cache_valid():
utils/turso_client.py:2524:    for cat in _explore_cache["categories"]:
utils/turso_client.py:2525:        cat_items = _explore_cache["items_by_category"].get(cat["id"], [])
utils/turso_client.py:2543:    if not _is_explore_cache_valid():
utils/turso_client.py:2544:        await refresh_explore_cache()
utils/turso_client.py:2552:    if not _is_explore_cache_valid():
utils/turso_client.py:2553:        await refresh_explore_cache()
utils/turso_client.py:2874:_video_config_cache = {
utils/turso_client.py:2883:    global _video_config_cache
utils/turso_client.py:2906:                    _video_config_cache[key] = value.lower() == 'true'
utils/turso_client.py:2908:        logger.debug(f"DB_REFRESH_VIDEO_CONFIG: config={_video_config_cache} duration_ms={duration_ms:.2f}")
utils/turso_client.py:2917:    return _video_config_cache.copy()
utils/turso_client.py:2922:    return _video_config_cache.get("video_generation_global", False)
utils/turso_client.py:2927:    return _video_config_cache.get("show_explore_page", True)
utils/turso_client.py:2932:    return _video_config_cache.get("show_video_premium_button", True)
utils/turso_client.py:2940:_characters_cache: Optional[List[Dict[str, Any]]] = None
utils/turso_client.py:2941:_characters_cache_time: float = 0
utils/turso_client.py:2945:_prompt_cache_time: Dict[str, float] = {}
utils/turso_client.py:3033:    global _character_prompts_cache, _prompt_cache_time
utils/turso_client.py:3037:        cache_age = time.time() - _prompt_cache_time.get(character_id, 0)
utils/turso_client.py:3067:                _prompt_cache_time[character_id] = time.time()
utils/turso_client.py:3083:    global _characters_cache, _characters_cache_time
utils/turso_client.py:3086:    if use_cache and _characters_cache is not None:
utils/turso_client.py:3087:        cache_age = time.time() - _characters_cache_time
utils/turso_client.py:3089:            logger.debug(f"CHARACTERS_CACHE_HIT: age={cache_age:.1f}s count={len(_characters_cache)}")
utils/turso_client.py:3090:            return _characters_cache
utils/turso_client.py:3095:        return _characters_cache or []  # Return stale cache if DB unavailable
utils/turso_client.py:3160:        _characters_cache = characters
utils/turso_client.py:3161:        _characters_cache_time = time.time()
utils/turso_client.py:3170:        return _characters_cache or []
utils/turso_client.py:3514:        "video": _video_config_cache,
utils/turso_client.py:3602:    return _video_config_cache.copy()
utils/turso_client.py:3605:    global _video_config_cache
utils/turso_client.py:3607:        _video_config_cache.update(val)
utils/turso_client.py:3611:    return _explore_cache.copy()
utils/turso_client.py:3614:    global _explore_cache
utils/turso_client.py:3616:        _explore_cache.update(val)
utils/turso_client.py:3631:_cf_flux_config: Optional[Dict[str, Any]] = None
utils/turso_client.py:3641:async def refresh_cf_flux_config():
utils/turso_client.py:3643:    global _cf_flux_config
utils/turso_client.py:3647:        if _cf_flux_config is None:
utils/turso_client.py:3648:            _cf_flux_config = _CF_FLUX_DEFAULT
utils/turso_client.py:3656:        }, "refresh_cf_flux_config")
utils/turso_client.py:3673:                    if _cf_flux_config != new_config:
utils/turso_client.py:3675:                    _cf_flux_config = new_config
utils/turso_client.py:3680:        if _cf_flux_config is None:
utils/turso_client.py:3681:            _cf_flux_config = _CF_FLUX_DEFAULT
utils/turso_client.py:3684:        if _cf_flux_config is None:
utils/turso_client.py:3685:            _cf_flux_config = _CF_FLUX_DEFAULT
utils/turso_client.py:3688:def get_cf_flux_config() -> Dict[str, Any]:
utils/turso_client.py:3690:    global _cf_flux_config
utils/turso_client.py:3691:    if _cf_flux_config is None:
utils/turso_client.py:3693:    return _cf_flux_config
utils/turso_client.py:3698:    config = get_cf_flux_config()
utils/turso_client.py:3702:def get_cached_cf_flux_config():
utils/turso_client.py:3703:    return _cf_flux_config
utils/turso_client.py:3705:def set_cached_cf_flux_config(val):
utils/turso_client.py:3706:    global _cf_flux_config
utils/turso_client.py:3708:        _cf_flux_config = val
utils/turso_client.py:3712:    return _characters_cache
utils/turso_client.py:3715:    global _characters_cache, _characters_cache_time
utils/turso_client.py:3718:        _characters_cache = val
utils/turso_client.py:3719:        _characters_cache_time = _t.time()

[Showing results with pagination = limit: 150, offset: 0]


Let me check the session state and look for more potential issues:


     1→"""Session state management for tracking generation requests and queue processing."""
     2→
     3→import asyncio
     4→import time
     5→import traceback
     6→from loguru import logger
     7→
     8→queue_logger = logger.bind(queue=True)
     9→
    10→
    11→class GenerationRequest:
    12→    def __init__(self, request_type: str, request_id: str, handler_func, params):
    13→        self.request_type = request_type
    14→        self.request_id = request_id
    15→        self.handler_func = handler_func
    16→        self.params = params
    17→        self.queued_at = time.time()
    18→
    19→    def __repr__(self):
    20→        return f"GenerationRequest({self.request_type}, {self.request_id})"
    21→
    22→
    23→class SessionState:
    24→    def __init__(self):
    25→        self.is_generating = False
    26→        self.last_generation_time = 0
    27→        self.current_language = "hi"
    28→        self.tts_service = None
    29→
    30→        self.image_queue = asyncio.Queue()
    31→        self.image_processor_task = None
    32→        self.is_image_busy = False
    33→
    34→        self.audio_queue = asyncio.Queue()
    35→        self.audio_processor_task = None
    36→        self.is_audio_busy = False
    37→
    38→        self.total_queued = 0
    39→        self.total_processed = 0
    40→
    41→    async def start_queue_processor(self):
    42→        if self.image_processor_task is None:
    43→            self.image_processor_task = asyncio.create_task(self._process_image_queue())
    44→
    45→        if self.audio_processor_task is None:
    46→            self.audio_processor_task = asyncio.create_task(self._process_audio_queue())
    47→
    48→    async def stop_queue_processor(self):
    49→        for task in [self.image_processor_task, self.audio_processor_task]:
    50→            if task and not task.done():
    51→                task.cancel()
    52→                try:
    53→                    await task
    54→                except asyncio.CancelledError:
    55→                    pass
    56→        self.image_processor_task = None
    57→        self.audio_processor_task = None
    58→
    59→        # Drain and clear queues to free any pending requests
    60→        self._drain_queue(self.image_queue)
    61→        self._drain_queue(self.audio_queue)
    62→
    63→    def _drain_queue(self, queue: asyncio.Queue):
    64→        """Drain a queue to free pending items."""
    65→        try:
    66→            while not queue.empty():
    67→                try:
    68→                    queue.get_nowait()
    69→                    queue.task_done()
    70→                except asyncio.QueueEmpty:
    71→                    break
    72→        except Exception:
    73→            pass
    74→
    75→    async def _process_image_queue(self):
    76→        while True:
    77→            try:
    78→                request: GenerationRequest = await self.image_queue.get()
    79→
    80→                self.is_image_busy = True
    81→                try:
    82→                    await request.handler_func()
    83→                    self.total_processed += 1
    84→                except Exception as e:
    85→                    queue_logger.error(f"[IMAGE] FAILED: {request.request_type.upper()} - {type(e).__name__}: {str(e)}\n{traceback.format_exc()}")
    86→                finally:
    87→                    self.is_image_busy = False
    88→                    self.image_queue.task_done()
    89→
    90→            except asyncio.CancelledError:
    91→                break
    92→            except Exception as e:
    93→                queue_logger.error(f"Image queue error: {type(e).__name__}: {str(e)}\n{traceback.format_exc()}")
    94→                await asyncio.sleep(0.1)
    95→
    96→    async def _process_audio_queue(self):
    97→        while True:
    98→            try:
    99→                request: GenerationRequest = await self.audio_queue.get()
   100→
   101→                self.is_audio_busy = True
   102→                try:
   103→                    await request.handler_func()
   104→                    self.total_processed += 1
   105→                except Exception as e:
   106→                    queue_logger.error(f"[AUDIO] FAILED: {request.request_type.upper()} - {type(e).__name__}: {str(e)}\n{traceback.format_exc()}")
   107→                finally:
   108→                    self.is_audio_busy = False
   109→                    self.audio_queue.task_done()
   110→
   111→            except asyncio.CancelledError:
   112→                break
   113→            except Exception as e:
   114→                queue_logger.error(f"Audio queue error: {type(e).__name__}: {str(e)}\n{traceback.format_exc()}")
   115→                await asyncio.sleep(0.1)
   116→
   117→    async def add_to_queue(self, request_type: str, handler_func, params) -> tuple[bool, str, int]:
   118→        request_id = f"{request_type}_{int(time.time() * 1000)}"
   119→        request = GenerationRequest(request_type, request_id, handler_func, params)
   120→
   121→        if request_type in ["image", "analysis"]:
   122→            target_queue = self.image_queue
   123→            is_busy = self.is_image_busy
   124→        elif request_type == "audio":
   125→            target_queue = self.audio_queue
   126→            is_busy = self.is_audio_busy
   127→        else:
   128→            target_queue = self.image_queue
   129→            is_busy = self.is_image_busy
   130→
   131→        await target_queue.put(request)
   132→        self.total_queued += 1
   133→
   134→        queue_position = target_queue.qsize()
   135→
   136→        if queue_position == 1 and not is_busy:
   137→            return True, "Processing now...", 0
   138→        else:
   139→            return True, f"Added to queue (position: {queue_position})", queue_position
   140→
   141→    def get_queue_status(self) -> dict:
   142→        self.is_generating = self.is_image_busy or self.is_audio_busy
   143→
   144→        return {
   145→            "image_queue": {
   146→                "size": self.image_queue.qsize(),
   147→                "is_busy": self.is_image_busy
   148→            },
   149→            "audio_queue": {
   150→                "size": self.audio_queue.qsize(),
   151→                "is_busy": self.is_audio_busy
   152→            },
   153→            "total_queued": self.total_queued,
   154→            "total_processed": self.total_processed,
   155→            "any_busy": self.is_generating
   156→        }
   157→


     1→"""Custom TTS Service for external TTS API."""
     2→
     3→import aiohttp
     4→from typing import AsyncGenerator, Optional, Callable
     5→from loguru import logger
     6→
     7→from pipecat.frames.frames import Frame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame
     8→from pipecat.services.tts_service import TTSService
     9→
    10→
    11→class CustomTTSService(TTSService):
    12→    """Custom TTS service that integrates with an external TTS API."""
    13→
    14→    def __init__(
    15→        self,
    16→        *,
    17→        api_url: str,
    18→        api_key: Optional[str] = None,
    19→        speaker: str = "Nilay",
    20→        sample_rate: int = 16000,
    21→        model_name: str = "veena3-tts",
    22→        on_pcm_chunk: Optional[Callable[[bytes], None]] = None,
    23→        **kwargs,
    24→    ):
    25→        super().__init__(sample_rate=sample_rate, **kwargs)
    26→
    27→        if not api_url or api_url == "None":
    28→            raise ValueError("api_url is required for CustomTTSService")
    29→
    30→        self._api_url = api_url
    31→        self._api_key = api_key
    32→        self._speaker = speaker
    33→        self._sample_rate = sample_rate
    34→        self._on_pcm_chunk = on_pcm_chunk
    35→
    36→        self._session = None
    37→        self._connector = None
    38→
    39→        self.set_model_name(model_name)
    40→        self._voice_id = speaker
    41→
    42→    def can_generate_metrics(self) -> bool:
    43→        return True
    44→
    45→    async def _ensure_session(self):
    46→        if self._session is None:
    47→            self._connector = aiohttp.TCPConnector(
    48→                limit=10,
    49→                limit_per_host=5,
    50→                ttl_dns_cache=300,
    51→                keepalive_timeout=60,
    52→                force_close=False,
    53→                enable_cleanup_closed=True
    54→            )
    55→            self._session = aiohttp.ClientSession(
    56→                connector=self._connector,
    57→                timeout=aiohttp.ClientTimeout(total=30, connect=5)
    58→            )
    59→            logger.debug("HTTP session created with connection pooling")
    60→
    61→    async def close(self):
    62→        if self._session:
    63→            await self._session.close()
    64→            self._session = None
    65→        if self._connector:
    66→            await self._connector.close()
    67→            self._connector = None
    68→        logger.debug("HTTP session and connector closed")
    69→
    70→    async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
    71→        logger.debug(f"Starting TTS generation, text_length={len(text)}")
    72→
    73→        try:
    74→            headers = {"Content-Type": "application/json"}
    75→
    76→            if self._api_key:
    77→                headers["X-API-Key"] = self._api_key
    78→
    79→            payload = {
    80→                "text": text,
    81→                "speaker": 'lipakshi',
    82→                "stream": True,
    83→                "output": "48khz",
    84→                "seed": 42
    85→            }
    86→
    87→            yield TTSStartedFrame()
    88→
    89→            await self.start_ttfb_metrics()
    90→            await self.start_tts_usage_metrics(text)
    91→
    92→            ttfb_stopped = False
    93→            await self._ensure_session()
    94→
    95→            async with self._session.post(
    96→                self._api_url,
    97→                headers=headers,
    98→                json=payload,
    99→                ssl=False,
   100→            ) as response:
   101→                if response.status != 200:
   102→                    error_text = await response.text()
   103→                    logger.error(f"TTS API error status={response.status} error={error_text[:500]}")
   104→                    if not ttfb_stopped:
   105→                        await self.stop_ttfb_metrics()
   106→                        ttfb_stopped = True
   107→                    yield TTSStoppedFrame()
   108→                    return
   109→
   110→                bytes_per_sample = 2
   111→                first_chunk = True
   112→
   113→                async for chunk in response.content.iter_any():
   114→                    if chunk:
   115→                        if not ttfb_stopped:
   116→                            await self.stop_ttfb_metrics()
   117→                            ttfb_stopped = True
   118→                            logger.debug(f"TTS first byte received, chunk_size={len(chunk)}")
   119→
   120→                        if first_chunk:
   121→                            first_chunk = False
   122→                            if len(chunk) >= 12 and chunk[:4] == b'RIFF' and chunk[8:12] == b'WAVE':
   123→                                data_pos = chunk.find(b'data')
   124→                                if data_pos != -1:
   125→                                    wav_header_size = data_pos + 8
   126→                                    chunk = chunk[wav_header_size:]
   127→                                else:
   128→                                    logger.warning("WAV header detected but 'data' chunk not found")
   129→
   130→                        if len(chunk) == 0:
   131→                            continue
   132→
   133→                        if len(chunk) % bytes_per_sample != 0:
   134→                            chunk = chunk[:-(len(chunk) % bytes_per_sample)]
   135→                            if len(chunk) == 0:
   136→                                continue
   137→
   138→                        if self._on_pcm_chunk:
   139→                            try:
   140→                                self._on_pcm_chunk(chunk)
   141→                            except Exception as e:
   142→                                logger.error(f"Audio tap callback error: {e}")
   143→
   144→                        yield TTSAudioRawFrame(
   145→                            audio=chunk,
   146→                            sample_rate=self._sample_rate,
   147→                            num_channels=1,
   148→                        )
   149→
   150→            yield TTSStoppedFrame()
   151→
   152→        except Exception as e:
   153→            logger.error(f"TTS ERROR: {type(e).__name__}", exc_info=True)
   154→            yield TTSStoppedFrame()
   155→


fnHandlers/clients/cf_flux_client.py:17:_shared_client: Optional[httpx.AsyncClient] = None
fnHandlers/clients/cf_flux_client.py:25:    global _shared_client
fnHandlers/clients/cf_flux_client.py:26:    if _shared_client is None or _shared_client.is_closed:
fnHandlers/clients/cf_flux_client.py:27:        _shared_client = httpx.AsyncClient(
fnHandlers/clients/cf_flux_client.py:31:    return _shared_client
fnHandlers/clients/edit_service_client.py:13:_shared_client: Optional[httpx.AsyncClient] = None
fnHandlers/clients/edit_service_client.py:18:    global _shared_client
fnHandlers/clients/edit_service_client.py:19:    if _shared_client is None or _shared_client.is_closed:
fnHandlers/clients/edit_service_client.py:20:        _shared_client = httpx.AsyncClient(
fnHandlers/clients/edit_service_client.py:24:    return _shared_client
fnHandlers/clients/edit_service_client.py:71:            images.append(reference_image_b64)


Let me check the client files and image handlers for potential issues:


     1→"""Cloudflare Flux API Client for Image Generation and Editing.
     2→
     3→Multi-account support: accounts are read from DB config (cf_flux_config).
     4→Requests are distributed round-robin across enabled accounts.
     5→On 429/5xx, the next account is tried automatically.
     6→"""
     7→
     8→import base64
     9→import time
    10→from dataclasses import dataclass
    11→from typing import List, Optional, Tuple
    12→from loguru import logger
    13→import httpx
    14→
    15→from .exceptions import ImageGenerationError, ImageEditError, RateLimitError, ServiceUnavailableError
    16→
    17→_shared_client: Optional[httpx.AsyncClient] = None
    18→
    19→# Round-robin counter (simple int, wraps via modulo)
    20→_rr_counter = 0
    21→
    22→
    23→def _get_client(timeout: float = 120.0) -> httpx.AsyncClient:
    24→    """Get or create shared httpx client with connection pooling."""
    25→    global _shared_client
    26→    if _shared_client is None or _shared_client.is_closed:
    27→        _shared_client = httpx.AsyncClient(
    28→            timeout=timeout,
    29→            limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
    30→        )
    31→    return _shared_client
    32→
    33→
    34→def _get_enabled_accounts() -> List[dict]:
    35→    """Get enabled accounts from DB config (cached in-memory)."""
    36→    from utils.turso_client import get_cf_flux_enabled_accounts
    37→    return get_cf_flux_enabled_accounts()
    38→
    39→
    40→@dataclass
    41→class CFFluxResult:
    42→    """Result of CF Flux generation/editing."""
    43→    image_bytes: bytes
    44→    mime_type: str
    45→    api_latency: float
    46→    model: str
    47→    prompt_used: str
    48→
    49→
    50→class CFFluxClient:
    51→    """Client for Cloudflare Flux API. Multi-account with round-robin + retry."""
    52→
    53→    def __init__(self, timeout: float = 120.0):
    54→        self._timeout = timeout
    55→        self._model = "flux-2-klein-4b"
    56→
    57→    def _pick_order(self, accounts: List[dict]) -> List[dict]:
    58→        """Pick account order: start from round-robin index, then rotate."""
    59→        global _rr_counter
    60→        if not accounts:
    61→            return []
    62→        idx = _rr_counter % len(accounts)
    63→        _rr_counter += 1
    64→        return accounts[idx:] + accounts[:idx]
    65→
    66→    async def _post(self, account: dict, files: dict) -> Tuple[bytes, float, str]:
    67→        """Post to a CF account. Returns (image_bytes, api_latency, account_name)."""
    68→        url = account["url"]
    69→        api_key = account["api_key"]
    70→        name = account.get("name", url[:40])
    71→
    72→        headers = {"api-key": api_key}
    73→        client = _get_client(self._timeout)
    74→        api_start = time.time()
    75→        response = await client.post(url, headers=headers, files=files)
    76→        api_latency = time.time() - api_start
    77→        response.raise_for_status()
    78→        return response.content, api_latency, name
    79→
    80→    async def generate_image(
    81→        self,
    82→        prompt: str,
    83→        width: int = 1024,
    84→        height: int = 1024,
    85→        num_steps: int = 25,
    86→        guidance: float = 3.5
    87→    ) -> CFFluxResult:
    88→        """Generate an image from text prompt."""
    89→        accounts = _get_enabled_accounts()
    90→        logger.info(f"CF_FLUX_GENERATE_START: prompt_length={len(prompt)} model={self._model} accounts={len(accounts)}")
    91→
    92→        if not accounts:
    93→            raise ImageGenerationError("No CF Flux accounts configured or enabled")
    94→
    95→        files = {
    96→            "prompt": (None, prompt),
    97→            "model": (None, self._model),
    98→            "num_steps": (None, str(num_steps)),
    99→            "width": (None, str(width)),
   100→            "height": (None, str(height)),
   101→            "guidance": (None, str(guidance))
   102→        }
   103→
   104→        last_error = None
   105→        for account in self._pick_order(accounts):
   106→            name = account.get("name", "?")
   107→            try:
   108→                image_bytes, api_latency, name = await self._post(account, files)
   109→                if len(image_bytes) == 0:
   110→                    raise ImageGenerationError("CF Flux returned empty image data")
   111→                logger.info(f"CF_FLUX_GENERATE_SUCCESS: account={name} api_latency={api_latency:.3f}s size_bytes={len(image_bytes)}")
   112→                return CFFluxResult(
   113→                    image_bytes=image_bytes,
   114→                    mime_type="image/png",
   115→                    api_latency=api_latency,
   116→                    model=self._model,
   117→                    prompt_used=prompt
   118→                )
   119→            except httpx.HTTPStatusError as e:
   120→                last_error = self._handle_http_error(e)
   121→                logger.error(f"CF_FLUX_GENERATE_HTTP_ERROR: account={name} status={e.response.status_code} response={e.response.text[:200] if e.response.text else 'empty'}")
   122→                if e.response.status_code in (429, 500, 502, 503):
   123→                    continue  # try next account
   124→                raise last_error
   125→            except ImageGenerationError:
   126→                raise
   127→            except Exception as e:
   128→                last_error = self._handle_error(e)
   129→                logger.error(f"CF_FLUX_GENERATE_ERROR: account={name} error_type={type(e).__name__} error={str(e)[:200]}")
   130→                continue  # try next account
   131→        raise last_error or ImageGenerationError("All CF accounts failed")
   132→
   133→    async def edit_image(
   134→        self,
   135→        image_base64: str,
   136→        prompt: str,
   137→        reference_image_b64: Optional[str] = None,
   138→        width: int = 1024,
   139→        height: int = 1024,
   140→        num_steps: int = 25
   141→    ) -> CFFluxResult:
   142→        """Edit an image using CF Flux API."""
   143→        accounts = _get_enabled_accounts()
   144→        logger.info(f"CF_FLUX_EDIT_START: prompt_length={len(prompt)} has_reference={bool(reference_image_b64)} accounts={len(accounts)}")
   145→
   146→        if not accounts:
   147→            raise ImageEditError("No CF Flux accounts configured or enabled")
   148→
   149→        user_image_bytes = base64.b64decode(image_base64)
   150→        files = {
   151→            "prompt": (None, prompt),
   152→            "model": (None, self._model),
   153→            "num_steps": (None, str(num_steps)),
   154→            "width": (None, str(width)),
   155→            "height": (None, str(height)),
   156→            "input_image_0": ("image_0.png", user_image_bytes, "image/png")
   157→        }
   158→        if reference_image_b64:
   159→            ref_image_bytes = base64.b64decode(reference_image_b64)
   160→            files["input_image_1"] = ("image_1.png", ref_image_bytes, "image/png")
   161→
   162→        last_error = None
   163→        for account in self._pick_order(accounts):
   164→            name = account.get("name", "?")
   165→            try:
   166→                image_bytes, api_latency, name = await self._post(account, files)
   167→                if len(image_bytes) == 0:
   168→                    raise ImageEditError("CF Flux returned empty image data")
   169→                logger.info(f"CF_FLUX_EDIT_SUCCESS: account={name} api_latency={api_latency:.3f}s size_bytes={len(image_bytes)}")
   170→                return CFFluxResult(
   171→                    image_bytes=image_bytes,
   172→                    mime_type="image/png",
   173→                    api_latency=api_latency,
   174→                    model=self._model,
   175→                    prompt_used=prompt
   176→                )
   177→            except httpx.HTTPStatusError as e:
   178→                last_error = self._handle_http_error(e)
   179→                logger.error(f"CF_FLUX_EDIT_HTTP_ERROR: account={name} status={e.response.status_code} response={e.response.text[:200] if e.response.text else 'empty'}")
   180→                if e.response.status_code in (429, 500, 502, 503):
   181→                    continue
   182→                raise last_error
   183→            except ImageEditError:
   184→                raise
   185→            except Exception as e:
   186→                last_error = self._handle_error(e)
   187→                logger.error(f"CF_FLUX_EDIT_ERROR: account={name} error_type={type(e).__name__} error={str(e)[:200]}")
   188→                continue
   189→        raise last_error or ImageEditError("All CF accounts failed")
   190→
   191→    def _handle_http_error(self, error: httpx.HTTPStatusError):
   192→        """Handle HTTP status errors."""
   193→        status_code = error.response.status_code
   194→
   195→        if status_code == 429:
   196→            return RateLimitError("Rate limit exceeded", error)
   197→
   198→        if status_code >= 500:
   199→            return ServiceUnavailableError(f"Service error: {status_code}", error)
   200→
   201→        return ImageGenerationError(f"HTTP error: {status_code}", error)
   202→
   203→    def _handle_error(self, error: Exception):
   204→        """Convert errors to custom exceptions."""
   205→        error_str = str(error).lower()
   206→        error_type = type(error).__name__
   207→
   208→        is_rate_limit = (
   209→            any(x in error_str for x in ['rate limit', '429', 'ratelimit']) or
   210→            'RateLimit' in error_type
   211→        )
   212→
   213→        if is_rate_limit:
   214→            return RateLimitError("Rate limit exceeded", error)
   215→
   216→        if isinstance(error, httpx.TimeoutException):
   217→            return ServiceUnavailableError("Request timed out", error)
   218→
   219→        return ImageGenerationError(str(error), error)
   220→
   221→    @property
   222→    def model_name(self) -> str:
   223→        return self._model
   224→


     1→"""External Image Edit Service Client."""
     2→
     3→import base64
     4→import time
     5→from dataclasses import dataclass
     6→from typing import Optional, Dict, Any
     7→from loguru import logger
     8→import httpx
     9→
    10→from utils import constants
    11→from .exceptions import ImageEditError, RateLimitError, ModerationError, ServiceUnavailableError
    12→
    13→_shared_client: Optional[httpx.AsyncClient] = None
    14→
    15→
    16→def _get_client(timeout: float = 120.0) -> httpx.AsyncClient:
    17→    """Get or create shared httpx client with connection pooling."""
    18→    global _shared_client
    19→    if _shared_client is None or _shared_client.is_closed:
    20→        _shared_client = httpx.AsyncClient(
    21→            timeout=timeout,
    22→            limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
    23→        )
    24→    return _shared_client
    25→
    26→
    27→@dataclass
    28→class ImageEditResult:
    29→    """Result of image editing."""
    30→    edited_image_bytes: bytes
    31→    mime_type: str
    32→    api_latency: float
    33→    processing_time: float
    34→    service_url: str
    35→
    36→
    37→class EditServiceClient:
    38→    """Client for external image editing service."""
    39→
    40→    def __init__(self, service_url: str = None, timeout: float = 120.0):
    41→        self._service_url = service_url or constants.EXTERNAL_IMAGE_EDIT_SERVICE_URL
    42→        self._timeout = timeout
    43→
    44→        if not self._service_url:
    45→            raise ValueError("External image edit service URL not configured")
    46→
    47→    async def edit_image(
    48→        self,
    49→        image_base64: str,
    50→        edit_instruction: str,
    51→        style: str = "photorealistic",
    52→        session_id: str = None,
    53→        request_id: str = None,
    54→        reference_image_b64: str = None
    55→    ) -> ImageEditResult:
    56→        """Edit an image using the external service.
    57→
    58→        Args:
    59→            image_base64: Base64 encoded user's image to edit
    60→            edit_instruction: What changes to make
    61→            style: Style for the edited image
    62→            session_id: Session identifier
    63→            request_id: Request identifier
    64→            reference_image_b64: Optional reference image (e.g., celebrity image) to use in editing
    65→        """
    66→
    67→        # Build images array - if reference image provided, send both
    68→        images = [image_base64]
    69→        if reference_image_b64:
    70→            # Add reference image as second image
    71→            images.append(reference_image_b64)
    72→            logger.info(f"EDIT_SERVICE_WITH_REFERENCE: num_images={len(images)}")
    73→
    74→        prompt = edit_instruction  # Pass LLM prompt directly, no extra processing
    75→
    76→        payload = {
    77→            "images": images,
    78→            "prompt": prompt,
    79→            "negative_prompt": constants.EDIT_NEGATIVE_PROMPT,
    80→            "seed": 42,
    81→            "steps": 4,
    82→            "guidance_scale": 1.0,
    83→            "request_id": request_id or "",
    84→            "session_id": session_id or ""
    85→        }
    86→
    87→        logger.info(f"EDIT_SERVICE_API_CALL: service_url={self._service_url} prompt_length={len(prompt)}")
    88→
    89→        try:
    90→            api_start = time.time()
    91→
    92→            client = _get_client(self._timeout)
    93→            response = await client.post(
    94→                self._service_url,
    95→                json=payload,
    96→                headers={"Content-Type": "application/json"}
    97→            )
    98→            response.raise_for_status()
    99→
   100→            api_latency = time.time() - api_start
   101→            response_data = response.json()
   102→
   103→            if not response_data.get("success", False):
   104→                error_msg = response_data.get("error", "Unknown error from external service")
   105→                raise ImageEditError(f"External service error: {error_msg}")
   106→
   107→            edited_image_b64 = response_data.get("image")
   108→            if not edited_image_b64:
   109→                raise ImageEditError("External service did not return image")
   110→
   111→            edited_image_bytes = base64.b64decode(edited_image_b64)
   112→
   113→            if len(edited_image_bytes) == 0:
   114→                raise ImageEditError("External service returned empty image data")
   115→
   116→            latency_info = response_data.get("latency", {})
   117→            processing_time = latency_info.get("processing_time", api_latency)
   118→
   119→            logger.info(f"EDIT_SERVICE_API_SUCCESS: api_latency={api_latency:.3f}s processing_time={processing_time:.3f}s size_bytes={len(edited_image_bytes)}")
   120→
   121→            return ImageEditResult(
   122→                edited_image_bytes=edited_image_bytes,
   123→                mime_type="image/png",
   124→                api_latency=api_latency,
   125→                processing_time=processing_time,
   126→                service_url=self._service_url
   127→            )
   128→
   129→        except httpx.HTTPStatusError as e:
   130→            logger.error(f"EDIT_SERVICE_HTTP_ERROR: status={e.response.status_code} response={e.response.text[:500] if e.response.text else 'empty'}")
   131→            raise self._handle_http_error(e)
   132→        except ImageEditError:
   133→            raise
   134→        except Exception as e:
   135→            logger.error(f"EDIT_SERVICE_ERROR: error_type={type(e).__name__} error={str(e)[:500]}")
   136→            raise self._handle_error(e)
   137→
   138→    def _handle_http_error(self, error: httpx.HTTPStatusError):
   139→        """Handle HTTP status errors."""
   140→        status_code = error.response.status_code
   141→
   142→        if status_code == 429:
   143→            return RateLimitError("Rate limit exceeded", error)
   144→
   145→        if status_code >= 500:
   146→            return ServiceUnavailableError(f"Service error: {status_code}", error)
   147→
   148→        return ImageEditError(f"HTTP error: {status_code}", error)
   149→
   150→    def _handle_error(self, error: Exception):
   151→        """Convert errors to custom exceptions."""
   152→        error_str = str(error).lower()
   153→        error_type = type(error).__name__
   154→
   155→        is_rate_limit = (
   156→            any(x in error_str for x in ['rate limit', '429', 'ratelimit']) or
   157→            'RateLimit' in error_type
   158→        )
   159→
   160→        if is_rate_limit:
   161→            return RateLimitError("Rate limit exceeded", error)
   162→
   163→        if any(x in error_str for x in ['moderation_blocked', 'safety', 'content_policy']):
   164→            return ModerationError("Content blocked by moderation", error)
   165→
   166→        if any(x in error_str for x in ['500', 'overloaded']):
   167→            return ServiceUnavailableError("Service temporarily unavailable", error)
   168→
   169→        if isinstance(error, httpx.TimeoutException):
   170→            return ServiceUnavailableError("Request timed out", error)
   171→
   172→        return ImageEditError(str(error), error)
   173→
   174→    @property
   175→    def service_url(self) -> str:
   176→        return self._service_url
   177→
   178→


     1→"""Session event handlers for bot."""
     2→
     3→import asyncio
     4→import io
     5→import json
     6→import time
     7→import wave
     8→from datetime import datetime
     9→from loguru import logger
    10→
    11→from pipecat.frames.frames import LLMRunFrame
    12→
    13→from utils import constants
    14→from utils.s3_utils import upload_to_s3, save_json_to_s3
    15→from utils.turso_client import save_session_to_turso, save_session_complete, save_character_session
    16→
    17→from typing import TYPE_CHECKING
    18→if TYPE_CHECKING:
    19→    from bot.config import BotSessionState, ChunkState, TranscriptManager
    20→
    21→
    22→def create_audio_data_handler(bot_session: "BotSessionState", chunk_state: "ChunkState"):
    23→    async def on_audio_data(buffer, audio, sample_rate, num_channels):
    24→        if not chunk_state.session_id:
    25→            chunk_state.session_id = bot_session.session_id or "unknown"
    26→
    27→        session_id = chunk_state.session_id
    28→        chunk_key = chunk_state.get_chunk_key(session_id, chunk_state.counter)
    29→        chunk_num = chunk_state.add_chunk(chunk_key)
    30→
    31→        try:
    32→            wav_buffer = io.BytesIO()
    33→            with wave.open(wav_buffer, 'wb') as wav_file:
    34→                wav_file.setnchannels(num_channels)
    35→                wav_file.setsampwidth(2)
    36→                wav_file.setframerate(sample_rate)
    37→                wav_file.writeframes(audio)
    38→
    39→            wav_buffer.seek(0)
    40→            wav_data = wav_buffer.read()
    41→
    42→            await upload_to_s3(wav_data, chunk_key, "audio/wav")
    43→            logger.debug(f"Chunk {chunk_num} uploaded: {chunk_key}")
    44→        except Exception as e:
    45→            logger.error(f"Failed to upload chunk {chunk_num}: {e}")
    46→    return on_audio_data
    47→
    48→
    49→def create_transcript_handler(bot_session: "BotSessionState", transcript_manager: "TranscriptManager", rtvi):
    50→    async def on_transcript_update(processor, frame):
    51→        try:
    52→            for message in frame.messages:
    53→                transcript_entry = {
    54→                    "timestamp": message.timestamp,
    55→                    "role": message.role,
    56→                    "content": message.content
    57→                }
    58→                transcript_manager.add_message(message.role, message.content)
    59→
    60→                if message.role == "user":
    61→                    bot_session.user_spoke_first = True
    62→
    63→                try:
    64→                    await rtvi.send_server_message({
    65→                        "type": "bot-transcript-update",
    66→                        "data": transcript_entry
    67→                    })
    68→                except Exception as e:
    69→                    logger.error(f"Failed to send transcript to RTVI: {e}")
    70→        except Exception as e:
    71→            logger.error(f"Error in transcript handler: {e}", exc_info=True)
    72→    return on_transcript_update
    73→
    74→
    75→def create_client_message_handler(bot_session: "BotSessionState", rtvi, task, transcript_manager: "TranscriptManager"):
    76→    from bot.image_upload import handle_image_upload, handle_image_batch_upload, handle_load_image_from_url
    77→    async def on_client_message(rtvi_proc, message):
    78→        try:
    79→            msg_type = getattr(message, 'type', None)
    80→            msg_data = getattr(message, 'data', None)
    81→            logger.debug(f"RTVI message: {msg_type}")
    82→
    83→            if msg_type == "user_image_upload":
    84→                data = msg_data if isinstance(msg_data, dict) else {}
    85→                image_data = data.get("image")
    86→                mime_type = data.get("mime_type", "image/jpeg")
    87→
    88→                if image_data:
    89→                    await handle_image_upload(
    90→                        image_data, mime_type, bot_session,
    91→                        rtvi, task, transcript_manager, source="rtvi"
    92→                    )
    93→
    94→            elif msg_type == "user_image_batch_upload":
    95→                data = msg_data if isinstance(msg_data, dict) else {}
    96→                images_data = data.get("images", [])
    97→
    98→                if images_data:
    99→                    await handle_image_batch_upload(
   100→                        images_data, bot_session, rtvi, task, transcript_manager


Now let me check for the Mistral STT WebSocket handling and look for more potential leaks:


     1→"""Mistral Voxtral STT service using vLLM Realtime API.
     2→
     3→For use with vLLM-hosted Voxtral models (e.g., on Modal).
     4→Uses WebSocket protocol compatible with vLLM's /v1/realtime endpoint.
     5→
     6→Based on: https://github.com/vllm-project/vllm/blob/main/examples/online_serving/openai_realtime_microphone_client.py
     7→"""
     8→
     9→import asyncio
    10→import base64
    11→import json
    12→from typing import AsyncGenerator, Optional
    13→
    14→from loguru import logger
    15→
    16→from pipecat.frames.frames import (
    17→    CancelFrame,
    18→    EndFrame,
    19→    Frame,
    20→    InterimTranscriptionFrame,
    21→    StartFrame,
    22→    TranscriptionFrame,
    23→    UserStoppedSpeakingFrame,
    24→)
    25→from pipecat.services.stt_service import STTService
    26→from pipecat.transcriptions.language import Language, resolve_language
    27→from pipecat.utils.time import time_now_iso8601
    28→
    29→try:
    30→    import websockets
    31→except ModuleNotFoundError as e:
    32→    logger.error(f"Exception: {e}")
    33→    logger.error("In order to use Mistral STT with vLLM, you need to `pip install websockets`.")
    34→    raise Exception(f"Missing module: {e}")
    35→
    36→
    37→def language_to_mistral_language(language: Language) -> Optional[str]:
    38→    """Convert pipecat Language enum to Mistral language code."""
    39→    LANGUAGE_MAP = {
    40→        Language.AF: "af", Language.AM: "am", Language.AR: "ar", Language.AS: "as",
    41→        Language.AZ: "az", Language.BE: "be", Language.BG: "bg", Language.BN: "bn",
    42→        Language.BS: "bs", Language.CA: "ca", Language.CS: "cs", Language.CY: "cy",
    43→        Language.DA: "da", Language.DE: "de", Language.EL: "el", Language.EN: "en",
    44→        Language.ES: "es", Language.ET: "et", Language.FA: "fa", Language.FI: "fi",
    45→        Language.FR: "fr", Language.GL: "gl", Language.GU: "gu", Language.HE: "he",
    46→        Language.HI: "hi", Language.HR: "hr", Language.HU: "hu", Language.HY: "hy",
    47→        Language.ID: "id", Language.IS: "is", Language.IT: "it", Language.JA: "ja",
    48→        Language.KA: "ka", Language.KK: "kk", Language.KN: "kn", Language.KO: "ko",
    49→        Language.LT: "lt", Language.LV: "lv", Language.MK: "mk", Language.ML: "ml",
    50→        Language.MN: "mn", Language.MR: "mr", Language.MS: "ms", Language.MT: "mt",
    51→        Language.NE: "ne", Language.NL: "nl", Language.NO: "no", Language.PA: "pa",
    52→        Language.PL: "pl", Language.PT: "pt", Language.RO: "ro", Language.RU: "ru",
    53→        Language.SK: "sk", Language.SL: "sl", Language.SO: "so", Language.SQ: "sq",
    54→        Language.SR: "sr", Language.SV: "sv", Language.SW: "sw", Language.TA: "ta",
    55→        Language.TE: "te", Language.TH: "th", Language.TL: "tl", Language.TR: "tr",
    56→        Language.UK: "uk", Language.UR: "ur", Language.VI: "vi", Language.ZH: "zh",
    57→    }
    58→    return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
    59→
    60→
    61→class MistralSTTService(STTService):
    62→    """Mistral Voxtral STT service using vLLM Realtime WebSocket API.
    63→
    64→    Compatible with vLLM-hosted Voxtral models on Modal or self-hosted.
    65→    Uses the /v1/realtime WebSocket endpoint with the following protocol:
    66→
    67→    1. Connect to WebSocket
    68→    2. Receive session.created
    69→    3. Send session.update with model
    70→    4. Send input_audio_buffer.commit
    71→    5. Stream audio via input_audio_buffer.append (base64 PCM16)
    72→    6. Receive transcription.delta events
    73→    """
    74→
    75→    def __init__(
    76→        self,
    77→        *,
    78→        api_key: str = "",  # Not required for vLLM but kept for compatibility
    79→        model: str = "mistralai/Voxtral-Mini-4B-Realtime-2602",
    80→        ws_url: str = "wss://mayaresearch--voxtral-realtime-serve.modal.run/v1/realtime",
    81→        language: Optional[Language] = None,
    82→        sample_rate: Optional[int] = 16000,
    83→        **kwargs,
    84→    ):
    85→        super().__init__(sample_rate=sample_rate, **kwargs)
    86→
    87→        self._api_key = api_key
    88→        self._ws_url = ws_url
    89→        self.set_model_name(model)
    90→
    91→        self._language = language_to_mistral_language(language) if language else None
    92→        self._websocket = None
    93→        self._receive_task: Optional[asyncio.Task] = None
    94→        self._send_task: Optional[asyncio.Task] = None
    95→        self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue()
    96→        self._connected = False
    97→        self._running = False
    98→        self._accumulated_text = ""
    99→
   100→        self._settings = {
   101→            "model": model,
   102→            "language": self._language,
   103→            "sample_rate": sample_rate,
   104→            "ws_url": ws_url,
   105→        }
   106→
   107→    def __str__(self):
   108→        return f"MistralSTTService [{self._model_name}]"
   109→
   110→    def can_generate_metrics(self) -> bool:
   111→        return True
   112→
   113→    async def set_model(self, model: str):
   114→        await super().set_model(model)
   115→        logger.info(f"Switching STT model to: [{model}]")
   116→        self._settings["model"] = model
   117→
   118→    async def set_language(self, language: Language):
   119→        logger.info(f"Switching STT language to: [{language}]")
   120→        self._language = language_to_mistral_language(language)
   121→        self._settings["language"] = self._language
   122→
   123→    async def start(self, frame: StartFrame):
   124→        """Start the service - connect to vLLM WebSocket."""
   125→        await super().start(frame)
   126→        self._settings["sample_rate"] = self.sample_rate
   127→        await self._connect()
   128→
   129→    async def stop(self, frame: EndFrame):
   130→        """Stop the service - disconnect."""
   131→        await super().stop(frame)
   132→        await self._disconnect()
   133→
   134→    async def cancel(self, frame: CancelFrame):
   135→        """Cancel the service - disconnect."""
   136→        await super().cancel(frame)
   137→        await self._disconnect()
   138→
   139→    async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
   140→        """Queue audio for transcription."""
   141→        if self._running and self._connected:
   142→            await self._audio_queue.put(audio)
   143→        yield None
   144→
   145→    async def process_frame(self, frame: Frame, direction):
   146→        """Handle special frames like UserStoppedSpeakingFrame."""
   147→        await super().process_frame(frame, direction)
   148→
   149→        # When user stops speaking, push final transcription
   150→        if isinstance(frame, UserStoppedSpeakingFrame):
   151→            await self._push_final_transcription()
   152→
   153→    async def _push_final_transcription(self):
   154→        """Push accumulated text as final transcription and reset."""
   155→        # Wait a bit for any remaining transcription deltas
   156→        await asyncio.sleep(0.3)
   157→
   158→        if self._accumulated_text.strip():
   159→            text = self._accumulated_text.strip()
   160→            logger.info(f"{self} Final Transcription: [{text}]")
   161→            await self.push_frame(
   162→                TranscriptionFrame(
   163→                    text=text,
   164→                    user_id=self._user_id,
   165→                    timestamp=time_now_iso8601(),
   166→                    language=self._language,
   167→                )
   168→            )
   169→            self._accumulated_text = ""
   170→
   171→    async def _connect(self):
   172→        """Connect to vLLM WebSocket and start send/receive tasks."""
   173→        try:
   174→            logger.debug(f"{self} Connecting to vLLM WebSocket: {self._ws_url}")
   175→
   176→            self._websocket = await websockets.connect(self._ws_url)
   177→
   178→            # Wait for session.created
   179→            response = await self._websocket.recv()
   180→            data = json.loads(response)
   181→            logger.debug(f"{self} Received: {data.get('type', 'unknown')}")
   182→
   183→            if data.get("type") == "session.created":
   184→                logger.info(f"{self} Session created")
   185→
   186→            # Send session.update with model
   187→            await self._websocket.send(json.dumps({
   188→                "type": "session.update",
   189→                "model": self._model_name
   190→            }))
   191→            logger.debug(f"{self} Sent session.update with model: {self._model_name}")
   192→
   193→            # Signal ready to receive audio
   194→            await self._websocket.send(json.dumps({
   195→                "type": "input_audio_buffer.commit"
   196→            }))
   197→            logger.debug(f"{self} Sent input_audio_buffer.commit")
   198→
   199→            self._connected = True
   200→            self._running = True
   201→            self._audio_queue = asyncio.Queue()
   202→            self._accumulated_text = ""
   203→
   204→            # Start send and receive tasks
   205→            self._send_task = asyncio.create_task(self._send_audio_loop())
   206→            self._receive_task = asyncio.create_task(self._receive_transcription_loop())
   207→
   208→            await self._call_event_handler("on_connected")
   209→            logger.info(f"{self} vLLM STT connected and ready")
   210→
   211→        except Exception as e:
   212→            logger.error(f"{self} Failed to connect: {e}")
   213→            await self.push_error(error_msg=f"Unable to connect to vLLM: {e}", exception=e)
   214→
   215→    async def _disconnect(self):
   216→        """Stop tasks and close WebSocket."""
   217→        self._running = False
   218→        self._connected = False
   219→
   220→        # Cancel tasks
   221→        for task in [self._send_task, self._receive_task]:
   222→            if task:
   223→                task.cancel()
   224→                try:
   225→                    await task
   226→                except asyncio.CancelledError:
   227→                    pass
   228→
   229→        self._send_task = None
   230→        self._receive_task = None
   231→
   232→        # Close WebSocket
   233→        if self._websocket:
   234→            try:
   235→                await self._websocket.close()
   236→            except Exception:
   237→                pass
   238→            self._websocket = None
   239→
   240→        await self._call_event_handler("on_disconnected")
   241→        logger.debug(f"{self} Disconnected from vLLM")
   242→
   243→    async def _send_audio_loop(self):
   244→        """Send audio chunks to vLLM WebSocket."""
   245→        try:
   246→            while self._running:
   247→                try:
   248→                    # Get audio from queue with timeout
   249→                    audio = await asyncio.wait_for(self._audio_queue.get(), timeout=0.1)
   250→
   251→                    # Convert PCM16 bytes to base64
   252→                    b64_audio = base64.b64encode(audio).decode("utf-8")
   253→
   254→                    # Send to WebSocket
   255→                    await self._websocket.send(json.dumps({
   256→                        "type": "input_audio_buffer.append",
   257→                        "audio": b64_audio
   258→                    }))
   259→
   260→                except asyncio.TimeoutError:
   261→                    continue
   262→                except Exception as e:
   263→                    if self._running:
   264→                        logger.error(f"{self} Send error: {e}")
   265→                    break
   266→
   267→        except asyncio.CancelledError:
   268→            logger.debug(f"{self} Send loop cancelled")
   269→
   270→    async def _receive_transcription_loop(self):
   271→        """Receive transcription events from vLLM WebSocket."""
   272→        try:
   273→            async for message in self._websocket:
   274→                if not self._running:
   275→                    break
   276→
   277→                try:
   278→                    data = json.loads(message)
   279→                    event_type = data.get("type", "")
   280→
   281→                    if event_type == "transcription.delta":
   282→                        delta = data.get("delta", "")
   283→                        if delta:
   284→                            self._accumulated_text += delta
   285→                            logger.debug(f"{self} Delta: '{delta}' (total: '{self._accumulated_text}')")
   286→
   287→                            # Push interim transcription
   288→                            await self.push_frame(
   289→                                InterimTranscriptionFrame(
   290→                                    text=self._accumulated_text,
   291→                                    user_id=self._user_id,
   292→                                    timestamp=time_now_iso8601(),
   293→                                    language=self._language,
   294→                                )
   295→                            )
   296→
   297→                    elif event_type == "transcription.done":
   298→                        # Final transcription for this segment
   299→                        if self._accumulated_text.strip():
   300→                            logger.info(f"{self} Transcription done: [{self._accumulated_text}]")
   301→                            await self.push_frame(
   302→                                TranscriptionFrame(
   303→                                    text=self._accumulated_text,
   304→                                    user_id=self._user_id,
   305→                                    timestamp=time_now_iso8601(),
   306→                                    language=self._language,
   307→                                )
   308→                            )
   309→                            self._accumulated_text = ""
   310→
   311→                    elif event_type == "error":
   312→                        error = data.get("error", {})
   313→                        error_msg = error.get("message", str(error))
   314→                        logger.error(f"{self} Server error: {error_msg}")
   315→                        await self.push_error(error_msg=f"vLLM STT error: {error_msg}")
   316→
   317→                    else:
   318→                        logger.trace(f"{self} Event: {event_type}")
   319→
   320→                except json.JSONDecodeError:
   321→                    logger.warning(f"{self} Invalid JSON received")
   322→                except Exception as e:
   323→                    logger.error(f"{self} Error processing message: {e}")
   324→
   325→        except websockets.exceptions.ConnectionClosed as e:
   326→            logger.warning(f"{self} WebSocket closed: {e}")
   327→        except asyncio.CancelledError:
   328→            logger.debug(f"{self} Receive loop cancelled")
   329→        except Exception as e:
   330→            if self._running:
   331→                logger.error(f"{self} Receive error: {e}")
   332→                await self.push_error(error_msg=f"vLLM STT error: {e}", exception=e)
   333→


     1→"""Daily.co API client for room and token management."""
     2→
     3→import time
     4→import aiohttp
     5→from typing import Optional, Tuple
     6→from loguru import logger
     7→
     8→from utils import constants
     9→
    10→DAILY_API_URL = "https://api.daily.co/v1"
    11→
    12→_shared_session: Optional[aiohttp.ClientSession] = None
    13→
    14→
    15→async def _get_session() -> aiohttp.ClientSession:
    16→    """Get or create shared aiohttp session."""
    17→    global _shared_session
    18→    if _shared_session is None or _shared_session.closed:
    19→        timeout = aiohttp.ClientTimeout(total=30)
    20→        connector = aiohttp.TCPConnector(limit=10, limit_per_host=5)
    21→        _shared_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
    22→    return _shared_session
    23→
    24→
    25→class DailyClient:
    26→    def __init__(self, api_key: str = None):
    27→        self._api_key = api_key or constants.DAILY_API_KEY
    28→        if not self._api_key:
    29→            raise ValueError("Daily API key not configured")
    30→
    31→    async def create_room(
    32→        self,
    33→        max_participants: int = None,
    34→        expiry_seconds: int = None
    35→    ) -> Tuple[str, str]:
    36→        """Create a Daily room and return (room_url, room_name)."""
    37→        max_participants = max_participants or constants.MAX_ROOM_PARTICIPANTS
    38→        expiry_seconds = expiry_seconds or constants.ROOM_EXPIRY_TIME
    39→        expiry_time = int(time.time() + expiry_seconds)
    40→
    41→        room_params = {
    42→            "properties": {
    43→                "max_participants": max_participants,
    44→                "enable_chat": False,
    45→                "enable_emoji_reactions": False,
    46→                "exp": expiry_time,
    47→            }
    48→        }
    49→
    50→        session = await _get_session()
    51→        async with session.post(
    52→            f"{DAILY_API_URL}/rooms",
    53→            headers=self._get_headers(),
    54→            json=room_params
    55→        ) as response:
    56→            if response.status != 200:
    57→                error_text = await response.text()
    58→                logger.error(f"Failed to create Daily room: {error_text}")
    59→                raise DailyAPIError(f"Failed to create room: {response.status}", response.status)
    60→
    61→            room_data = await response.json()
    62→            return room_data.get("url"), room_data.get("name")
    63→
    64→    async def create_token(self, room_name: str, is_owner: bool = True) -> str:
    65→        """Create a meeting token for a room."""
    66→        token_params = {
    67→            "properties": {
    68→                "room_name": room_name,
    69→                "is_owner": is_owner,
    70→            }
    71→        }
    72→
    73→        session = await _get_session()
    74→        async with session.post(
    75→            f"{DAILY_API_URL}/meeting-tokens",
    76→            headers=self._get_headers(),
    77→            json=token_params
    78→        ) as response:
    79→            if response.status != 200:
    80→                error_text = await response.text()
    81→                logger.error(f"Failed to create token: {error_text}")
    82→                raise DailyAPIError(f"Failed to create token: {response.status}", response.status)
    83→
    84→            token_data = await response.json()
    85→            return token_data.get("token")
    86→
    87→    async def create_room_with_token(
    88→        self,
    89→        max_participants: int = None,
    90→        expiry_seconds: int = None
    91→    ) -> Tuple[str, str, str]:
    92→        """Create room and token in one call. Returns (room_url, room_name, token)."""
    93→        room_url, room_name = await self.create_room(max_participants, expiry_seconds)
    94→        token = await self.create_token(room_name)
    95→        return room_url, room_name, token
    96→
    97→    async def delete_room(self, room_name: str) -> bool:
    98→        """Delete a Daily room. Returns True if deleted, False if not found."""
    99→        session = await _get_session()
   100→        async with session.delete(
   101→            f"{DAILY_API_URL}/rooms/{room_name}",
   102→            headers=self._get_headers()
   103→        ) as response:
   104→            if response.status == 200:
   105→                logger.info(f"ROOM_DELETED: room_name={room_name}")
   106→                return True
   107→            elif response.status == 404:
   108→                logger.debug(f"ROOM_NOT_FOUND: room_name={room_name}")
   109→                return False
   110→            else:
   111→                error_text = await response.text()
   112→                logger.error(f"ROOM_DELETE_FAILED: room_name={room_name} status={response.status} error={error_text}")
   113→                return False
   114→
   115→    def _get_headers(self) -> dict:
   116→        return {
   117→            "Authorization": f"Bearer {self._api_key}",
   118→            "Content-Type": "application/json"
   119→        }
   120→
   121→
   122→class DailyAPIError(Exception):
   123→    """Exception raised for Daily API errors."""
   124→
   125→    def __init__(self, message: str, status_code: int = None):
   126→        self.message = message
   127→        self.status_code = status_code
   128→        super().__init__(self.message)
   129→
   130→


bot/config.py:137:        self.participants: dict = {}
bot/config.py:186:        self.participants[participant_id] = {
bot/config.py:193:        return self.participants.get(participant_id, {})
bot/config.py:327:        def _on_task_done(t):
bot/config.py:332:        task.add_done_callback(_on_task_done)
bot/config.py:529:    # 12. Clear participants dict
bot/config.py:531:        bot_session.participants.clear()
bot/config.py:533:        logger.error(f"CLEANUP_ERROR: participants clear failed: {e}")
bot/main.py:111:    audio_buffer.event_handler("on_audio_data")(create_audio_data_handler(bot_session, chunk_state))
bot/main.py:117:        llm.register_function("generate_image", create_generate_image_handler(
bot/main.py:123:            llm.register_function("cf_image", create_cf_image_handler(
bot/main.py:128:            logger.error(f"Failed to register cf_image handler: {e}", exc_info=True)
bot/main.py:133:            llm.register_function("azure_edit_image", create_azure_edit_image_handler(
bot/main.py:138:            logger.error(f"Failed to register azure_edit_image handler: {e}", exc_info=True)
bot/main.py:144:            llm.register_function("generate_video", create_video_handler(
bot/main.py:148:            llm.register_function("generate_video_from_image", create_video_from_image_handler(
bot/main.py:153:            logger.error(f"Failed to register video handlers: {e}", exc_info=True)
bot/main.py:155:    transcript.event_handler("on_transcript_update")(create_transcript_handler(bot_session, transcript_manager, rtvi))
bot/main.py:156:    rtvi.event_handler("on_client_ready")(create_client_ready_handler())
bot/main.py:157:    rtvi.event_handler("on_client_message")(create_client_message_handler(bot_session, rtvi, task, transcript_manager))
bot/main.py:161:    transport.event_handler("on_participant_joined")(create_participant_joined_handler(bot_session, task))
bot/main.py:162:    transport.event_handler("on_first_participant_joined")(create_first_participant_joined_handler(bot_session, rtvi, task, audio_buffer, chunk_state))
bot/main.py:163:    transport.event_handler("on_participant_left")(create_participant_left_handler(bot_session, session_state, transcript_manager, chunk_state, task))
bot/main.py:164:    transport.event_handler("on_client_disconnected")(create_client_disconnected_handler(bot_session, task))
bot/main.py:165:    task.event_handler("on_idle_timeout")(create_idle_timeout_handler(bot_session))

[Showing results with pagination = limit: 100, offset: 0]


Let me check for more potential issues around WebSocket cleanup and logging:


     1→"""Centralized logging configuration for Maya server."""
     2→
     3→import os
     4→import sys
     5→import json
     6→import logging
     7→import warnings
     8→import traceback
     9→from datetime import datetime, timezone
    10→from loguru import logger
    11→
    12→from utils import constants
    13→
    14→
    15→LOG_LEVEL_PREFIXES = {
    16→    "DEBUG": "DEBUG",
    17→    "INFO": "INFO",
    18→    "WARNING": "WARN",
    19→    "ERROR": "ERROR",
    20→    "CRITICAL": "CRITICAL",
    21→    "EXCEPTION": "ERROR",
    22→}
    23→
    24→
    25→def log_filter(record):
    26→    # Check message content filters first, even for ERROR level logs
    27→    msg = record["message"]
    28→    if "Unable to send message" in msg and "before joining" in msg:
    29→        return False  # Normal race condition - message sent before Daily join completes
    30→
    31→    # Also check exception message if present
    32→    if record.get("exception"):
    33→        exc_info = record["exception"]
    34→        if exc_info and exc_info.value:
    35→            exc_msg = str(exc_info.value)
    36→            if "Unable to send message" in exc_msg and "before joining" in exc_msg:
    37→                return False
    38→
    39→    # Daily SDK noise (filter BEFORE error passthrough — these are harmless at any level)
    40→    if "Failed to send LogLine" in msg:
    41→        return False
    42→    if "ResponseCanceled" in msg or "ResponseCancelled" in msg:
    43→        return False
    44→    if "Metrics failed to get snapshot" in msg:
    45→        return False
    46→    if "daily_core" in msg:
    47→        return False
    48→    if "CallManagerEvent" in msg:
    49→        return False
    50→    if "MediasoupManager" in msg:
    51→        return False
    52→
    53→    # Allow all other ERROR/CRITICAL/EXCEPTION logs
    54→    if record["level"].name in ("ERROR", "CRITICAL", "EXCEPTION"):
    55→        return True
    56→
    57→    name = record.get("name", "")
    58→    if name and name.startswith(("botocore", "boto3", "urllib3", "s3transfer")):
    59→        return False
    60→    if name and name.startswith("openai"):
    61→        return False
    62→    if name and name.startswith("httpx"):
    63→        return False
    64→
    65→    if "Empty audio frame received for STT service" in msg:
    66→        return False
    67→    if "Ignoring not RTVI message" in msg:
    68→        return False
    69→    if "User stopped speaking but no new aggregation received" in msg:
    70→        return False
    71→    if "Unclosed client session" in msg or "Unclosed connector" in msg:
    72→        return False
    73→    if "Task was destroyed but it is pending" in msg:
    74→        return False
    75→    if "Loading JSON file:" in msg:
    76→        return False
    77→    if "Changing event name" in msg:
    78→        return False
    79→    if "Request options:" in msg:
    80→        return False
    81→    return True
    82→
    83→
    84→CONSOLE_FORMAT = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} | {message}"
    85→
    86→
    87→def datadog_sink(message):
    88→    record = message.record
    89→    level = record["level"].name
    90→
    91→    timestamp = record["time"].astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
    92→
    93→    log_entry = {
    94→        "timestamp": timestamp,
    95→        "level": level,
    96→        "logger": record["name"],
    97→        "function": record["function"],
    98→        "line": record["line"],
    99→        "message": record["message"],
   100→        "service": "maya-pipecat",
   101→        "env": os.getenv("DD_ENV", os.getenv("ENVIRONMENT", "development")),
   102→        "version": os.getenv("DD_VERSION", getattr(constants, 'APP_VERSION', "1.0.0")),
   103→    }
   104→
   105→    # Add all extra fields from logger.bind()
   106→    for key, value in record["extra"].items():
   107→        if value and key not in ("_maya_configured",):  # Skip internal keys
   108→            log_entry[key] = value
   109→
   110→    if level in ("ERROR", "CRITICAL", "EXCEPTION"):
   111→        log_entry["status"] = "error"
   112→        log_entry["error.kind"] = level
   113→
   114→        if record.get("exception"):
   115→            exc_info = record["exception"]
   116→            if exc_info:
   117→                log_entry["error.type"] = exc_info.type.__name__ if exc_info.type else "Unknown"
   118→                log_entry["error.message"] = str(exc_info.value) if exc_info.value else ""
   119→                log_entry["error.stack"] = "".join(traceback.format_exception(exc_info.type, exc_info.value, exc_info.traceback))
   120→    else:
   121→        log_entry["status"] = "info"
   122→
   123→    return json.dumps(log_entry, ensure_ascii=False) + "\n"
   124→
   125→
   126→class DatadogFileSink:
   127→    def __init__(self, filepath):
   128→        self.filepath = filepath
   129→        self._file = None
   130→
   131→    def _ensure_file(self):
   132→        if self._file is None:
   133→            os.makedirs(os.path.dirname(self.filepath), exist_ok=True)
   134→            self._file = open(self.filepath, "a", encoding="utf-8")
   135→
   136→    def write(self, message):
   137→        json_line = datadog_sink(message)
   138→        self._ensure_file()
   139→        self._file.write(json_line)
   140→        self._file.flush()
   141→
   142→    def close(self):
   143→        if self._file:
   144→            self._file.close()
   145→
   146→
   147→class DatadogStdoutSink:
   148→    """Writes JSON-formatted logs to stdout for DD DaemonSet collection.
   149→    DD auto-parses level, message, service from the JSON structure."""
   150→
   151→    def write(self, message):
   152→        json_line = datadog_sink(message)
   153→        sys.stdout.write(json_line)
   154→        sys.stdout.flush()
   155→
   156→
   157→class InterceptHandler(logging.Handler):
   158→    def emit(self, record):
   159→        try:
   160→            level = logger.level(record.levelname).name
   161→        except ValueError:
   162→            level = record.levelno
   163→
   164→        frame, depth = sys._getframe(6), 6
   165→        while frame and frame.f_code.co_filename == logging.__file__:
   166→            frame = frame.f_back
   167→            depth += 1
   168→
   169→        logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
   170→
   171→
   172→def log_startup_banner():
   173→    banner = """
   174→╔══════════════════════════════════════════════════════════════════╗
   175→║                     MAYA PIPECAT SERVER                          ║
   176→║                   Starting Application...                        ║
   177→╚══════════════════════════════════════════════════════════════════╝
   178→"""
   179→    print(banner)
   180→
   181→    logger.info("=" * 60)
   182→    logger.info("MAYA PIPECAT SERVER - STARTUP INITIATED")
   183→    logger.info("=" * 60)
   184→    logger.info(f"STARTUP_CONFIG: env={os.getenv('ENVIRONMENT', 'development')} host={constants.SERVER_HOST} port={constants.SERVER_PORT}")
   185→    logger.info(f"STARTUP_CONFIG: log_dir={constants.LOG_DIR} log_file={constants.LOG_FILE_PATH}")
   186→
   187→    dd_enabled = "yes" if os.getenv("DD_API_KEY") else "no"
   188→    logger.info(f"STARTUP_CONFIG: datadog_enabled={dd_enabled}")
   189→
   190→    turso_configured = "yes" if constants.TURSO_DATABASE_URL else "no"
   191→    logger.info(f"STARTUP_CONFIG: turso_configured={turso_configured}")
   192→
   193→    logger.info("=" * 60)
   194→
   195→
   196→def log_startup_complete(startup_time_ms: float = None):
   197→    logger.info("=" * 60)
   198→    logger.info("MAYA PIPECAT SERVER - STARTUP COMPLETE")
   199→    if startup_time_ms:
   200→        logger.info(f"STARTUP_TIME: {startup_time_ms:.2f}ms")
   201→    logger.info("Server is ready to accept connections")
   202→    logger.info("=" * 60)
   203→
   204→
   205→def log_shutdown():
   206→    logger.info("=" * 60)
   207→    logger.info("MAYA PIPECAT SERVER - SHUTDOWN INITIATED")
   208→    logger.info("=" * 60)
   209→
   210→
   211→def configure_logging():
   212→    if hasattr(logger, '_maya_configured'):
   213→        return
   214→
   215→    os.makedirs(constants.LOG_DIR, exist_ok=True)
   216→    logger.remove()
   217→
   218→    is_production = bool(os.getenv("DD_AGENT_HOST"))
   219→    stdout_level = os.getenv("LOG_LEVEL", "INFO" if is_production else "DEBUG")
   220→
   221→    if is_production:
   222→        # Production: JSON to stdout (DD DaemonSet auto-parses level, message, service, etc.)
   223→        json_stdout_sink = DatadogStdoutSink()
   224→        logger.add(
   225→            json_stdout_sink.write,
   226→            level=stdout_level,
   227→            filter=log_filter,
   228→        )
   229→    else:
   230→        # Local dev: human-readable plain text to stdout
   231→        logger.add(
   232→            sys.stdout,
   233→            level="DEBUG",
   234→            format=CONSOLE_FORMAT,
   235→            filter=log_filter,
   236→            colorize=False,
   237→            enqueue=False,
   238→            backtrace=True,
   239→            diagnose=True,
   240→        )
   241→
   242→    # Always write plain text to file for local debugging
   243→    logger.add(
   244→        constants.LOG_FILE_PATH,
   245→        level="DEBUG",
   246→        format=CONSOLE_FORMAT,
   247→        filter=log_filter,
   248→        rotation=constants.LOG_ROTATION_SIZE,
   249→        retention=constants.LOG_RETENTION,
   250→        compression="zip",
   251→        enqueue=False,
   252→        backtrace=True,
   253→        diagnose=True,
   254→    )
   255→
   256→    warnings.filterwarnings("default")
   257→    logging.captureWarnings(True)
   258→
   259→    logging.basicConfig(handlers=[InterceptHandler()], level=logging.DEBUG, force=True)
   260→
   261→    for logger_name in ["uvicorn", "uvicorn.error", "uvicorn.access", "fastapi", "pipecat", "aiohttp"]:
   262→        logging.getLogger(logger_name).handlers = [InterceptHandler()]
   263→        logging.getLogger(logger_name).propagate = False
   264→
   265→    logger._maya_configured = True
   266→
   267→    log_startup_banner()
   268→
   269→
   270→def _configure_datadog_logging():
   271→    try:
   272→        datadog_path = "/var/log/maya-pipecat/datadog.log"
   273→        os.makedirs("/var/log/maya-pipecat", exist_ok=True)
   274→        sink = DatadogFileSink(datadog_path)
   275→        logger.add(
   276→            sink.write,
   277→            level="INFO",
   278→            filter=log_filter,
   279→        )
   280→        logger.info(f"DATADOG_LOGGING: enabled=true path={datadog_path}")
   281→    except PermissionError:
   282→        datadog_log_dir = os.path.join(constants.LOG_DIR, "datadog")
   283→        datadog_path = os.path.join(datadog_log_dir, "datadog.log")
   284→        os.makedirs(datadog_log_dir, exist_ok=True)
   285→        sink = DatadogFileSink(datadog_path)
   286→        logger.add(
   287→            sink.write,
   288→            level="INFO",
   289→            filter=log_filter,
   290→        )
   291→        logger.info(f"DATADOG_LOGGING: enabled=true path={datadog_path} (fallback)")
   292→
   293→
   294→def get_logger(name: str = None, session_id: str = None, device_id: str = None):
   295→    bound_logger = logger
   296→    if name:
   297→        bound_logger = bound_logger.bind(name=name)
   298→    if session_id:
   299→        bound_logger = bound_logger.bind(session_id=session_id)
   300→    if device_id:
   301→        bound_logger = bound_logger.bind(device_id=device_id)
   302→    return bound_logger
   303→
   304→
   305→def log_error(message: str, error: Exception = None, **context):
   306→    error_type = type(error).__name__ if error else "Unknown"
   307→    error_msg = str(error) if error else ""
   308→
   309→    full_message = f"ERROR: {message}"
   310→    if error:
   311→        full_message += f" | error_type={error_type} error_message={error_msg}"
   312→
   313→    for key, value in context.items():
   314→        full_message += f" {key}={value}"
   315→
   316→    if error:
   317→        logger.opt(exception=error).error(full_message)
   318→    else:
   319→        logger.error(full_message)
   320→
   321→
   322→def log_session_event(event: str, session_id: str, device_id: str = None, **extra):
   323→    parts = [f"SESSION_{event.upper()}: session_id={session_id}"]
   324→    if device_id:
   325→        parts.append(f"device_id={device_id}")
   326→    for key, value in extra.items():
   327→        parts.append(f"{key}={value}")
   328→    logger.bind(session_id=session_id, device_id=device_id or "").info(" ".join(parts))
   329→
   330→
   331→def log_api_request(endpoint: str, method: str, device_id: str = None, **extra):
   332→    parts = [f"API_REQUEST: endpoint={endpoint} method={method}"]
   333→    if device_id:
   334→        parts.append(f"device_id={device_id}")
   335→    for key, value in extra.items():
   336→        parts.append(f"{key}={value}")
   337→    logger.info(" ".join(parts))
   338→
   339→
   340→def log_api_response(endpoint: str, status_code: int, duration_ms: float = None, **extra):
   341→    parts = [f"API_RESPONSE: endpoint={endpoint} status={status_code}"]
   342→    if duration_ms:
   343→        parts.append(f"duration_ms={duration_ms:.2f}")
   344→    for key, value in extra.items():
   345→        parts.append(f"{key}={value}")
   346→    logger.info(" ".join(parts))
   347→
   348→
   349→def log_db_operation(operation: str, table: str = None, success: bool = True, duration_ms: float = None, **extra):
   350→    status = "success" if success else "failed"
   351→    parts = [f"DB_{operation.upper()}: status={status}"]
   352→    if table:
   353→        parts.append(f"table={table}")
   354→    if duration_ms:
   355→        parts.append(f"duration_ms={duration_ms:.2f}")
   356→    for key, value in extra.items():
   357→        parts.append(f"{key}={value}")
   358→
   359→    if success:
   360→        logger.debug(" ".join(parts))
   361→    else:
   362→        logger.error(" ".join(parts))
   363→
   364→
   365→def log_external_call(service: str, operation: str, success: bool = True, duration_ms: float = None, **extra):
   366→    status = "success" if success else "failed"
   367→    parts = [f"EXTERNAL_{service.upper()}: operation={operation} status={status}"]
   368→    if duration_ms:
   369→        parts.append(f"duration_ms={duration_ms:.2f}")
   370→    for key, value in extra.items():
   371→        parts.append(f"{key}={value}")
   372→
   373→    if success:
   374→        logger.info(" ".join(parts))
   375→    else:
   376→        logger.error(" ".join(parts))
   377→


  2940→_characters_cache: Optional[List[Dict[str, Any]]] = None
  2941→_characters_cache_time: float = 0
  2942→
  2943→# Cache for character prompts
  2944→_character_prompts_cache: Dict[str, str] = {}
  2945→_prompt_cache_time: Dict[str, float] = {}
  2946→
  2947→
  2948→async def _get_character_by_id_db(character_id: str) -> Optional[Dict[str, Any]]:
  2949→    """DB-only: Get character info with tags (2 queries)."""
  2950→    client = get_turso_client()
  2951→    if not client:
  2952→        logger.warning(f"CHARACTER_FETCH_SKIPPED: character={character_id} reason=client_not_available")
  2953→        return None
  2954→
  2955→    start_time = time.time()
  2956→    try:
  2957→        result = await execute_turso_query_async({
  2958→            "sql": """
  2959→                SELECT
  2960→                    character_id,
  2961→                    display_name,
  2962→                    description,
  2963→                    avatar_url,
  2964→                    thumbnail_url,
  2965→                    banner_url,
  2966→                    voice_id
  2967→                FROM characters
  2968→                WHERE character_id = ? AND is_active = 1
  2969→                LIMIT 1
  2970→            """,
  2971→            "args": [character_id]
  2972→        }, "get_character_by_id")
  2973→
  2974→        if not result.rows or len(result.rows) == 0:
  2975→            logger.warning(f"CHARACTER_NOT_FOUND: character={character_id}")
  2976→            return None
  2977→
  2978→        row = result.rows[0]
  2979→
  2980→        # Get tags for this character
  2981→        tags_result = await execute_turso_query_async({
  2982→            "sql": """
  2983→                SELECT t.tag_id, t.display_name
  2984→                FROM character_tag_mapping m
  2985→                JOIN character_tags t ON m.tag_id = t.tag_id
  2986→                WHERE m.character_id = ?
  2987→                ORDER BY t.sort_order
  2988→            """,
  2989→            "args": [character_id]
  2990→        }, "get_character_tags")
  2991→
  2992→        tags = []
  2993→        if tags_result.rows:
  2994→            for tag_row in tags_result.rows:
  2995→                tags.append({
  2996→                    "tag_id": _extract_text(tag_row, 0),
  2997→                    "display_name": _extract_text(tag_row, 1)
  2998→                })
  2999→
  3000→        character = {
  3001→            "character_id": _extract_text(row, 0),
  3002→            "display_name": _extract_text(row, 1),
  3003→            "description": _extract_text(row, 2),
  3004→            "avatar_url": _extract_text(row, 3),
  3005→            "thumbnail_url": _extract_text(row, 4),
  3006→            "banner_url": _extract_text(row, 5),
  3007→            "voice_id": _extract_text(row, 6),
  3008→            "tags": tags
  3009→        }
  3010→
  3011→        duration_ms = (time.time() - start_time) * 1000
  3012→        logger.info(f"DB_CHARACTER_FETCHED: character={character_id} name={character['display_name']} duration_ms={duration_ms:.2f}")
  3013→        return character
  3014→
  3015→    except Exception as e:
  3016→        duration_ms = (time.time() - start_time) * 1000
  3017→        logger.error(f"ERROR: DB_CHARACTER_FETCH_FAILED character={character_id} duration_ms={duration_ms:.2f} error={str(e)}")
  3018→        return None
  3019→
  3020→
  3021→async def get_character_by_id(character_id: str) -> Optional[Dict[str, Any]]:
  3022→    """Redis-cached (5 min TTL). Saves 2 DB queries per character session."""
  3023→    from utils.cache_layer import cached, CacheKeys
  3024→    return await cached(
  3025→        key=CacheKeys.character(character_id),
  3026→        fallback=lambda: _get_character_by_id_db(character_id),
  3027→        ttl=300,
  3028→    )
  3029→
  3030→
  3031→async def get_character_prompt(character_id: str, use_cache: bool = True) -> Optional[str]:
  3032→    """Get the active prompt for a character. Cached for performance."""
  3033→    global _character_prompts_cache, _prompt_cache_time
  3034→
  3035→    # Check cache first
  3036→    if use_cache and character_id in _character_prompts_cache:
  3037→        cache_age = time.time() - _prompt_cache_time.get(character_id, 0)
  3038→        if cache_age < constants.CHARACTERS_CACHE_TTL_SECONDS:
  3039→            logger.debug(f"PROMPT_CACHE_HIT: character={character_id} age={cache_age:.1f}s")
  3040→            return _character_prompts_cache[character_id]
  3041→
  3042→    client = get_turso_client()
  3043→    if not client:
  3044→        # Return cached prompt if DB unavailable
  3045→        return _character_prompts_cache.get(character_id)
  3046→
  3047→    start_time = time.time()
  3048→    try:
  3049→        result = await execute_turso_query_async({
  3050→            "sql": """
  3051→                SELECT content
  3052→                FROM prompts
  3053→                WHERE prompt_name = ? AND is_active = 1
  3054→                LIMIT 1
  3055→            """,
  3056→            "args": [character_id]
  3057→        }, "get_character_prompt")
  3058→
  3059→        if result.rows and len(result.rows) > 0:
  3060→            content = _extract_text(result.rows[0], 0)
  3061→            duration_ms = (time.time() - start_time) * 1000
  3062→            logger.info(f"CHARACTER_PROMPT_LOADED: character={character_id} length={len(content) if content else 0} duration_ms={duration_ms:.2f}")
  3063→
  3064→            # Update cache
  3065→            if content:
  3066→                _character_prompts_cache[character_id] = content
  3067→                _prompt_cache_time[character_id] = time.time()
  3068→
  3069→            return content
  3070→
  3071→        duration_ms = (time.time() - start_time) * 1000
  3072→        logger.warning(f"CHARACTER_PROMPT_NOT_FOUND: character={character_id} duration_ms={duration_ms:.2f}")
  3073→        return None
  3074→
  3075→    except Exception as e:
  3076→        duration_ms = (time.time() - start_time) * 1000
  3077→        logger.error(f"ERROR: DB_CHARACTER_PROMPT_FETCH_FAILED character={character_id} duration_ms={duration_ms:.2f} error={str(e)}")
  3078→        return _character_prompts_cache.get(character_id)
  3079→
  3080→
  3081→async def get_all_characters(use_cache: bool = True) -> List[Dict[str, Any]]:
  3082→    """Get all active characters with their tags. Optimized to avoid N+1 queries."""
  3083→    global _characters_cache, _characters_cache_time
  3084→
  3085→    # Check cache first
  3086→    if use_cache and _characters_cache is not None:
  3087→        cache_age = time.time() - _characters_cache_time
  3088→        if cache_age < constants.CHARACTERS_CACHE_TTL_SECONDS:
  3089→            logger.debug(f"CHARACTERS_CACHE_HIT: age={cache_age:.1f}s count={len(_characters_cache)}")


utils/turso_client.py:2944:_character_prompts_cache: Dict[str, str] = {}
utils/turso_client.py:3031:async def get_character_prompt(character_id: str, use_cache: bool = True) -> Optional[str]:
utils/turso_client.py:3033:    global _character_prompts_cache, _prompt_cache_time
utils/turso_client.py:3036:    if use_cache and character_id in _character_prompts_cache:
utils/turso_client.py:3040:            return _character_prompts_cache[character_id]
utils/turso_client.py:3045:        return _character_prompts_cache.get(character_id)
utils/turso_client.py:3066:                _character_prompts_cache[character_id] = content
utils/turso_client.py:3078:        return _character_prompts_cache.get(character_id)

[Showing results with pagination = limit: 80, offset: 0]


Perfect! Now let me check the config sync and look at one final area - image upload and background tasks: