Let me add debug logging to the PATCH endpoint to capture what the Android client is sending. Read 1 file (ctrl+o to expand) ⏺ The 422 error means FastAPI's Pydantic validation is rejecting the request body before it even reaches our handler. I need to accept the raw body first to see what the client sends. Let me update the endpoint: ⏺ Update(src/routes/bot.py) ⎿ Added 1 line, removed 1 line 4 import uuid 5 from typing import Any, Dict, List, Optional 6 7 -from fastapi import APIRouter, HTTPException 7 +from fastapi import APIRouter, HTTPException, Request 8 from loguru import logger 9 from pydantic import BaseModel 10 ⏺ Update(src/routes/bot.py) ⎿ Added 21 lines, removed 12 lines 196 197 198 @router.patch("/sessions/{session_id}/api/offer") 199 -async def handle_ice_candidates(session_id: str, data: PatchRequest): 199 +async def handle_ice_candidates(session_id: str, request: Request): 200 """Handle ICE candidate trickle from client.""" 201 + raw_body = await request.json() 202 + logger.info(f"ICE_PATCH_RAW: session={session_id} body={raw_body}") 203 + 204 handler = session_handlers.get(session_id) 205 if not handler: 206 + logger.error(f"ICE_PATCH_SESSION_NOT_FOUND: session={session_id} known_sessions={list(session_handlers.keys())}") 207 raise HTTPException(status_code=404, detail="Session not found") 208 209 try: 206 - request = SmallWebRTCPatchRequest( 207 - pc_id=data.pc_id, 208 - candidates=[ 209 - IceCandidate( 210 - candidate=c.candidate, 211 - sdp_mid=c.sdpMid, 212 - sdp_mline_index=c.sdpMLineIndex, 213 - ) 214 - for c in data.candidates 215 - ], 210 + # Parse flexibly - handle both camelCase and snake_case 211 + pc_id = raw_body.get("pc_id") or raw_body.get("pcId") 212 + candidates_raw = raw_body.get("candidates", []) 213 + 214 + candidates = [] 215 + for c in candidates_raw: 216 + candidates.append(IceCandidate( 217 + candidate=c.get("candidate", ""), 218 + sdp_mid=c.get("sdpMid") or c.get("sdp_mid", ""), 219 + sdp_mline_index=c.get("sdpMLineIndex") or c.get("sdp_mline_index", 0), 220 + )) 221 + 222 + patch_request = SmallWebRTCPatchRequest( 223 + pc_id=pc_id, 224 + candidates=candidates, 225 ) 217 - await handler.handle_patch_request(request) 226 + await handler.handle_patch_request(patch_request) 227 return {"status": "ok"} 228 except HTTPException: 229 raise ⏺ Now let me also add TURN server support while we're at it. Let me also check what the current server.py looks like for the v4 prefix: Read 1 file (ctrl+o to expand) ⏺ Good, server.py already has /api/v4 prefix. Now let me also add TURN server config to constants and update the handler creation. Let me also add a Metered.ca free TURN server config as env vars: ⏺ Update(src/config/constants.py) ⎿ Added 5 lines 90 RAZORPAY_KEY_ID = os.getenv("RAZORPAY_KEY_ID", "") 91 RAZORPAY_KEY_SECRET = os.getenv("RAZORPAY_KEY_SECRET", "") 92 93 +# TURN Server Configuration (required for cloud/NAT traversal) 94 +TURN_SERVER_URL = os.getenv("TURN_SERVER_URL", "") # e.g. turn:your-server.com:3478 95 +TURN_USERNAME = os.getenv("TURN_USERNAME", "") 96 +TURN_PASSWORD = os.getenv("TURN_PASSWORD", "") 97 + 98 SERVER_HOST = "0.0.0.0" 99 SERVER_PORT = 3006 100 ⏺ Now update bot.py to use TURN servers when configured: Read 1 file (ctrl+o to expand) ⏺ Update(src/routes/bot.py) ⎿ Added 17 lines 8 from loguru import logger 9 from pydantic import BaseModel 10 11 +from pipecat.transports.smallwebrtc.connection import IceServer 12 from pipecat.transports.smallwebrtc.request_handler import ( 13 SmallWebRTCRequestHandler, 14 SmallWebRTCRequest, ... 17 ) 18 19 from src.bot import run_bot_webrtc_global_agent 20 +from src.config import constants 21 from src.core.logging import log_error 22 from src.core.redis import add_active_session, remove_active_session, cleanup_pod_sessions, redis_available, _get_pod_name 23 from src.core.pod_lifecycle import on_session_complete 24 from src.dto.common import BotConnectRequest 25 26 + 27 +def _get_ice_servers() -> list[IceServer]: 28 + """Build ICE server list from config. TURN is required for cloud/NAT traversal.""" 29 + servers = [IceServer(urls="stun:stun.l.google.com:19302")] 30 + if constants.TURN_SERVER_URL: 31 + servers.append(IceServer( 32 + urls=constants.TURN_SERVER_URL, 33 + username=constants.TURN_USERNAME, 34 + credential=constants.TURN_PASSWORD, 35 + )) 36 + logger.info(f"TURN_SERVER_CONFIGURED: url={constants.TURN_SERVER_URL}") 37 + else: 38 + logger.warning("NO_TURN_SERVER: WebRTC will fail behind NAT/K8s without TURN") 39 + return servers 40 + 41 router = APIRouter(tags=["bot"]) 42 43 # Track active sessions: {task: session_id} ⏺ Now update the handler creation to use ICE servers: ⏺ Update(src/routes/bot.py) ⎿ Added 2 lines, removed 2 lines 103 104 session_id = str(uuid.uuid4()) 105 106 - # Create SmallWebRTC handler for this session 107 - handler = SmallWebRTCRequestHandler() 106 + # Create SmallWebRTC handler for this session (with TURN for NAT traversal) 107 + handler = SmallWebRTCRequestHandler(ice_servers=_get_ice_servers()) 108 session_handlers[session_id] = handler 109 110 # Store session params for when the SDP offer arrives ⏺ Done. Here's what I changed: 1. PATCH endpoint — Now accepts raw Request instead of PatchRequest Pydantic model, so it won't 422 on field name mismatches. Logs the raw body for debugging. Handles both camelCase and snake_case field names. 2. TURN server support — Added TURN_SERVER_URL, TURN_USERNAME, TURN_PASSWORD env vars in constants. The handler now passes ICE servers (STUN + TURN) to SmallWebRTCRequestHandler. To deploy and test: - Set these env vars on your pod: TURN_SERVER_URL, TURN_USERNAME, TURN_PASSWORD - For a quick free TURN server, sign up at metered.ca — they give you a TURN URL like turn:global.relay.metered.ca:443 with credentials Deploy this first without TURN to at least fix the 422 and see what the client is actually sending. The debug logs will show the raw PATCH body. ✻ Cooked for 53s ❯ from where i will get these ? TURN_SERVER_URL, TURN_USERNAME, TURN_PASSWORD ⏺ You have a few options: 1. Metered.ca (Easiest — Free tier) - Go to metered.ca/stun-turn - Sign up → Get free TURN credentials instantly - Free tier: 50GB/month - You'll get something like: TURN_SERVER_URL=turn:global.relay.metered.ca:443?transport=tcp TURN_USERNAME=abc123def456 TURN_PASSWORD=xyz789secret 2. Xirsys (Free tier) - xirsys.com — sign up, get free TURN - 500MB/month free 3. Self-host Coturn (Free, but more work) - Deploy coturn on a VM with a public IP (GCE e2-micro is free tier) - Full control, no bandwidth limits, but you manage it --- My recommendation: Go with Metered.ca — sign up takes 2 minutes, you get credentials immediately, and 50GB/month is plenty for testing. Once it works, you can decide whether to self-host coturn or keep using Metered. Want me to do anything else while you get the credentials? ❯ how much charge will happen for this scale [Image #7] ⎿ [Image #7] ⏺ Your scale is significant. Let me break down the TURN costs: Your Numbers - ~44K sessions/day (308K/week) - 4m 35s avg session - Audio only (Opus codec ~32kbps each direction) Bandwidth Calculation ┌───────────────────────┬───────────────────────────┐ │ Metric │ Value │ ├───────────────────────┼───────────────────────────┤ │ Bandwidth per session │ ~64 kbps × 275s = 2.2 MB │ ├───────────────────────┼───────────────────────────┤ │ Monthly bandwidth │ ~2.9 TB/month │ └───────────────────────┴───────────────────────────┘ Note: With SmallWebRTC behind K8s, 100% of traffic goes through TURN (server is always behind NAT). Cost Comparison ┌────────────────────────────────┬────────────────────────────────────────────────────────────────────────────┐ │ Option │ Monthly Cost │ ├────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Metered.ca │ 2,900 GB × $0.08/GB = ~$232/month │ ├────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Xirsys │ Similar pricing, ~$200-250/month │ ├────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Self-hosted Coturn (GCE VM) │ VM ~$30 + GCP egress ~$230 = ~$260/month │ ├────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤ │ LiveKit Cloud │ $0.004/participant-min × 2 × 44K × 4.58 = ~$48K/month (way more expensive) │ ├────────────────────────────────┼────────────────────────────────────────────────────────────────────────────┤ │ Daily.co (what you had before) │ Similar to LiveKit, $thousands/month │ └────────────────────────────────┴────────────────────────────────────────────────────────────────────────────┘ Bottom Line TURN at your scale costs ~$230/month — which is massively cheaper than Daily.co or LiveKit Cloud, since you're only relaying raw audio, not paying per-participant-minute. Best option: Self-host coturn on a GCE VM with a public IP. The VM is cheap (~$30/month), and you avoid third-party dependency. Want me to help set that up? ✻ Cogitated for 34s ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ⏵⏵ bypass permissions on (shift+tab to cycle)