mythaitts commited on
Commit
ddaaa1b
·
verified ·
1 Parent(s): a6b4550

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +39 -0
  2. main.py +2160 -0
  3. requirements.txt +17 -11
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Relay backend engine - Docker image (Hugging Face Space / any host)
2
+ # Minimal base: python:3.11-slim (HF Spaces standard, Python preinstalled).
3
+ # No extra OS distro layers needed.
4
+
5
+ FROM python:3.11-slim
6
+
7
+ ENV PYTHONDONTWRITEBYTECODE=1 \
8
+ PYTHONUNBUFFERED=1 \
9
+ PIP_NO_CACHE_DIR=1 \
10
+ DATA_DIR=/data
11
+
12
+ WORKDIR /app
13
+
14
+ # Optional system dep: ffmpeg is only needed for mp3->wav transcoding of OpenAI TTS output.
15
+ # Remove this line if you don't use OpenAI TTS.
16
+ RUN apt-get update \
17
+ && apt-get install -y --no-install-recommends ffmpeg \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Install Python dependencies
21
+ COPY requirements.txt .
22
+ RUN pip install --upgrade pip && pip install -r requirements.txt
23
+
24
+ # Copy application
25
+ COPY main.py .
26
+ COPY .env.example .env
27
+
28
+ # Persistence volume (/data): SQLite DB, recordings, transcripts
29
+ VOLUME ["/data"]
30
+ RUN mkdir -p /data/recordings
31
+
32
+ EXPOSE 8000
33
+
34
+ # Health check
35
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
36
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)" || exit 1
37
+
38
+ # For Hugging Face Spaces (port comes from env, default 7860 on Spaces)
39
+ CMD ["sh", "-c", "python main.py"]
main.py ADDED
@@ -0,0 +1,2160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Relay - a competitive Vapi alternative backend engine (single file).
3
+
4
+ Production-ready voice-agent platform with:
5
+ * Persistent storage at /data (SQLite DB, recordings, transcripts) - DATA_DIR configurable
6
+ * Per-user accounts, projects and scoped API keys (pbkdf2 password hashing)
7
+ * BYO provider credentials: users add their OWN Deepgram/OpenAI/Anthropic/... keys via API
8
+ * Deepgram fully supported:
9
+ - streaming STT : wss://api.deepgram.com/v1/listen (interim, endpointing, utterance_end_ms, vad_events)
10
+ - streaming TTS : wss://api.deepgram.com/v2/speak (Flux) + v1/speak (Aura) with Speak/Flush/Clear/Close
11
+ * Streaming LLM: OpenAI-compatible SSE + Anthropic streaming events (OpenAI/Anthropic/Groq/DeepSeek/custom)
12
+ * Voice pipeline: streaming-first with Deepgram, turn-based VAD fallback for Whisper/file providers,
13
+ barge-in with LLM cancellation + TTS Clear
14
+ * Twilio telephony: inbound TwiML <Connect><Stream> + media-streams WebSocket (mulaw 8k codec)
15
+ * Usage metering + billing estimates, per-stage latency metrics, transcripts, recordings
16
+ * Tool calling + webhooks
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import base64
23
+ import hashlib
24
+ import hmac
25
+ import io
26
+ import json
27
+ import logging
28
+ import os
29
+ import secrets
30
+ import sqlite3
31
+ import struct
32
+ import threading
33
+ import time
34
+ import uuid
35
+ import wave
36
+ from contextlib import asynccontextmanager
37
+ from dataclasses import dataclass, field
38
+ from datetime import datetime, timezone
39
+ from typing import Any, Optional
40
+ from urllib.parse import urljoin
41
+
42
+ import httpx
43
+
44
+ try:
45
+ import uvicorn
46
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
47
+ from fastapi.middleware.cors import CORSMiddleware
48
+ from fastapi.responses import JSONResponse, Response
49
+ from fastapi.staticfiles import StaticFiles
50
+ from pydantic import BaseModel, Field
51
+ from pydantic_settings import BaseSettings, SettingsConfigDict
52
+ except ImportError as exc: # pragma: no cover
53
+ raise SystemExit("Missing dependencies. Run: pip install -r requirements.txt\n" + str(exc))
54
+
55
+ try:
56
+ import websockets # websocket CLIENT for Deepgram streaming
57
+ except ImportError: # pragma: no cover
58
+ websockets = None
59
+
60
+ try:
61
+ import numpy as np
62
+ except ImportError: # pragma: no cover
63
+ np = None
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Settings (.env)
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ class Settings(BaseSettings):
71
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
72
+
73
+ app_name: str = "Relay"
74
+ environment: str = "production"
75
+ host: str = "0.0.0.0"
76
+ port: int = 8000
77
+ log_level: str = "info"
78
+ api_base_url: str = "http://localhost:8000"
79
+
80
+ # Auth
81
+ jwt_secret: str = "change_me_relay_jwt_secret" # SET THIS in production
82
+ jwt_expiry_minutes: int = 10080 # 7 days
83
+ jwt_issuer: str = "relay"
84
+
85
+ # Persistence - the /data path (mount a volume here in Docker/HF Spaces)
86
+ data_dir: str = "data"
87
+
88
+ # ---- Global fallback provider settings (used when a project has no own key)
89
+ stt_provider: str = "deepgram" # deepgram | whisper | openai
90
+ deepgram_api_key: str = ""
91
+ deepgram_stt_model: str = "nova-3"
92
+ deepgram_tts_model: str = "flux-haley-en" # flux model (v2). Set "aura-2-en" for v1
93
+ deepgram_tts_voice: str = "aura-athena-en"
94
+ openai_api_key: str = ""
95
+ whisper_stt_model: str = "base"
96
+ whisper_device: str = "cpu"
97
+
98
+ llm_provider: str = "openai" # openai | anthropic | groq | deepseek | custom
99
+ llm_model: str = "gpt-4o-mini"
100
+ llm_api_key: str = ""
101
+ llm_base_url: str = ""
102
+ anthropic_api_key: str = ""
103
+ anthropic_model: str = "claude-3-5-haiku-latest"
104
+
105
+ tts_provider: str = "deepgram" # deepgram | elevenlabs | cartesia | openai
106
+ elevenlabs_api_key: str = ""
107
+ elevenlabs_voice_id: str = "21m00Tcm4TlvDq8ikWAM"
108
+ cartesia_api_key: str = ""
109
+ cartesia_voice_id: str = "a0e4d1b0-0000-0000-0000-000000000000"
110
+ openai_tts_model: str = "tts-1"
111
+ openai_tts_voice: str = "alloy"
112
+
113
+ # Audio
114
+ sample_rate: int = 16000
115
+ frame_ms: int = 20
116
+ silence_timeout_ms: int = 700 # turn-based fallback only (Deepgram handles streaming turns)
117
+ max_user_turn_ms: int = 20000
118
+ barge_in_enabled: bool = True
119
+
120
+ # Deepgram streaming tuning
121
+ deepgram_endpointing: int = 300
122
+ deepgram_utterance_end_ms: int = 1000
123
+ deepgram_interim: bool = True
124
+ deepgram_language: str = "en-US"
125
+
126
+ # Observability / recording
127
+ record_audio: bool = False
128
+ webhook_url: str = ""
129
+ webhook_secret: str = ""
130
+
131
+ # Billing rates (approx $ per unit) for usage metering
132
+ rate_stt_per_sec: float = 0.0000717 # deepgram nova ~$4.30/hr
133
+ rate_llm_input_per_1k: float = 0.00015
134
+ rate_llm_output_per_1k: float = 0.0006
135
+ rate_tts_per_1k_chars: float = 0.03
136
+
137
+
138
+ settings = Settings()
139
+
140
+ os.makedirs(settings.data_dir, exist_ok=True)
141
+ DB_PATH = os.path.join(settings.data_dir, "relay.db")
142
+ RECORDING_DIR = os.path.join(settings.data_dir, "recordings")
143
+ os.makedirs(RECORDING_DIR, exist_ok=True)
144
+
145
+ logging.basicConfig(
146
+ level=getattr(logging, settings.log_level.upper(), logging.INFO),
147
+ format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
148
+ )
149
+ log = logging.getLogger("relay")
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Database (SQLite, persisted at DATA_DIR/relay.db)
153
+ # ---------------------------------------------------------------------------
154
+
155
+ _SCHEMA = """
156
+ CREATE TABLE IF NOT EXISTS users (
157
+ id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT,
158
+ password_hash TEXT NOT NULL, created_at TEXT NOT NULL
159
+ );
160
+ CREATE TABLE IF NOT EXISTS projects (
161
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL
162
+ );
163
+ CREATE TABLE IF NOT EXISTS api_keys (
164
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT,
165
+ prefix TEXT NOT NULL, key_hash TEXT NOT NULL, key_value TEXT, created_at TEXT NOT NULL, revoked INTEGER DEFAULT 0
166
+ );
167
+ CREATE TABLE IF NOT EXISTS provider_credentials (
168
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL, provider TEXT NOT NULL,
169
+ config TEXT NOT NULL, created_at TEXT NOT NULL
170
+ );
171
+ CREATE TABLE IF NOT EXISTS agents (
172
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, system_prompt TEXT,
173
+ voice TEXT, tools TEXT, config TEXT, created_at TEXT NOT NULL
174
+ );
175
+ CREATE TABLE IF NOT EXISTS tools (
176
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT,
177
+ parameters TEXT, created_at TEXT NOT NULL
178
+ );
179
+ CREATE TABLE IF NOT EXISTS calls (
180
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, agent_id TEXT, phone TEXT, status TEXT,
181
+ direction TEXT, transport TEXT, started_at TEXT, ended_at TEXT, duration_ms INTEGER DEFAULT 0
182
+ );
183
+ CREATE TABLE IF NOT EXISTS transcripts (
184
+ id TEXT PRIMARY KEY, call_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT,
185
+ is_final INTEGER DEFAULT 1, ts TEXT NOT NULL
186
+ );
187
+ CREATE TABLE IF NOT EXISTS usage (
188
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, call_id TEXT, kind TEXT NOT NULL,
189
+ provider TEXT NOT NULL, model TEXT, units REAL DEFAULT 0, latency_ms REAL DEFAULT 0,
190
+ cost REAL DEFAULT 0, created_at TEXT NOT NULL
191
+ );
192
+ CREATE TABLE IF NOT EXISTS events (
193
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, call_id TEXT, type TEXT NOT NULL,
194
+ payload TEXT, created_at TEXT NOT NULL
195
+ );
196
+ """
197
+
198
+
199
+ def db() -> sqlite3.Connection:
200
+ conn = sqlite3.connect(DB_PATH)
201
+ conn.row_factory = sqlite3.Row
202
+ conn.execute("PRAGMA journal_mode=WAL")
203
+ conn.execute("PRAGMA busy_timeout=5000")
204
+ return conn
205
+
206
+
207
+ def db_exec(sql: str, params: tuple = ()) -> int:
208
+ with db() as c:
209
+ cur = c.execute(sql, params)
210
+ c.commit()
211
+ return cur.lastrowid or 0
212
+
213
+
214
+ def db_query(sql: str, params: tuple = ()) -> list[dict[str, Any]]:
215
+ with db() as c:
216
+ rows = c.execute(sql, params).fetchall()
217
+ return [dict(r) for r in rows]
218
+
219
+
220
+ def db_one(sql: str, params: tuple = ()) -> Optional[dict[str, Any]]:
221
+ rows = db_query(sql, params)
222
+ return rows[0] if rows else None
223
+
224
+
225
+ def init_db():
226
+ with db() as c:
227
+ c.executescript(_SCHEMA)
228
+ c.commit()
229
+ # migrations for existing databases
230
+ try:
231
+ cols = {r["name"] for r in db_query("PRAGMA table_info(api_keys)")}
232
+ if "key_value" not in cols:
233
+ db_exec("ALTER TABLE api_keys ADD COLUMN key_value TEXT")
234
+ log.info("migrated api_keys: added key_value column")
235
+ except Exception:
236
+ pass
237
+ log.info("database ready at %s", DB_PATH)
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Security: passwords (pbkdf2) + API keys (prefix + sha256 hash) + JWT (HS256)
242
+ # ---------------------------------------------------------------------------
243
+
244
+ _PBKDF2_ITER = 210_000
245
+
246
+
247
+ def hash_password(password: str) -> str:
248
+ salt = secrets.token_hex(16)
249
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), _PBKDF2_ITER)
250
+ return f"pbkdf2${_PBKDF2_ITER}${salt}${dk.hex()}"
251
+
252
+
253
+ def verify_password(password: str, stored: str) -> bool:
254
+ try:
255
+ algo, iters, salt, hx = stored.split("$")
256
+ if algo != "pbkdf2":
257
+ return False
258
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), int(iters))
259
+ return hmac.compare_digest(dk.hex(), hx)
260
+ except Exception:
261
+ return False
262
+
263
+
264
+ def gen_api_key() -> tuple[str, str, str]:
265
+ """Return (full_key, prefix, sha256_hash). Full key shown at creation (and via reveal endpoint)."""
266
+ full = "relay_" + secrets.token_urlsafe(32)
267
+ return full, full[:12], hashlib.sha256(full.encode()).hexdigest()
268
+
269
+
270
+ def verify_api_key(full_key: str) -> Optional[dict[str, Any]]:
271
+ prefix = full_key[:12]
272
+ row = db_one("SELECT * FROM api_keys WHERE prefix=? AND revoked=0", (prefix,))
273
+ if not row:
274
+ return None
275
+ if not hmac.compare_digest(hashlib.sha256(full_key.encode()).hexdigest(), row["key_hash"]):
276
+ return None
277
+ return row
278
+
279
+
280
+ # ---- JWT (HS256, stdlib only) ----------------------------------------------
281
+
282
+ def _b64url(data: bytes) -> str:
283
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
284
+
285
+
286
+ def _b64url_decode(s: str) -> bytes:
287
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
288
+
289
+
290
+ def create_token(user_id: str, project_id: str) -> str:
291
+ now = int(time.time())
292
+ header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
293
+ payload = _b64url(json.dumps({
294
+ "sub": user_id, "pid": project_id, "iss": settings.jwt_issuer,
295
+ "iat": now, "exp": now + settings.jwt_expiry_minutes * 60,
296
+ }, separators=(",", ":")).encode())
297
+ signing_input = f"{header}.{payload}"
298
+ sig = hmac.new(settings.jwt_secret.encode(), signing_input.encode(), hashlib.sha256).digest()
299
+ return f"{signing_input}.{_b64url(sig)}"
300
+
301
+
302
+ def decode_token(token: str) -> Optional[dict[str, Any]]:
303
+ try:
304
+ header, payload, sig = token.split(".")
305
+ signing_input = f"{header}.{payload}"
306
+ expected = _b64url(hmac.new(settings.jwt_secret.encode(), signing_input.encode(), hashlib.sha256).digest())
307
+ if not hmac.compare_digest(sig, expected):
308
+ return None
309
+ data = json.loads(_b64url_decode(payload))
310
+ if data.get("exp", 0) < int(time.time()):
311
+ return None
312
+ if data.get("iss") != settings.jwt_issuer:
313
+ return None
314
+ return data
315
+ except Exception:
316
+ return None
317
+
318
+
319
+ def utcnow() -> str:
320
+ return datetime.now(timezone.utc).isoformat()
321
+
322
+
323
+ def gen_id(prefix: str) -> str:
324
+ return f"{prefix}_{uuid.uuid4().hex[:24]}"
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Pydantic schemas
329
+ # ---------------------------------------------------------------------------
330
+
331
+
332
+ class SignupRequest(BaseModel):
333
+ email: str
334
+ password: str = Field(min_length=6)
335
+ name: str = ""
336
+ project_name: str = "Default"
337
+
338
+
339
+ class LoginRequest(BaseModel):
340
+ email: str
341
+ password: str
342
+
343
+
344
+ class ProjectCreate(BaseModel):
345
+ name: str
346
+
347
+
348
+ class KeyCreate(BaseModel):
349
+ name: str = "default"
350
+
351
+
352
+ class CredentialCreate(BaseModel):
353
+ kind: str # stt | llm | tts
354
+ provider: str # deepgram | openai | anthropic | groq | deepseek | elevenlabs | cartesia | whisper | custom
355
+ api_key: str = ""
356
+ model: str = ""
357
+ base_url: str = ""
358
+ voice: str = ""
359
+ language: str = ""
360
+
361
+
362
+ class AgentCreate(BaseModel):
363
+ name: str = "My Agent"
364
+ system_prompt: str = "You are a helpful voice assistant."
365
+ voice: str = ""
366
+ tools: list[dict[str, Any]] = Field(default_factory=list)
367
+ config: dict[str, Any] = Field(default_factory=dict)
368
+
369
+
370
+ class ToolCreate(BaseModel):
371
+ name: str
372
+ description: str = ""
373
+ parameters: dict[str, Any] = Field(default_factory=lambda: {"type": "object", "properties": {}})
374
+
375
+
376
+ class CallCreate(BaseModel):
377
+ agent_id: str = ""
378
+ phone: str = ""
379
+ webhook_url: str = ""
380
+
381
+
382
+ class ChatRequest(BaseModel):
383
+ model: str = ""
384
+ messages: list[dict[str, Any]]
385
+ tools: Optional[list[dict[str, Any]]] = None
386
+ temperature: float = 0.7
387
+ stream: bool = False
388
+
389
+
390
+ class TTSRequest(BaseModel):
391
+ text: str
392
+ voice: str = ""
393
+ model: str = ""
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Auth dependencies
398
+ # ---------------------------------------------------------------------------
399
+
400
+
401
+ def extract_api_key(request: Request) -> Optional[str]:
402
+ auth = request.headers.get("Authorization", "")
403
+ if auth.lower().startswith("bearer "):
404
+ return auth[7:].strip()
405
+ key = request.headers.get("X-API-Key")
406
+ if key:
407
+ return key
408
+ return request.query_params.get("api_key")
409
+
410
+
411
+ def _auth_identity(request: Request) -> Optional[tuple[str, str]]:
412
+ """Return (project_id, user_id) from a valid JWT OR API key, else None.
413
+
414
+ Priority: JWT bearer token first, then API key. Allows:
415
+ - Dashboard calls: Authorization: Bearer <jwt>
416
+ - Programmatic: Authorization: Bearer <api_key> (or ?api_key= for WS)
417
+ """
418
+ auth = request.headers.get("Authorization", "")
419
+ if auth.lower().startswith("bearer "):
420
+ cred = auth[7:].strip()
421
+ # try JWT first
422
+ if "." in cred and len(cred) < 500:
423
+ tok = decode_token(cred)
424
+ if tok and tok.get("sub") and tok.get("pid"):
425
+ return tok["pid"], tok["sub"]
426
+ # else treat as API key
427
+ row = verify_api_key(cred)
428
+ if row:
429
+ proj = db_one("SELECT user_id FROM projects WHERE id=?", (row["project_id"],))
430
+ if proj:
431
+ return row["project_id"], proj["user_id"]
432
+ # API key via X-API-Key header
433
+ xkey = request.headers.get("X-API-Key")
434
+ if xkey:
435
+ row = verify_api_key(xkey)
436
+ if row:
437
+ proj = db_one("SELECT user_id FROM projects WHERE id=?", (row["project_id"],))
438
+ if proj:
439
+ return row["project_id"], proj["user_id"]
440
+ # API key via query (for WebSockets)
441
+ qkey = request.query_params.get("api_key")
442
+ if qkey:
443
+ row = verify_api_key(qkey)
444
+ if row:
445
+ proj = db_one("SELECT user_id FROM projects WHERE id=?", (row["project_id"],))
446
+ if proj:
447
+ return row["project_id"], proj["user_id"]
448
+ return None
449
+
450
+
451
+ def project_from_request(request: Request) -> dict[str, Any]:
452
+ ident = _auth_identity(request)
453
+ if not ident:
454
+ raise HTTPException(status_code=401, detail="Missing/invalid auth. Use Authorization: Bearer <jwt> or <api_key>.")
455
+ project = db_one("SELECT * FROM projects WHERE id=?", (ident[0],))
456
+ if not project:
457
+ raise HTTPException(status_code=401, detail="Project not found")
458
+ return project
459
+
460
+
461
+ def user_from_request(request: Request) -> dict[str, Any]:
462
+ project = project_from_request(request)
463
+ user = db_one("SELECT * FROM users WHERE id=?", (project["user_id"],))
464
+ if not user:
465
+ raise HTTPException(status_code=401, detail="User not found")
466
+ return user
467
+
468
+
469
+ # ---------------------------------------------------------------------------
470
+ # Provider configuration: project credentials take priority over globals
471
+ # ---------------------------------------------------------------------------
472
+
473
+
474
+ def _global_cfg(kind: str) -> tuple[str, dict[str, Any]]:
475
+ if kind == "stt":
476
+ return settings.stt_provider, {
477
+ "provider": settings.stt_provider,
478
+ "api_key": settings.deepgram_api_key if settings.stt_provider == "deepgram" else settings.openai_api_key,
479
+ "model": settings.deepgram_stt_model if settings.stt_provider == "deepgram" else settings.whisper_stt_model,
480
+ "language": settings.deepgram_language,
481
+ }
482
+ if kind == "llm":
483
+ base = settings.llm_base_url
484
+ if settings.llm_provider == "anthropic":
485
+ return "anthropic", {"provider": "anthropic", "api_key": settings.anthropic_api_key, "model": settings.anthropic_model, "base_url": "https://api.anthropic.com"}
486
+ if settings.llm_provider in ("groq",):
487
+ base = base or "https://api.groq.com/openai/v1"
488
+ return settings.llm_provider, {"provider": settings.llm_provider, "api_key": settings.llm_api_key, "model": settings.llm_model, "base_url": base}
489
+ # tts
490
+ return settings.tts_provider, {
491
+ "provider": settings.tts_provider,
492
+ "api_key": settings.deepgram_api_key if settings.tts_provider == "deepgram"
493
+ else (settings.elevenlabs_api_key if settings.tts_provider == "elevenlabs"
494
+ else (settings.cartesia_api_key if settings.tts_provider == "cartesia" else settings.openai_api_key)),
495
+ "model": settings.deepgram_tts_model if settings.tts_provider == "deepgram" else settings.openai_tts_model,
496
+ "voice": settings.deepgram_tts_voice if settings.tts_provider == "deepgram"
497
+ else (settings.elevenlabs_voice_id if settings.tts_provider == "elevenlabs"
498
+ else (settings.cartesia_voice_id if settings.tts_provider == "cartesia" else settings.openai_tts_voice)),
499
+ }
500
+
501
+
502
+ def resolve_provider(project_id: str, kind: str) -> tuple[str, dict[str, Any]]:
503
+ """Return (provider, config) for a project, falling back to global settings."""
504
+ row = db_one(
505
+ "SELECT provider, config FROM provider_credentials WHERE project_id=? AND kind=? ORDER BY created_at DESC LIMIT 1",
506
+ (project_id, kind),
507
+ )
508
+ if row:
509
+ cfg = json.loads(row["config"])
510
+ return row["provider"], cfg
511
+ return _global_cfg(kind)
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Usage metering + billing
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ def record_usage(project_id: str, call_id: str, kind: str, provider: str, model: str,
520
+ units: float = 0, latency_ms: float = 0, cost: float = 0.0):
521
+ try:
522
+ db_exec(
523
+ "INSERT INTO usage (id,project_id,call_id,kind,provider,model,units,latency_ms,cost,created_at) "
524
+ "VALUES (?,?,?,?,?,?,?,?,?,?)",
525
+ (gen_id("usage"), project_id, call_id, kind, provider, model, units, latency_ms, cost, utcnow()),
526
+ )
527
+ except Exception as exc:
528
+ log.warning("usage record failed: %s", exc)
529
+
530
+
531
+ def estimate_cost(kind: str, provider: str, units: float) -> float:
532
+ if kind == "stt":
533
+ return units * settings.rate_stt_per_sec # units = audio seconds
534
+ if kind == "llm":
535
+ # units stored as (input_tokens, output_tokens) via payload string below
536
+ return 0.0
537
+ if kind == "tts":
538
+ return units * settings.rate_tts_per_1k_chars / 1000.0
539
+ return 0.0
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Audio codecs (G.711 mu-law + simple resampling)
544
+ # ---------------------------------------------------------------------------
545
+
546
+ try:
547
+ import audioop as _audioop # stdlib, removed in Python 3.13+
548
+
549
+ def pcm_to_mulaw(pcm: bytes) -> bytes:
550
+ return _audioop.lin2ulaw(pcm, 2)
551
+
552
+ def mulaw_to_pcm(mulaw: bytes) -> bytes:
553
+ return _audioop.ulaw2lin(mulaw, 2)
554
+
555
+ _G711 = "audioop"
556
+ except ImportError: # pragma: no cover
557
+ _CLIP = 32635
558
+ _BIAS = 132
559
+ _EXP_LUT = [0, 132, 396, 924, 1980, 4092, 8316, 16764]
560
+
561
+ def _lin2ulaw(sample: int) -> int:
562
+ sign = (sample >> 8) & 0x80
563
+ if sign:
564
+ sample = -sample
565
+ if sample > _CLIP:
566
+ sample = _CLIP
567
+ sample += _BIAS
568
+ exp = 0
569
+ while sample >= 256:
570
+ sample >>= 1
571
+ exp += 1
572
+ mantissa = (sample >> 4) & 0x0F
573
+ return (~(sign | (exp << 4) | mantissa)) & 0xFF
574
+
575
+ def _ulaw2lin(b: int) -> int:
576
+ u = (~b) & 0xFF
577
+ sign = u & 0x80
578
+ exp = (u >> 4) & 0x07
579
+ man = u & 0x0F
580
+ val = _EXP_LUT[exp] + (man << (exp + 3))
581
+ return -val if sign else val
582
+
583
+ def pcm_to_mulaw(pcm: bytes) -> bytes:
584
+ return bytes(_lin2ulaw(struct.unpack_from("<h", pcm, i)[0]) for i in range(0, len(pcm), 2))
585
+
586
+ def mulaw_to_pcm(mulaw: bytes) -> bytes:
587
+ out = bytearray(len(mulaw) * 2)
588
+ for i, b in enumerate(mulaw):
589
+ struct.pack_into("<h", out, i * 2, _ulaw2lin(b))
590
+ return bytes(out)
591
+
592
+ _G711 = "python"
593
+
594
+
595
+ def resample_pcm16(data: bytes, src_rate: int, dst_rate: int) -> bytes:
596
+ if src_rate == dst_rate:
597
+ return data
598
+ if np is None:
599
+ return data
600
+ arr = np.frombuffer(data, dtype="<i2")
601
+ n = int(len(arr) * dst_rate / src_rate)
602
+ out = np.interp(
603
+ np.linspace(0, len(arr) - 1, n),
604
+ np.arange(len(arr)),
605
+ arr.astype(np.float64),
606
+ ).astype("<i2").tobytes()
607
+ return out
608
+
609
+
610
+ def mix_audio_chunks(pcm: bytes, sample_rate: int, frame_ms: int = 20) -> bytes:
611
+ """No-op passthrough helper kept for API symmetry."""
612
+ return pcm
613
+
614
+
615
+ # ---------------------------------------------------------------------------
616
+ # Deepgram streaming STT client (websocket)
617
+ # ---------------------------------------------------------------------------
618
+
619
+
620
+ class DeepgramSTTStream:
621
+ def __init__(self, api_key: str, model: str, language: str, sample_rate: int,
622
+ encoding: str = "linear16", interim: bool = True,
623
+ endpointing: int = 300, utterance_end_ms: int = 1000):
624
+ self.api_key = api_key
625
+ self.model = model or "nova-3"
626
+ self.language = language or "en-US"
627
+ self.sample_rate = sample_rate
628
+ self.encoding = encoding
629
+ self.interim = interim
630
+ self.endpointing = endpointing
631
+ self.utterance_end_ms = utterance_end_ms
632
+ self.ws: Optional[Any] = None
633
+ self._lock = asyncio.Lock()
634
+
635
+ def url(self) -> str:
636
+ q = (
637
+ f"model={self.model}&language={self.language}&encoding={self.encoding}"
638
+ f"&sample_rate={self.sample_rate}&channels=1"
639
+ f"&interim_results={'true' if self.interim else 'false'}"
640
+ f"&endpointing={self.endpointing}&utterance_end_ms={self.utterance_end_ms}"
641
+ f"&vad_events=true&smart_format=true"
642
+ )
643
+ return f"wss://api.deepgram.com/v1/listen?{q}"
644
+
645
+ async def _open(self) -> Any:
646
+ headers = {"Authorization": f"Token {self.api_key}"}
647
+ try:
648
+ return await websockets.connect(self.url(), additional_headers=headers, max_size=None,
649
+ open_timeout=3, ping_interval=None)
650
+ except TypeError:
651
+ return await websockets.connect(self.url(), extra_headers=headers, max_size=None,
652
+ open_timeout=3, ping_interval=None)
653
+
654
+ async def connect(self):
655
+ async with self._lock:
656
+ if self.ws is None:
657
+ if websockets is None:
658
+ raise ProviderError("websockets library not installed")
659
+ self.ws = await asyncio.wait_for(self._open(), timeout=4)
660
+
661
+ async def send_audio(self, pcm: bytes):
662
+ await self.connect()
663
+ await self.ws.send(pcm)
664
+
665
+ async def recv(self):
666
+ await self.connect()
667
+ msg = await self.ws.recv()
668
+ if isinstance(msg, bytes):
669
+ return {"type": "audio"}
670
+ return json.loads(msg)
671
+
672
+ async def close(self):
673
+ if self.ws:
674
+ try:
675
+ await self.ws.close()
676
+ except Exception:
677
+ pass
678
+ self.ws = None
679
+
680
+
681
+ # ---------------------------------------------------------------------------
682
+ # Deepgram streaming TTS client (Flux v2 + Aura v1)
683
+ # ---------------------------------------------------------------------------
684
+
685
+
686
+ class DeepgramTTSStream:
687
+ def __init__(self, api_key: str, model: str, sample_rate: int = 16000, encoding: str = "linear16"):
688
+ self.api_key = api_key
689
+ self.model = model or "flux-haley-en"
690
+ self.sample_rate = sample_rate
691
+ self.encoding = encoding
692
+ self.version = 2 if self.model.startswith("flux") else 1
693
+ self.ws: Optional[Any] = None
694
+ self._lock = asyncio.Lock()
695
+
696
+ def url(self) -> str:
697
+ return (
698
+ f"wss://api.deepgram.com/v{self.version}/speak?model={self.model}"
699
+ f"&encoding={self.encoding}&sample_rate={self.sample_rate}&container=none"
700
+ )
701
+
702
+ async def _open(self) -> Any:
703
+ headers = {"Authorization": f"Token {self.api_key}"}
704
+ try:
705
+ return await websockets.connect(self.url(), additional_headers=headers, max_size=None,
706
+ open_timeout=3, ping_interval=None)
707
+ except TypeError:
708
+ return await websockets.connect(self.url(), extra_headers=headers, max_size=None,
709
+ open_timeout=3, ping_interval=None)
710
+
711
+ async def connect(self):
712
+ async with self._lock:
713
+ if self.ws is None:
714
+ if websockets is None:
715
+ raise ProviderError("websockets library not installed")
716
+ self.ws = await asyncio.wait_for(self._open(), timeout=4)
717
+
718
+ async def speak(self, text: str, text_id: str):
719
+ await self.connect()
720
+ await self.ws.send(json.dumps({"type": "Speak", "text": text, "text_id": text_id}))
721
+
722
+ async def flush(self):
723
+ await self.connect()
724
+ await self.ws.send(json.dumps({"type": "Flush"}))
725
+
726
+ async def clear(self):
727
+ await self.connect()
728
+ await self.ws.send(json.dumps({"type": "Clear"}))
729
+
730
+ async def close(self):
731
+ if self.ws:
732
+ try:
733
+ await self.ws.send(json.dumps({"type": "Close"}))
734
+ await self.ws.close()
735
+ except Exception:
736
+ pass
737
+ self.ws = None
738
+
739
+ async def recv(self):
740
+ await self.connect()
741
+ msg = await self.ws.recv()
742
+ if isinstance(msg, bytes):
743
+ return {"type": "audio", "data": msg}
744
+ return json.loads(msg)
745
+
746
+
747
+ class ProviderError(Exception):
748
+ pass
749
+
750
+
751
+ # ---------------------------------------------------------------------------
752
+ # Non-streaming fallback providers (Whisper STT, file TTS, one-shot synth)
753
+ # ---------------------------------------------------------------------------
754
+
755
+
756
+ class STTFileEngine:
757
+ def __init__(self, provider: str, cfg: dict[str, Any], project_id: str = "", call_id: str = ""):
758
+ self.provider = provider
759
+ self.cfg = cfg
760
+ self.project_id = project_id
761
+ self.call_id = call_id
762
+ self._whisper = None
763
+
764
+ def _load_whisper(self):
765
+ if self._whisper is None:
766
+ import whisper # type: ignore
767
+ self._whisper = whisper.load_model(self.cfg.get("model") or "base", device=settings.whisper_device)
768
+
769
+ async def transcribe(self, wav_bytes: bytes) -> str:
770
+ start = time.perf_counter()
771
+ text = ""
772
+ if self.provider == "openai":
773
+ text = await self._openai(wav_bytes)
774
+ else:
775
+ text = await asyncio.to_thread(self._local_whisper, wav_bytes)
776
+ record_usage(self.project_id, self.call_id, "stt", self.provider, self.cfg.get("model", ""),
777
+ units=0, latency_ms=(time.perf_counter() - start) * 1000)
778
+ return text
779
+
780
+ async def _openai(self, wav_bytes: bytes) -> str:
781
+ api_key = self.cfg.get("api_key") or settings.openai_api_key
782
+ if not api_key:
783
+ raise ProviderError("No OpenAI API key configured for STT")
784
+ url = urljoin((self.cfg.get("base_url") or settings.llm_base_url or "https://api.openai.com/v1"), "audio/transcriptions")
785
+ files = {"file": ("audio.wav", wav_bytes, "audio/wav")}
786
+ data = {"model": self.cfg.get("model") or "whisper-1"}
787
+ async with httpx.AsyncClient(timeout=30) as client:
788
+ r = await client.post(url, headers={"Authorization": f"Bearer {api_key}"}, data=data, files=files)
789
+ if r.status_code != 200:
790
+ raise ProviderError(f"OpenAI STT {r.status_code}: {r.text[:200]}")
791
+ return r.json().get("text", "")
792
+
793
+ def _local_whisper(self, wav_bytes: bytes) -> str:
794
+ self._load_whisper()
795
+ with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
796
+ raw = wf.readframes(wf.getnframes())
797
+ audio = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
798
+ return (self._whisper.transcribe(audio).get("text") or "").strip()
799
+
800
+
801
+ _ctx_project = ""
802
+ _ctx_call = ""
803
+
804
+
805
+ class TTSFileEngine:
806
+ def __init__(self, provider: str, cfg: dict[str, Any], project_id: str = "", call_id: str = ""):
807
+ self.provider = provider
808
+ self.cfg = cfg
809
+ self.project_id = project_id
810
+ self.call_id = call_id
811
+
812
+ async def synthesize(self, text: str, sample_rate: int = 16000) -> bytes:
813
+ start = time.perf_counter()
814
+ if self.provider == "elevenlabs":
815
+ audio = await self._elevenlabs(text)
816
+ elif self.provider == "cartesia":
817
+ audio = await self._cartesia(text)
818
+ else:
819
+ audio = await self._openai(text)
820
+ record_usage(self.project_id, self.call_id, "tts", self.provider, self.cfg.get("model", ""),
821
+ units=len(text), latency_ms=(time.perf_counter() - start) * 1000)
822
+ return audio
823
+
824
+ async def _elevenlabs(self, text: str) -> bytes:
825
+ api_key = self.cfg.get("api_key") or settings.elevenlabs_api_key
826
+ if not api_key:
827
+ raise ProviderError("No ElevenLabs API key")
828
+ url = f"https://api.elevenlabs.io/v1/text-to-speech/{self.cfg.get('voice') or settings.elevenlabs_voice_id}?output_format=pcm_24000"
829
+ async with httpx.AsyncClient(timeout=30) as client:
830
+ r = await client.post(url, headers={"xi-api-key": api_key, "Content-Type": "application/json"}, json={"text": text})
831
+ if r.status_code != 200:
832
+ raise ProviderError(f"ElevenLabs {r.status_code}: {r.text[:200]}")
833
+ return self._wav_from_pcm(r.content, 24000)
834
+
835
+ async def _cartesia(self, text: str) -> bytes:
836
+ api_key = self.cfg.get("api_key") or settings.cartesia_api_key
837
+ if not api_key:
838
+ raise ProviderError("No Cartesia API key")
839
+ body = {"model_id": "sonic-english", "transcript": text,
840
+ "voice": {"mode": "id", "id": self.cfg.get("voice") or settings.cartesia_voice_id},
841
+ "output_format": {"container": "wav", "encoding": "pcm_s16le", "sample_rate": 24000}}
842
+ async with httpx.AsyncClient(timeout=30) as client:
843
+ r = await client.post("https://api.cartesia.ai/tts/bytes",
844
+ headers={"Cartesia-Version": "2024-06-10", "X-API-Key": api_key, "Content-Type": "application/json"},
845
+ json=body)
846
+ if r.status_code != 200:
847
+ raise ProviderError(f"Cartesia {r.status_code}: {r.text[:200]}")
848
+ return r.content if r.content[:4] == b"RIFF" else self._wav_from_pcm(r.content, 24000)
849
+
850
+ async def _openai(self, text: str) -> bytes:
851
+ api_key = self.cfg.get("api_key") or settings.openai_api_key
852
+ if not api_key:
853
+ raise ProviderError("No OpenAI API key for TTS")
854
+ url = urljoin((self.cfg.get("base_url") or "https://api.openai.com/v1"), "audio/speech")
855
+ payload = {"model": self.cfg.get("model") or settings.openai_tts_model,
856
+ "voice": self.cfg.get("voice") or settings.openai_tts_voice, "input": text}
857
+ async with httpx.AsyncClient(timeout=30) as client:
858
+ r = await client.post(url, headers={"Authorization": f"Bearer {api_key}"}, json=payload)
859
+ if r.status_code != 200:
860
+ raise ProviderError(f"OpenAI TTS {r.status_code}: {r.text[:200]}")
861
+ return await self._mp3_to_wav(r.content)
862
+
863
+ async def _mp3_to_wav(self, mp3: bytes) -> bytes:
864
+ try:
865
+ import subprocess
866
+ p = await asyncio.create_subprocess_exec(
867
+ "ffmpeg", "-y", "-i", "pipe:0", "-f", "wav", "pipe:1",
868
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
869
+ out, _ = await p.communicate(mp3)
870
+ if out:
871
+ return out
872
+ except Exception:
873
+ pass
874
+ raise ProviderError("MP3->WAV transcoding requires ffmpeg")
875
+
876
+ @staticmethod
877
+ def _wav_from_pcm(pcm: bytes, sr: int) -> bytes:
878
+ buf = io.BytesIO()
879
+ with wave.open(buf, "wb") as wf:
880
+ wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(sr); wf.writeframes(pcm)
881
+ return buf.getvalue()
882
+
883
+
884
+ # ---------------------------------------------------------------------------
885
+ # Streaming LLM (OpenAI-compatible SSE + Anthropic events)
886
+ # ---------------------------------------------------------------------------
887
+
888
+
889
+ async def llm_stream(messages: list[dict[str, Any]], cfg: dict[str, Any], tools: Optional[list[dict[str, Any]]] = None,
890
+ cancel: Optional[asyncio.Event] = None):
891
+ """Yield {"content": str} and {"tool_call": {...}} chunks from a streaming LLM."""
892
+ provider = cfg.get("provider", "openai")
893
+ api_key = cfg.get("api_key") or settings.llm_api_key or settings.openai_api_key
894
+ model = cfg.get("model") or settings.llm_model
895
+ base_url = cfg.get("base_url") or settings.llm_base_url
896
+
897
+ if provider == "anthropic":
898
+ base = (cfg.get("base_url") or settings.anthropic_base_url or "https://api.anthropic.com").rstrip("/")
899
+ url = base + "/v1/messages"
900
+ system = "\n".join(m["content"] for m in messages if m["role"] == "system")
901
+ convo = [m for m in messages if m["role"] != "system"]
902
+ payload: dict[str, Any] = {"model": model, "max_tokens": 2048, "system": system or "You are a helpful voice assistant.", "messages": convo, "stream": True}
903
+ if tools:
904
+ payload["tools"] = [{"name": t["function"]["name"], "description": t["function"].get("description", ""),
905
+ "input_schema": t["function"].get("parameters", {"type": "object", "properties": {}})} for t in tools]
906
+ headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"}
907
+ async with httpx.AsyncClient(timeout=None) as client:
908
+ async with client.stream("POST", url, headers=headers, json=payload) as resp:
909
+ if resp.status_code != 200:
910
+ raise ProviderError(f"Anthropic {resp.status_code}")
911
+ async for line in resp.aiter_lines():
912
+ if cancel and cancel.is_set():
913
+ break
914
+ if not line.startswith("data: "):
915
+ continue
916
+ obj = json.loads(line[6:])
917
+ t = obj.get("type")
918
+ if t == "content_block_delta":
919
+ yield {"content": obj.get("delta", {}).get("text", "")}
920
+ elif t == "content_block_start":
921
+ pass
922
+ return
923
+
924
+ # OpenAI-compatible (openai, groq, deepseek, custom)
925
+ base = (base_url or "https://api.openai.com/v1").rstrip("/")
926
+ url = base + "/chat/completions"
927
+ payload = {
928
+ "model": model, "messages": messages, "stream": True,
929
+ "temperature": 0.7, "stream_options": {"include_usage": True},
930
+ }
931
+ if tools:
932
+ payload["tools"] = tools
933
+ payload["tool_choice"] = "auto"
934
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
935
+ async with httpx.AsyncClient(timeout=None) as client:
936
+ async with client.stream("POST", url, headers=headers, json=payload) as resp:
937
+ if resp.status_code != 200:
938
+ raise ProviderError(f"LLM {resp.status_code}")
939
+ async for line in resp.aiter_lines():
940
+ if cancel and cancel.is_set():
941
+ break
942
+ if not line.startswith("data:"):
943
+ continue
944
+ data = line[5:].strip()
945
+ if not data or data == "[DONE]":
946
+ continue
947
+ obj = json.loads(data)
948
+ choice = obj.get("choices", [{}])[0]
949
+ delta = choice.get("delta", {})
950
+ if delta.get("content"):
951
+ yield {"content": delta["content"]}
952
+ for tc in delta.get("tool_calls") or []:
953
+ yield {"tool_call": tc}
954
+
955
+
956
+ async def execute_tool_call(project_id: str, call_id: str, name: str, args: dict[str, Any]) -> dict[str, Any]:
957
+ tool = db_one("SELECT * FROM tools WHERE project_id=? AND name=?", (project_id, name))
958
+ await dispatch_event(project_id, call_id, "tool.call", {"name": name, "arguments": args})
959
+ if not tool:
960
+ return {"success": False, "error": f"Unknown tool: {name}"}
961
+ handler = TOOL_HANDLERS.get(name)
962
+ if handler:
963
+ try:
964
+ if asyncio.iscoroutinefunction(handler):
965
+ result = await handler(**args)
966
+ else:
967
+ result = await asyncio.to_thread(handler, **args)
968
+ return {"success": True, "result": result}
969
+ except Exception as exc:
970
+ return {"success": False, "error": str(exc)}
971
+ return {"success": True, "deferred": True}
972
+
973
+
974
+ TOOL_HANDLERS: dict[str, Any] = {}
975
+
976
+
977
+ async def llm_generate(messages: list[dict[str, Any]], cfg: dict[str, Any], project_id: str = "",
978
+ call_id: str = "", tools: Optional[list[dict[str, Any]]] = None,
979
+ on_token: Optional[Callable[[str], Any]] = None,
980
+ cancel: Optional[asyncio.Event] = None) -> str:
981
+ """Stream an LLM response, executing tool calls, returning final text."""
982
+ provider = cfg.get("provider", "openai")
983
+ model = cfg.get("model") or settings.llm_model
984
+ start = time.perf_counter()
985
+ full = ""
986
+ input_tokens = 0
987
+ output_tokens = 0
988
+
989
+ for _ in range(6):
990
+ tool_calls: dict[int, dict[str, Any]] = {}
991
+ content = ""
992
+ async for chunk in llm_stream(messages, cfg, tools, cancel):
993
+ if cancel and cancel.is_set():
994
+ return full
995
+ if chunk.get("content"):
996
+ content += chunk["content"]
997
+ if on_token:
998
+ await on_token(chunk["content"])
999
+ if chunk.get("tool_call"):
1000
+ tc = chunk["tool_call"]
1001
+ idx = tc.get("index", 0)
1002
+ entry = tool_calls.setdefault(idx, {"id": tc.get("id") or f"call_{idx}", "name": "", "arguments": ""})
1003
+ if tc.get("id"):
1004
+ entry["id"] = tc["id"]
1005
+ fn = tc.get("function", {})
1006
+ if fn.get("name"):
1007
+ entry["name"] += fn["name"]
1008
+ if fn.get("arguments"):
1009
+ entry["arguments"] += fn["arguments"]
1010
+ full = content
1011
+ if not tool_calls:
1012
+ break
1013
+ assistant_msg = {"role": "assistant", "content": content or None,
1014
+ "tool_calls": [{"id": v["id"], "type": "function",
1015
+ "function": {"name": v["name"], "arguments": v["arguments"]}} for v in tool_calls.values()]}
1016
+ messages.append(assistant_msg)
1017
+ for idx in sorted(tool_calls):
1018
+ call = tool_calls[idx]
1019
+ try:
1020
+ args = json.loads(call["arguments"]) if call["arguments"] else {}
1021
+ except Exception:
1022
+ args = {}
1023
+ result = await execute_tool_call(project_id, call_id, call["name"], args)
1024
+ messages.append({"role": "tool", "tool_call_id": call["id"], "content": json.dumps(result)})
1025
+ else:
1026
+ pass
1027
+
1028
+ latency = (time.perf_counter() - start) * 1000
1029
+ # approx token counts
1030
+ input_tokens = sum(len(str(m.get("content", ""))) // 4 for m in messages)
1031
+ output_tokens = max(len(full) // 4, 1)
1032
+ cost = (input_tokens / 1000) * settings.rate_llm_input_per_1k + (output_tokens / 1000) * settings.rate_llm_output_per_1k
1033
+ record_usage(project_id, call_id, "llm", provider, model, units=output_tokens, latency_ms=latency, cost=cost)
1034
+ return full
1035
+
1036
+
1037
+ from typing import Callable # noqa: E402 (used above)
1038
+
1039
+ # ---------------------------------------------------------------------------
1040
+ # Webhooks / events
1041
+ # ---------------------------------------------------------------------------
1042
+
1043
+
1044
+ async def dispatch_event(project_id: str, call_id: str, event_type: str, payload: dict[str, Any]):
1045
+ db_exec("INSERT INTO events (id,project_id,call_id,type,payload,created_at) VALUES (?,?,?,?,?,?)",
1046
+ (gen_id("event"), project_id, call_id, event_type, json.dumps(payload), utcnow()))
1047
+ project = db_one("SELECT * FROM projects WHERE id=?", (project_id,))
1048
+ url = payload.pop("webhook_url", None) or (project and project.get("webhook_url")) or settings.webhook_url
1049
+ if not url:
1050
+ return
1051
+ body = json.dumps({"event": event_type, "call_id": call_id, "data": payload, "ts": utcnow()}, separators=(",", ":"))
1052
+ headers = {"Content-Type": "application/json"}
1053
+ if settings.webhook_secret:
1054
+ sig = hmac.new(settings.webhook_secret.encode(), body.encode(), hashlib.sha256).hexdigest()
1055
+ headers["X-Relay-Signature"] = sig
1056
+ try:
1057
+ async with httpx.AsyncClient(timeout=5) as client:
1058
+ await client.post(url, content=body, headers=headers)
1059
+ except Exception as exc:
1060
+ log.warning("webhook %s failed: %s", event_type, exc)
1061
+
1062
+
1063
+ # ---------------------------------------------------------------------------
1064
+ # Transports: normalize client WebSocket and Twilio media streams
1065
+ # ---------------------------------------------------------------------------
1066
+
1067
+
1068
+ class Transport:
1069
+ async def recv(self): ...
1070
+ async def send_json(self, data: dict[str, Any]): ...
1071
+ async def send_audio(self, pcm: bytes, sample_rate: int): ...
1072
+ async def close(self): ...
1073
+
1074
+
1075
+ class WebSocketTransport(Transport):
1076
+ """Client sends binary PCM16 at 16 kHz (default); server returns base64 audio JSON."""
1077
+
1078
+ def __init__(self, ws: WebSocket):
1079
+ self.ws = ws
1080
+ self.sample_rate = settings.sample_rate
1081
+
1082
+ async def recv(self):
1083
+ msg = await self.ws.receive()
1084
+ if msg.get("type") == "websocket.disconnect":
1085
+ return ("close", None)
1086
+ if msg.get("type") == "websocket.receive":
1087
+ data = msg.get("bytes") or msg.get("text")
1088
+ if isinstance(data, bytes):
1089
+ return ("audio", data)
1090
+ if isinstance(data, str):
1091
+ try:
1092
+ return ("json", json.loads(data))
1093
+ except Exception:
1094
+ return ("json", {"type": "unknown"})
1095
+ return ("close", None)
1096
+
1097
+ async def send_json(self, data: dict[str, Any]):
1098
+ try:
1099
+ await self.ws.send_text(json.dumps(data))
1100
+ except Exception:
1101
+ pass
1102
+
1103
+ async def send_audio(self, pcm: bytes, sample_rate: int):
1104
+ try:
1105
+ await self.ws.send_bytes(pcm)
1106
+ except Exception:
1107
+ pass
1108
+
1109
+ async def close(self):
1110
+ try:
1111
+ await self.ws.close()
1112
+ except Exception:
1113
+ pass
1114
+
1115
+
1116
+ class TwilioTransport(Transport):
1117
+ """Twilio media streams: inbound mulaw 8k JSON -> PCM16 16k; outbound PCM -> mulaw 8k media events."""
1118
+
1119
+ def __init__(self, ws: WebSocket):
1120
+ self.ws = ws
1121
+ self.stream_sid: Optional[str] = None
1122
+ self.in_rate = 8000
1123
+ self.out_rate = 8000
1124
+
1125
+ async def recv(self):
1126
+ msg = await self.ws.receive()
1127
+ if msg.get("type") == "websocket.disconnect":
1128
+ return ("close", None)
1129
+ data = msg.get("text") or msg.get("bytes")
1130
+ if isinstance(data, bytes):
1131
+ return ("audio", resample_pcm16(data, self.in_rate, 16000))
1132
+ if isinstance(data, str):
1133
+ try:
1134
+ obj = json.loads(data)
1135
+ except Exception:
1136
+ return ("json", {})
1137
+ event = obj.get("event")
1138
+ if event == "start":
1139
+ self.stream_sid = obj.get("streamSid")
1140
+ fmt = obj.get("start", {}).get("mediaFormat", {})
1141
+ self.in_rate = int(fmt.get("sampleRate", 8000))
1142
+ return ("json", {"type": "start", "stream_sid": self.stream_sid})
1143
+ if event == "media":
1144
+ payload = obj.get("media", {}).get("payload", "")
1145
+ if payload:
1146
+ mulaw = base64.b64decode(payload)
1147
+ pcm8k = mulaw_to_pcm(mulaw)
1148
+ return ("audio", resample_pcm16(pcm8k, 8000, 16000))
1149
+ return ("audio", b"")
1150
+ if event == "stop":
1151
+ return ("close", None)
1152
+ return ("json", {"type": event})
1153
+ return ("close", None)
1154
+
1155
+ async def send_json(self, data: dict[str, Any]):
1156
+ try:
1157
+ await self.ws.send_text(json.dumps(data))
1158
+ except Exception:
1159
+ pass
1160
+
1161
+ async def send_audio(self, pcm: bytes, sample_rate: int):
1162
+ if not self.stream_sid:
1163
+ return
1164
+ pcm8k = resample_pcm16(pcm, sample_rate, 8000)
1165
+ mulaw = pcm_to_mulaw(pcm8k)
1166
+ payload = base64.b64encode(mulaw).decode()
1167
+ try:
1168
+ await self.ws.send_text(json.dumps({
1169
+ "event": "media",
1170
+ "streamSid": self.stream_sid,
1171
+ "media": {"payload": payload, "track": "outbound"},
1172
+ }))
1173
+ except Exception:
1174
+ pass
1175
+
1176
+ async def close(self):
1177
+ try:
1178
+ await self.ws.close()
1179
+ except Exception:
1180
+ pass
1181
+
1182
+
1183
+ # ---------------------------------------------------------------------------
1184
+ # Voice pipeline (streaming-first, turn-based fallback, barge-in)
1185
+ # ---------------------------------------------------------------------------
1186
+
1187
+
1188
+ def split_sentences(text: str) -> list[str]:
1189
+ import re
1190
+ parts = re.split(r"(?<=[.!?])\s+|\n+", text)
1191
+ return [p.strip() for p in parts if p.strip()]
1192
+
1193
+
1194
+ class VoicePipeline:
1195
+ def __init__(self, call_id: str, project_id: str, agent: dict[str, Any],
1196
+ transport: Transport, client_sample_rate: int = 16000):
1197
+ self.call_id = call_id
1198
+ self.project_id = project_id
1199
+ self.agent = agent
1200
+ self.transport = transport
1201
+ self.client_rate = client_sample_rate
1202
+ self.conversation: list[dict[str, Any]] = []
1203
+ self.state = "listening"
1204
+ self.cancel_llm = asyncio.Event()
1205
+ self.speaking = False
1206
+ self.llm_task: Optional[asyncio.Task] = None
1207
+ self.stt_stream: Optional[DeepgramSTTStream] = None
1208
+ self.tts_stream: Optional[DeepgramTTSStream] = None
1209
+ self.stt_file: Optional[STTFileEngine] = None
1210
+ self.tts_file: Optional[TTSFileEngine] = None
1211
+ self.turn_buffer = bytearray()
1212
+ self.last_speech = 0.0
1213
+ self.vad_threshold = 0.012
1214
+ self.inbound_recording = bytearray()
1215
+ self.recording_path: Optional[str] = None
1216
+
1217
+ self.stt_provider, self.stt_cfg = resolve_provider(project_id, "stt")
1218
+ self.llm_provider, self.llm_cfg = resolve_provider(project_id, "llm")
1219
+ self.tts_provider, self.tts_cfg = resolve_provider(project_id, "tts")
1220
+ self.streaming_stt = self.stt_provider == "deepgram"
1221
+ self.streaming_tts = self.tts_provider == "deepgram"
1222
+
1223
+ # ---- lifecycle ------------------------------------------------------
1224
+ async def start(self):
1225
+ await self.transport.send_json({"type": "session", "call_id": self.call_id,
1226
+ "sample_rate": self.client_rate,
1227
+ "model": self.llm_cfg.get("model", ""),
1228
+ "stt": self.stt_provider, "llm": self.llm_provider, "tts": self.tts_provider})
1229
+ if self.streaming_stt:
1230
+ self.stt_stream = DeepgramSTTStream(
1231
+ self.stt_cfg.get("api_key"), self.stt_cfg.get("model"), self.stt_cfg.get("language"),
1232
+ self.client_rate, interim=settings.deepgram_interim,
1233
+ endpointing=settings.deepgram_endpointing, utterance_end_ms=settings.deepgram_utterance_end_ms)
1234
+ asyncio.create_task(self._stt_read_loop())
1235
+ else:
1236
+ self.stt_file = STTFileEngine(self.stt_provider, self.stt_cfg, project_id=project_id, call_id=call_id)
1237
+ self.vad_threshold = float(self.agent.get("config", {}).get("vad_threshold", 0.012))
1238
+
1239
+ if not self.streaming_tts:
1240
+ self.tts_file = TTSFileEngine(self.tts_provider, self.tts_cfg, project_id=project_id, call_id=call_id)
1241
+
1242
+ # greeting
1243
+ greeting = self.agent.get("config", {}).get("greeting")
1244
+ if greeting:
1245
+ await self.speak(greeting)
1246
+
1247
+ async def run(self):
1248
+ try:
1249
+ while True:
1250
+ kind, payload = await self.transport.recv()
1251
+ if kind == "close":
1252
+ break
1253
+ if kind == "audio":
1254
+ await self._on_audio(payload)
1255
+ elif kind == "json":
1256
+ await self._on_json(payload)
1257
+ except WebSocketDisconnect:
1258
+ pass
1259
+ except Exception as exc:
1260
+ log.warning("pipeline error: %s", exc)
1261
+ finally:
1262
+ await self.cleanup()
1263
+
1264
+ # ---- inbound --------------------------------------------------------
1265
+ async def _on_audio(self, pcm: bytes):
1266
+ if settings.record_audio:
1267
+ self.inbound_recording.extend(pcm)
1268
+ if self.streaming_stt:
1269
+ # barge-in: speech energy while assistant talking
1270
+ if self.speaking and self._has_energy(pcm):
1271
+ await self.barge_in()
1272
+ if self.stt_stream:
1273
+ try:
1274
+ await self.stt_stream.send_audio(pcm)
1275
+ except Exception as exc:
1276
+ log.warning("stt send: %s", exc)
1277
+ else:
1278
+ await self._turn_based_audio(pcm)
1279
+
1280
+ def _has_energy(self, pcm: bytes) -> bool:
1281
+ if np is None or len(pcm) < 2:
1282
+ return False
1283
+ arr = np.frombuffer(pcm, dtype="<i2").astype(np.float32) / 32768.0
1284
+ return float(np.sqrt(np.mean(np.square(arr)))) > self.vad_threshold
1285
+
1286
+ async def _turn_based_audio(self, pcm: bytes):
1287
+ speech = self._has_energy(pcm)
1288
+ if speech:
1289
+ if not self.turn_buffer:
1290
+ self.last_speech = time.perf_counter()
1291
+ await self.transport.send_json({"type": "status", "state": "user_speaking"})
1292
+ self.turn_buffer.extend(pcm)
1293
+ self.last_speech = time.perf_counter()
1294
+ else:
1295
+ if self.turn_buffer and (time.perf_counter() - self.last_speech) >= (self.agent.get("config", {}).get("silence_timeout_ms", settings.silence_timeout_ms) / 1000.0):
1296
+ await self._process_turn(bytes(self.turn_buffer))
1297
+ self.turn_buffer.clear()
1298
+
1299
+ async def _process_turn(self, pcm: bytes):
1300
+ buf = io.BytesIO()
1301
+ with wave.open(buf, "wb") as wf:
1302
+ wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(self.client_rate); wf.writeframes(pcm)
1303
+ start = time.perf_counter()
1304
+ text = ""
1305
+ try:
1306
+ text = await self.stt_file.transcribe(buf.getvalue())
1307
+ except ProviderError as exc:
1308
+ log.warning("stt: %s", exc)
1309
+ if text:
1310
+ record_usage(self.project_id, self.call_id, "stt", self.stt_provider, self.stt_cfg.get("model", ""),
1311
+ units=0, latency_ms=(time.perf_counter() - start) * 1000)
1312
+ await self.handle_user_text(text)
1313
+
1314
+ async def _on_json(self, data: dict[str, Any]):
1315
+ typ = data.get("type")
1316
+ if typ in ("end_of_turn", "eot"):
1317
+ if self.turn_buffer:
1318
+ await self._process_turn(bytes(self.turn_buffer))
1319
+ self.turn_buffer.clear()
1320
+ elif typ == "barge_in":
1321
+ await self.barge_in()
1322
+ elif typ == "ping":
1323
+ await self.transport.send_json({"type": "pong"})
1324
+
1325
+ # ---- STT read loop (streaming) --------------------------------------
1326
+ async def _stt_read_loop(self):
1327
+ try:
1328
+ while True:
1329
+ data = await self.stt_stream.recv()
1330
+ typ = data.get("type")
1331
+ if typ == "Results":
1332
+ alt = data.get("channel", {}).get("alternatives", [{}])[0]
1333
+ text = alt.get("transcript", "")
1334
+ is_final = data.get("is_final", False)
1335
+ if text:
1336
+ await self.transport.send_json({"type": "transcript", "role": "user", "text": text, "is_final": is_final})
1337
+ if is_final and text.strip():
1338
+ record_usage(self.project_id, self.call_id, "stt", self.stt_provider, self.stt_cfg.get("model", ""),
1339
+ units=0, latency_ms=0)
1340
+ await self.handle_user_text(text.strip())
1341
+ elif typ == "UtteranceEnd":
1342
+ await self.transport.send_json({"type": "utterance_end"})
1343
+ elif typ == "SpeechStarted":
1344
+ if self.speaking:
1345
+ await self.barge_in()
1346
+ except Exception as exc:
1347
+ log.warning("stt loop: %s", exc)
1348
+
1349
+ # ---- turn processing ------------------------------------------------
1350
+ async def handle_user_text(self, text: str):
1351
+ if self.speaking or (self.llm_task and not self.llm_task.done()):
1352
+ await self.barge_in()
1353
+ self.conversation.append({"role": "user", "content": text})
1354
+ db_exec("INSERT INTO transcripts (id,call_id,role,content,is_final,ts) VALUES (?,?,?,?,1,?)",
1355
+ (gen_id("t"), self.call_id, "user", text, utcnow()))
1356
+ await self.transport.send_json({"type": "status", "state": "thinking"})
1357
+ self.llm_task = asyncio.create_task(self._run_llm())
1358
+
1359
+ async def _run_llm(self):
1360
+ self.cancel_llm.clear()
1361
+ tools = self.agent.get("tools")
1362
+ buffer = ""
1363
+
1364
+ async def on_token(tok: str):
1365
+ nonlocal buffer
1366
+ buffer += tok
1367
+ await self.transport.send_json({"type": "token", "text": tok})
1368
+ # stream sentences to TTS as they complete
1369
+ sents = split_sentences(buffer)
1370
+ if len(sents) > 1:
1371
+ chunk = " ".join(sents[:-1])
1372
+ buffer = sents[-1]
1373
+ if chunk:
1374
+ await self.speak(chunk)
1375
+
1376
+ try:
1377
+ final = await llm_generate(
1378
+ self.conversation, self.llm_cfg, project_id=self.project_id, call_id=self.call_id,
1379
+ tools=tools, on_token=on_token, cancel=self.cancel_llm)
1380
+ except ProviderError as exc:
1381
+ await self.transport.send_json({"type": "error", "error": str(exc)})
1382
+ return
1383
+ if self.cancel_llm.is_set():
1384
+ return
1385
+ if buffer.strip():
1386
+ await self.speak(buffer)
1387
+ if final:
1388
+ self.conversation.append({"role": "assistant", "content": final})
1389
+ db_exec("INSERT INTO transcripts (id,call_id,role,content,is_final,ts) VALUES (?,?,?,?,1,?)",
1390
+ (gen_id("t"), self.call_id, "assistant", final, utcnow()))
1391
+ await self.transport.send_json({"type": "status", "state": "listening"})
1392
+
1393
+ # ---- TTS ------------------------------------------------------------
1394
+ async def speak(self, text: str):
1395
+ if not text.strip():
1396
+ return
1397
+ if self.speaking:
1398
+ return
1399
+ self.speaking = True
1400
+ await self.transport.send_json({"type": "status", "state": "assistant_speaking"})
1401
+ try:
1402
+ if self.streaming_tts:
1403
+ await self._stream_tts(text)
1404
+ else:
1405
+ audio = await self.tts_file.synthesize(text, sample_rate=self.client_rate)
1406
+ await self.transport.send_audio(audio, self.client_rate)
1407
+ except Exception as exc:
1408
+ await self.transport.send_json({"type": "error", "error": str(exc)})
1409
+ finally:
1410
+ self.speaking = False
1411
+ await self.transport.send_json({"type": "status", "state": "listening"})
1412
+
1413
+ async def _stream_tts(self, text: str):
1414
+ if self.tts_stream is None:
1415
+ self.tts_stream = DeepgramTTSStream(
1416
+ self.tts_cfg.get("api_key"), self.tts_cfg.get("model"), sample_rate=self.client_rate)
1417
+ asyncio.create_task(self._tts_read_loop())
1418
+ text_id = gen_id("tid")
1419
+ await self.tts_stream.speak(text, text_id)
1420
+ if self.tts_stream.version == 1:
1421
+ await self.tts_stream.flush()
1422
+
1423
+ async def _tts_read_loop(self):
1424
+ try:
1425
+ while True:
1426
+ data = await self.tts_stream.recv()
1427
+ if data.get("type") == "audio":
1428
+ await self.transport.send_audio(data["data"], self.client_rate)
1429
+ elif data.get("type") in ("Flushed", "Cleared", "Close"):
1430
+ await self.transport.send_json({"type": "tts_event", "event": data.get("type")})
1431
+ except Exception:
1432
+ pass
1433
+
1434
+ # ---- barge-in -------------------------------------------------------
1435
+ async def barge_in(self):
1436
+ if self.cancel_llm.is_set():
1437
+ return
1438
+ self.cancel_llm.set()
1439
+ self.speaking = False
1440
+ if self.llm_task and not self.llm_task.done():
1441
+ self.llm_task.cancel()
1442
+ if self.tts_stream:
1443
+ try:
1444
+ await self.tts_stream.clear()
1445
+ except Exception:
1446
+ pass
1447
+ await self.transport.send_json({"type": "barge_in"})
1448
+ await dispatch_event(self.project_id, self.call_id, "barge_in", {"state": "interrupted"})
1449
+
1450
+ # ---- cleanup --------------------------------------------------------
1451
+ async def cleanup(self):
1452
+ if self.stt_stream:
1453
+ try:
1454
+ await self.stt_stream.close()
1455
+ except Exception:
1456
+ pass
1457
+ if self.tts_stream:
1458
+ try:
1459
+ await self.tts_stream.close()
1460
+ except Exception:
1461
+ pass
1462
+ if self.llm_task and not self.llm_task.done():
1463
+ self.llm_task.cancel()
1464
+ if settings.record_audio and self.inbound_recording:
1465
+ try:
1466
+ path = os.path.join(RECORDING_DIR, f"{self.call_id}.wav")
1467
+ with wave.open(path, "wb") as wf:
1468
+ wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(self.client_rate)
1469
+ wf.writeframes(bytes(self.inbound_recording))
1470
+ self.recording_path = path
1471
+ except Exception as exc:
1472
+ log.warning("recording: %s", exc)
1473
+ db_exec("UPDATE calls SET status='ended', ended_at=?, duration_ms=? WHERE id=?",
1474
+ (utcnow(), int((time.time() - CALL_START.get(self.call_id, time.time())) * 1000), self.call_id))
1475
+ await dispatch_event(self.project_id, self.call_id, "call.ended", {})
1476
+ try:
1477
+ await self.transport.close()
1478
+ except Exception:
1479
+ pass
1480
+
1481
+
1482
+ CALL_START: dict[str, float] = {}
1483
+
1484
+
1485
+ # ---------------------------------------------------------------------------
1486
+ # FastAPI app
1487
+ # ---------------------------------------------------------------------------
1488
+
1489
+ @asynccontextmanager
1490
+ async def lifespan(app: FastAPI):
1491
+ init_db()
1492
+ log.info("%s engine started (env=%s, data=%s)", settings.app_name, settings.environment, settings.data_dir)
1493
+ yield
1494
+
1495
+
1496
+ app = FastAPI(title="Relay", version="2.0.0", lifespan=lifespan)
1497
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
1498
+
1499
+ if os.path.isdir(RECORDING_DIR):
1500
+ app.mount("/data", StaticFiles(directory=RECORDING_DIR), name="data")
1501
+
1502
+
1503
+ # ---- Health ---------------------------------------------------------------
1504
+
1505
+ @app.get("/health")
1506
+ async def health():
1507
+ return {"status": "ok", "app": settings.app_name, "environment": settings.environment,
1508
+ "data_dir": settings.data_dir, "db": DB_PATH, "time": utcnow()}
1509
+
1510
+
1511
+ # ---- Auth -----------------------------------------------------------------
1512
+
1513
+ @app.post("/auth/signup", status_code=201)
1514
+ async def signup(body: SignupRequest):
1515
+ email = body.email.lower().strip()
1516
+ if db_one("SELECT id FROM users WHERE email=?", (email,)):
1517
+ raise HTTPException(status_code=409, detail="Email already registered")
1518
+ user_id = gen_id("user")
1519
+ db_exec("INSERT INTO users (id,email,name,password_hash,created_at) VALUES (?,?,?,?,?)",
1520
+ (user_id, email, body.name, hash_password(body.password), utcnow()))
1521
+ project_id = gen_id("proj")
1522
+ db_exec("INSERT INTO projects (id,user_id,name,created_at) VALUES (?,?,?,?)",
1523
+ (project_id, user_id, body.project_name or "Default", utcnow()))
1524
+ full, prefix, kh = gen_api_key()
1525
+ key_id = gen_id("key")
1526
+ db_exec("INSERT INTO api_keys (id,project_id,name,prefix,key_hash,key_value,created_at) VALUES (?,?,?,?,?,?,?)",
1527
+ (key_id, project_id, "default", prefix, kh, full, utcnow()))
1528
+ token = create_token(user_id, project_id)
1529
+ return {
1530
+ "user_id": user_id, "project_id": project_id,
1531
+ "access_token": token, "token_type": "bearer",
1532
+ "api_key": full, "api_key_id": key_id,
1533
+ }
1534
+
1535
+
1536
+ @app.post("/auth/login")
1537
+ async def login(body: LoginRequest):
1538
+ email = body.email.lower().strip()
1539
+ user = db_one("SELECT * FROM users WHERE email=?", (email,))
1540
+ if not user or not verify_password(body.password, user["password_hash"]):
1541
+ raise HTTPException(status_code=401, detail="Invalid credentials")
1542
+ project = db_one("SELECT * FROM projects WHERE user_id=? ORDER BY created_at LIMIT 1", (user["id"],))
1543
+ if not project:
1544
+ raise HTTPException(status_code=401, detail="No project found")
1545
+ token = create_token(user["id"], project["id"])
1546
+ return {
1547
+ "user_id": user["id"], "project_id": project["id"], "name": user["name"],
1548
+ "access_token": token, "token_type": "bearer",
1549
+ }
1550
+
1551
+
1552
+ @app.get("/auth/me")
1553
+ async def me(request: Request):
1554
+ user = user_from_request(request)
1555
+ project = project_from_request(request)
1556
+ return {"user": user, "project": project}
1557
+
1558
+
1559
+ # ---- Projects & API keys --------------------------------------------------
1560
+
1561
+ @app.post("/user/project", status_code=201)
1562
+ async def create_project(body: ProjectCreate, request: Request):
1563
+ user = user_from_request(request)
1564
+ pid = gen_id("proj")
1565
+ db_exec("INSERT INTO projects (id,user_id,name,created_at) VALUES (?,?,?,?)",
1566
+ (pid, user["id"], body.name, utcnow()))
1567
+ return db_one("SELECT * FROM projects WHERE id=?", (pid,))
1568
+
1569
+
1570
+ @app.get("/user/project")
1571
+ async def list_projects(request: Request):
1572
+ user = user_from_request(request)
1573
+ return db_query("SELECT * FROM projects WHERE user_id=?", (user["id"],))
1574
+
1575
+
1576
+ @app.post("/user/project/{project_id}/key", status_code=201)
1577
+ async def create_key(project_id: str, body: KeyCreate, request: Request):
1578
+ user = user_from_request(request)
1579
+ proj = db_one("SELECT * FROM projects WHERE id=? AND user_id=?", (project_id, user["id"]))
1580
+ if not proj:
1581
+ raise HTTPException(status_code=404, detail="Project not found")
1582
+ full, prefix, kh = gen_api_key()
1583
+ key_id = gen_id("key")
1584
+ db_exec("INSERT INTO api_keys (id,project_id,name,prefix,key_hash,key_value,created_at) VALUES (?,?,?,?,?,?,?)",
1585
+ (key_id, project_id, body.name, prefix, kh, full, utcnow()))
1586
+ return {"api_key_id": key_id, "api_key": full, "name": body.name, "project_id": project_id}
1587
+
1588
+
1589
+ @app.get("/user/project/{project_id}/key")
1590
+ async def list_keys(project_id: str, request: Request):
1591
+ user = user_from_request(request)
1592
+ rows = db_query("SELECT id,name,prefix,created_at,revoked FROM api_keys WHERE project_id=? AND revoked=0", (project_id,))
1593
+ for r in rows:
1594
+ r["owner"] = db_one("SELECT user_id FROM projects WHERE id=?", (project_id,))["user_id"] == user["id"]
1595
+ return rows
1596
+
1597
+
1598
+ @app.get("/user/project/{project_id}/key/{key_id}")
1599
+ async def get_key(project_id: str, key_id: str, request: Request):
1600
+ """Show (reveal) a specific API key for the current project."""
1601
+ user = user_from_request(request)
1602
+ proj = db_one("SELECT * FROM projects WHERE id=? AND user_id=?", (project_id, user["id"]))
1603
+ if not proj:
1604
+ raise HTTPException(status_code=404, detail="Project not found")
1605
+ row = db_one("SELECT id,name,prefix,key_value,created_at,revoked FROM api_keys WHERE id=? AND project_id=?",
1606
+ (key_id, project_id))
1607
+ if not row:
1608
+ raise HTTPException(status_code=404, detail="API key not found")
1609
+ return {"id": row["id"], "name": row["name"], "prefix": row["prefix"],
1610
+ "api_key": row["key_value"] if row["key_value"] else None, "created_at": row["created_at"],
1611
+ "revoked": row["revoked"]}
1612
+
1613
+
1614
+ @app.delete("/user/project/{project_id}/key/{key_id}")
1615
+ async def revoke_key(project_id: str, key_id: str, request: Request):
1616
+ user = user_from_request(request)
1617
+ proj = db_one("SELECT * FROM projects WHERE id=? AND user_id=?", (project_id, user["id"]))
1618
+ if not proj:
1619
+ raise HTTPException(status_code=404, detail="Project not found")
1620
+ db_exec("UPDATE api_keys SET revoked=1 WHERE id=? AND project_id=?", (key_id, project_id))
1621
+ return {"ok": True}
1622
+
1623
+
1624
+ # ---- Provider credentials (users add their own keys) ----------------------
1625
+
1626
+ @app.post("/user/project/{project_id}/credential", status_code=201)
1627
+ async def add_credential(project_id: str, body: CredentialCreate, request: Request):
1628
+ user = user_from_request(request)
1629
+ proj = db_one("SELECT * FROM projects WHERE id=? AND user_id=?", (project_id, user["id"]))
1630
+ if not proj:
1631
+ raise HTTPException(status_code=404, detail="Project not found")
1632
+ if body.kind not in ("stt", "llm", "tts"):
1633
+ raise HTTPException(status_code=400, detail="kind must be stt|llm|tts")
1634
+ config = {"provider": body.provider, "api_key": body.api_key, "model": body.model,
1635
+ "base_url": body.base_url, "voice": body.voice, "language": body.language}
1636
+ cid = gen_id("cred")
1637
+ db_exec("INSERT INTO provider_credentials (id,project_id,kind,provider,config,created_at) VALUES (?,?,?,?,?,?)",
1638
+ (cid, project_id, body.kind, body.provider, json.dumps(config), utcnow()))
1639
+ return {"id": cid, "kind": body.kind, "provider": body.provider, "config": config}
1640
+
1641
+
1642
+ @app.get("/user/project/{project_id}/credential")
1643
+ async def list_credentials(project_id: str, request: Request):
1644
+ user = user_from_request(request)
1645
+ proj = db_one("SELECT * FROM projects WHERE id=? AND user_id=?", (project_id, user["id"]))
1646
+ if not proj:
1647
+ raise HTTPException(status_code=404, detail="Project not found")
1648
+ rows = db_query("SELECT id,kind,provider,config,created_at FROM provider_credentials WHERE project_id=? ORDER BY created_at", (project_id,))
1649
+ for r in rows:
1650
+ cfg = json.loads(r["config"])
1651
+ if cfg.get("api_key"):
1652
+ cfg["api_key"] = cfg["api_key"][:8] + "..." + cfg["api_key"][-4:] if len(cfg["api_key"]) > 12 else "***"
1653
+ r["config"] = cfg
1654
+ return rows
1655
+
1656
+
1657
+ @app.delete("/user/project/{project_id}/credential/{cred_id}")
1658
+ async def delete_credential(project_id: str, cred_id: str, request: Request):
1659
+ user = user_from_request(request)
1660
+ proj = db_one("SELECT * FROM projects WHERE id=? AND user_id=?", (project_id, user["id"]))
1661
+ if not proj:
1662
+ raise HTTPException(status_code=404, detail="Project not found")
1663
+ db_exec("DELETE FROM provider_credentials WHERE id=? AND project_id=?", (cred_id, project_id))
1664
+ return {"ok": True}
1665
+
1666
+
1667
+ # ---- Agents ---------------------------------------------------------------
1668
+
1669
+ @app.post("/agent", status_code=201)
1670
+ async def create_agent(body: AgentCreate, request: Request):
1671
+ project = project_from_request(request)
1672
+ aid = gen_id("agent")
1673
+ db_exec("INSERT INTO agents (id,project_id,name,system_prompt,voice,tools,config,created_at) VALUES (?,?,?,?,?,?,?,?)",
1674
+ (aid, project["id"], body.name, body.system_prompt, body.voice, json.dumps(body.tools),
1675
+ json.dumps(body.config), utcnow()))
1676
+ return get_agent_row(aid)
1677
+
1678
+
1679
+ def get_agent_row(aid: str) -> dict[str, Any]:
1680
+ row = db_one("SELECT * FROM agents WHERE id=?", (aid,))
1681
+ if not row:
1682
+ raise HTTPException(status_code=404, detail="Agent not found")
1683
+ row["tools"] = json.loads(row["tools"] or "[]")
1684
+ row["config"] = json.loads(row["config"] or "{}")
1685
+ return row
1686
+
1687
+
1688
+ @app.get("/agent")
1689
+ async def list_agents(request: Request):
1690
+ project = project_from_request(request)
1691
+ rows = db_query("SELECT * FROM agents WHERE project_id=?", (project["id"],))
1692
+ for r in rows:
1693
+ r["tools"] = json.loads(r["tools"] or "[]")
1694
+ r["config"] = json.loads(r["config"] or "{}")
1695
+ return rows
1696
+
1697
+
1698
+ @app.get("/agent/{agent_id}")
1699
+ async def get_agent(agent_id: str, request: Request):
1700
+ project = project_from_request(request)
1701
+ row = db_one("SELECT * FROM agents WHERE id=? AND project_id=?", (agent_id, project["id"]))
1702
+ if not row:
1703
+ raise HTTPException(status_code=404, detail="Agent not found")
1704
+ row["tools"] = json.loads(row["tools"] or "[]")
1705
+ row["config"] = json.loads(row["config"] or "{}")
1706
+ return row
1707
+
1708
+
1709
+ @app.patch("/agent/{agent_id}")
1710
+ async def update_agent(agent_id: str, body: AgentCreate, request: Request):
1711
+ project = project_from_request(request)
1712
+ existing = db_one("SELECT * FROM agents WHERE id=? AND project_id=?", (agent_id, project["id"]))
1713
+ if not existing:
1714
+ raise HTTPException(status_code=404, detail="Agent not found")
1715
+ db_exec("UPDATE agents SET name=?, system_prompt=?, voice=?, tools=?, config=? WHERE id=?",
1716
+ (body.name, body.system_prompt, body.voice, json.dumps(body.tools), json.dumps(body.config), agent_id))
1717
+ return get_agent_row(agent_id)
1718
+
1719
+
1720
+ @app.delete("/agent/{agent_id}")
1721
+ async def delete_agent(agent_id: str, request: Request):
1722
+ project = project_from_request(request)
1723
+ db_exec("DELETE FROM agents WHERE id=? AND project_id=?", (agent_id, project["id"]))
1724
+ return {"ok": True}
1725
+
1726
+
1727
+ # ---- Tools ----------------------------------------------------------------
1728
+
1729
+ @app.post("/tool", status_code=201)
1730
+ async def create_tool(body: ToolCreate, request: Request):
1731
+ project = project_from_request(request)
1732
+ tid = gen_id("tool")
1733
+ db_exec("INSERT INTO tools (id,project_id,name,description,parameters,created_at) VALUES (?,?,?,?,?,?)",
1734
+ (tid, project["id"], body.name, body.description, json.dumps(body.parameters), utcnow()))
1735
+ return {"id": tid, **body.model_dump()}
1736
+
1737
+
1738
+ @app.get("/tool")
1739
+ async def list_tools(request: Request):
1740
+ project = project_from_request(request)
1741
+ rows = db_query("SELECT * FROM tools WHERE project_id=?", (project["id"],))
1742
+ for r in rows:
1743
+ r["parameters"] = json.loads(r["parameters"] or "{}")
1744
+ return rows
1745
+
1746
+
1747
+ @app.delete("/tool/{tool_id}")
1748
+ async def delete_tool(tool_id: str, request: Request):
1749
+ project = project_from_request(request)
1750
+ db_exec("DELETE FROM tools WHERE id=? AND project_id=?", (tool_id, project["id"]))
1751
+ return {"ok": True}
1752
+
1753
+
1754
+ # ---- Calls ----------------------------------------------------------------
1755
+
1756
+ @app.post("/call", status_code=201)
1757
+ async def create_call(body: CallCreate, request: Request):
1758
+ project = project_from_request(request)
1759
+ call_id = gen_id("call")
1760
+ db_exec("INSERT INTO calls (id,project_id,agent_id,phone,status,direction,transport,started_at) VALUES (?,?,?,?,?,?,?,?)",
1761
+ (call_id, project["id"], body.agent_id, body.phone, "queued", "inbound", "ws", utcnow()))
1762
+ await dispatch_event(project["id"], call_id, "call.started", {"agent_id": body.agent_id, "phone": body.phone})
1763
+ return db_one("SELECT * FROM calls WHERE id=?", (call_id,))
1764
+
1765
+
1766
+ @app.get("/call")
1767
+ async def list_calls(request: Request):
1768
+ project = project_from_request(request)
1769
+ return db_query("SELECT * FROM calls WHERE project_id=? ORDER BY started_at DESC", (project["id"],))
1770
+
1771
+
1772
+ @app.get("/call/{call_id}")
1773
+ async def get_call(call_id: str, request: Request):
1774
+ project = project_from_request(request)
1775
+ row = db_one("SELECT * FROM calls WHERE id=? AND project_id=?", (call_id, project["id"]))
1776
+ if not row:
1777
+ raise HTTPException(status_code=404, detail="Call not found")
1778
+ return row
1779
+
1780
+
1781
+ @app.get("/call/{call_id}/transcript")
1782
+ async def call_transcript(call_id: str, request: Request):
1783
+ project = project_from_request(request)
1784
+ return db_query("SELECT role,content,is_final,ts FROM transcripts WHERE call_id=? ORDER BY rowid", (call_id,))
1785
+
1786
+
1787
+ # ---- Usage & billing ------------------------------------------------------
1788
+
1789
+ @app.get("/usage")
1790
+ async def usage(request: Request):
1791
+ project = project_from_request(request)
1792
+ rows = db_query("SELECT kind,provider,model,COUNT(*) as count,ROUND(SUM(units),2) as units,ROUND(AVG(latency_ms),1) as avg_ms,ROUND(SUM(cost),6) as cost FROM usage WHERE project_id=? GROUP BY kind,provider,model", (project["id"],))
1793
+ return rows
1794
+
1795
+
1796
+ @app.get("/billing")
1797
+ async def billing(request: Request):
1798
+ project = project_from_request(request)
1799
+ rows = db_query("SELECT kind,ROUND(SUM(cost),6) as cost FROM usage WHERE project_id=? GROUP BY kind", (project["id"],))
1800
+ total = sum(r["cost"] for r in rows)
1801
+ return {"total_estimated_cost": round(total, 6), "breakdown": rows}
1802
+
1803
+
1804
+ # ---- Observability --------------------------------------------------------
1805
+
1806
+ @app.get("/events")
1807
+ async def list_events(request: Request):
1808
+ project = project_from_request(request)
1809
+ rows = db_query("SELECT id,call_id,type,payload,created_at FROM events WHERE project_id=? ORDER BY rowid DESC LIMIT 200", (project["id"],))
1810
+ for r in rows:
1811
+ r["payload"] = json.loads(r["payload"] or "{}")
1812
+ return rows
1813
+
1814
+
1815
+ @app.get("/metrics/latency")
1816
+ async def latency_summary(request: Request):
1817
+ project = project_from_request(request)
1818
+ rows = db_query(
1819
+ "SELECT kind,provider,COUNT(*) as count,ROUND(AVG(latency_ms),1) as avg_ms,ROUND(MIN(latency_ms),1) as min_ms,ROUND(MAX(latency_ms),1) as max_ms "
1820
+ "FROM usage WHERE project_id=? GROUP BY kind,provider", (project["id"],))
1821
+ return rows
1822
+
1823
+
1824
+ @app.get("/recording/{call_id}")
1825
+ async def get_recording(call_id: str, request: Request):
1826
+ project = project_from_request(request)
1827
+ path = os.path.join(RECORDING_DIR, f"{call_id}.wav")
1828
+ if not os.path.exists(path):
1829
+ raise HTTPException(status_code=404, detail="Recording not found")
1830
+ return Response(content=open(path, "rb").read(), media_type="audio/wav")
1831
+
1832
+
1833
+ # ---- OpenAI-compatible bridge ---------------------------------------------
1834
+
1835
+ @app.post("/v1/chat/completions")
1836
+ async def chat_completions(body: ChatRequest, request: Request):
1837
+ project = project_from_request(request)
1838
+ _, cfg = resolve_provider(project["id"], "llm")
1839
+ if body.model:
1840
+ cfg = {**cfg, "model": body.model}
1841
+ if body.stream:
1842
+ from fastapi.responses import StreamingResponse
1843
+
1844
+ async def gen():
1845
+ collected = ""
1846
+ yield "data: " + json.dumps({"id": gen_id("cmpl"), "object": "chat.completion.chunk",
1847
+ "created": int(time.time()), "model": cfg.get("model", ""), "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]}) + "\n\n"
1848
+ async for chunk in llm_stream(body.messages, cfg, body.tools):
1849
+ tok = chunk.get("content", "")
1850
+ if tok:
1851
+ collected += tok
1852
+ yield "data: " + json.dumps({"id": gen_id("cmpl"), "object": "chat.completion.chunk", "created": int(time.time()),
1853
+ "model": cfg.get("model", ""), "choices": [{"index": 0, "delta": {"content": tok}, "finish_reason": None}]}) + "\n\n"
1854
+ yield "data: " + json.dumps({"id": gen_id("cmpl"), "object": "chat.completion.chunk", "created": int(time.time()),
1855
+ "model": cfg.get("model", ""), "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}) + "\n\n"
1856
+ yield "data: [DONE]\n\n"
1857
+
1858
+ return StreamingResponse(gen(), media_type="text/event-stream")
1859
+
1860
+ try:
1861
+ final = await llm_generate(body.messages, cfg, project_id=project["id"])
1862
+ except ProviderError as exc:
1863
+ raise HTTPException(status_code=502, detail=str(exc))
1864
+ return {"id": gen_id("cmpl"), "object": "chat.completion", "created": int(time.time()),
1865
+ "model": cfg.get("model", ""),
1866
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": final}, "finish_reason": "stop"}],
1867
+ "usage": {"prompt_tokens": 0, "completion_tokens": max(len(final) // 4, 1), "total_tokens": max(len(final) // 4, 1)}}
1868
+
1869
+
1870
+ @app.get("/v1/models")
1871
+ async def list_models(request: Request):
1872
+ project = project_from_request(request)
1873
+ _, cfg = resolve_provider(project["id"], "llm")
1874
+ return {"object": "list", "data": [{"id": cfg.get("model", ""), "object": "model", "owned_by": "relay"}]}
1875
+
1876
+
1877
+ # ---- Direct TTS/STT -------------------------------------------------------
1878
+
1879
+ @app.post("/v1/tts")
1880
+ async def tts_speak(body: TTSRequest, request: Request):
1881
+ project = project_from_request(request)
1882
+ provider, cfg = resolve_provider(project["id"], "tts")
1883
+ if body.model:
1884
+ cfg = {**cfg, "model": body.model}
1885
+ if body.voice:
1886
+ cfg = {**cfg, "voice": body.voice}
1887
+ engine = TTSFileEngine(provider, cfg, project_id=project["id"])
1888
+ try:
1889
+ audio = await engine.synthesize(body.text)
1890
+ except ProviderError as exc:
1891
+ raise HTTPException(status_code=502, detail=str(exc))
1892
+ return Response(content=audio, media_type="audio/wav")
1893
+
1894
+
1895
+ @app.post("/v1/stt")
1896
+ async def stt_transcribe(request: Request):
1897
+ project = project_from_request(request)
1898
+ body = await request.body()
1899
+ provider, cfg = resolve_provider(project["id"], "stt")
1900
+ engine = STTFileEngine(provider, cfg, project_id=project["id"])
1901
+ try:
1902
+ text = await engine.transcribe(body)
1903
+ except ProviderError as exc:
1904
+ raise HTTPException(status_code=502, detail=str(exc))
1905
+ return {"text": text}
1906
+
1907
+
1908
+ # ---- Normalized real-time voice WebSocket ---------------------------------
1909
+
1910
+ @app.websocket("/ws/audio/{call_id}")
1911
+ async def websocket_audio(ws: WebSocket, call_id: str):
1912
+ await ws.accept()
1913
+ project = ws_project(ws)
1914
+ if not project:
1915
+ await ws.send_text(json.dumps({"type": "error", "error": "Invalid auth. Pass ?api_key= (API key) or ?token= (JWT)."}))
1916
+ await ws.close()
1917
+ return
1918
+ agent_id = ws.query_params.get("agent_id", "")
1919
+ agent = db_one("SELECT * FROM agents WHERE id=? AND project_id=?", (agent_id, project["id"])) if agent_id else None
1920
+ if not agent:
1921
+ await ws.send_text(json.dumps({"type": "error", "error": "Agent not found. Provide ?agent_id= or create one."}))
1922
+ await ws.close()
1923
+ return
1924
+ agent["tools"] = json.loads(agent["tools"] or "[]")
1925
+ agent["config"] = json.loads(agent["config"] or "{}")
1926
+
1927
+ db_exec("INSERT INTO calls (id,project_id,agent_id,status,direction,transport,started_at) VALUES (?,?,?,?,?,?,?)",
1928
+ (call_id, project["id"], agent["id"], "in-progress", "inbound", "ws", utcnow()))
1929
+ CALL_START[call_id] = time.time()
1930
+ await dispatch_event(project["id"], call_id, "call.connected", {"agent_id": agent["id"]})
1931
+
1932
+ transport = WebSocketTransport(ws)
1933
+ pipeline = VoicePipeline(call_id, project["id"], agent, transport, client_sample_rate=settings.sample_rate)
1934
+ await pipeline.start()
1935
+ await pipeline.run()
1936
+
1937
+
1938
+ def _header_key(ws: WebSocket) -> str:
1939
+ try:
1940
+ return ws.headers.get("authorization", "").replace("Bearer ", "")
1941
+ except Exception:
1942
+ return ""
1943
+
1944
+
1945
+ def ws_project(ws: WebSocket) -> Optional[dict[str, Any]]:
1946
+ """Resolve the authenticated project for a WebSocket connection.
1947
+
1948
+ Accepts either an API key (?api_key= or Authorization: Bearer <api_key>)
1949
+ or a JWT (?token= or Authorization: Bearer <jwt>). Returns the project row.
1950
+ """
1951
+ cred = ws.query_params.get("api_key") or ws.query_params.get("token") or _header_key(ws)
1952
+ if not cred:
1953
+ return None
1954
+ # JWT first (looks like header.payload.signature)
1955
+ if "." in cred and len(cred) < 500:
1956
+ tok = decode_token(cred)
1957
+ if tok and tok.get("pid"):
1958
+ return db_one("SELECT * FROM projects WHERE id=?", (tok["pid"],))
1959
+ # else API key
1960
+ row = verify_api_key(cred)
1961
+ if row:
1962
+ return db_one("SELECT * FROM projects WHERE id=?", (row["project_id"],))
1963
+ return None
1964
+
1965
+
1966
+ # ---- Deepgram streaming proxies (direct STT / TTS over WS) ----------------
1967
+
1968
+ @app.websocket("/v1/stt/stream")
1969
+ async def stt_stream_proxy(ws: WebSocket):
1970
+ await ws.accept()
1971
+ project = ws_project(ws)
1972
+ if not project:
1973
+ await ws.send_text(json.dumps({"type": "error", "error": "Invalid auth. Pass ?api_key= (API key) or ?token= (JWT)."}))
1974
+ await ws.close()
1975
+ return
1976
+ provider, cfg = resolve_provider(project["id"], "stt")
1977
+ if provider != "deepgram":
1978
+ await ws.send_text(json.dumps({"type": "error", "error": "STT provider is not deepgram"}))
1979
+ await ws.close()
1980
+ return
1981
+ sr = int(ws.query_params.get("sample_rate", settings.sample_rate))
1982
+ dg = DeepgramSTTStream(cfg.get("api_key"), cfg.get("model"), cfg.get("language"), sr,
1983
+ interim=settings.deepgram_interim, endpointing=settings.deepgram_endpointing,
1984
+ utterance_end_ms=settings.deepgram_utterance_end_ms)
1985
+ try:
1986
+ await dg.connect()
1987
+ except Exception as exc:
1988
+ await ws.send_text(json.dumps({"type": "error", "error": f"Deepgram connect failed: {exc}"}))
1989
+ await ws.close()
1990
+ return
1991
+ await ws.send_text(json.dumps({"type": "connected", "url": dg.url()}))
1992
+
1993
+ async def relay():
1994
+ try:
1995
+ while True:
1996
+ data = await dg.recv()
1997
+ if data.get("type") != "audio":
1998
+ await ws.send_text(json.dumps(data))
1999
+ except Exception:
2000
+ pass
2001
+
2002
+ task = asyncio.create_task(relay())
2003
+ try:
2004
+ while True:
2005
+ msg = await ws.receive()
2006
+ if msg.get("type") == "websocket.disconnect":
2007
+ break
2008
+ data = msg.get("bytes") or msg.get("text")
2009
+ if isinstance(data, bytes):
2010
+ await dg.send_audio(data)
2011
+ elif isinstance(data, str):
2012
+ obj = json.loads(data)
2013
+ if obj.get("type") in ("keepalive", "ping"):
2014
+ await ws.send_text(json.dumps({"type": "keepalive"}))
2015
+ except WebSocketDisconnect:
2016
+ pass
2017
+ finally:
2018
+ task.cancel()
2019
+ await dg.close()
2020
+ try:
2021
+ await ws.close()
2022
+ except Exception:
2023
+ pass
2024
+
2025
+
2026
+ @app.websocket("/v1/tts/stream")
2027
+ async def tts_stream_proxy(ws: WebSocket):
2028
+ await ws.accept()
2029
+ project = ws_project(ws)
2030
+ if not project:
2031
+ await ws.send_text(json.dumps({"type": "error", "error": "Invalid auth. Pass ?api_key= (API key) or ?token= (JWT)."}))
2032
+ await ws.close()
2033
+ return
2034
+ provider, cfg = resolve_provider(project["id"], "tts")
2035
+ if provider != "deepgram":
2036
+ await ws.send_text(json.dumps({"type": "error", "error": "TTS provider is not deepgram"}))
2037
+ await ws.close()
2038
+ return
2039
+ sr = int(ws.query_params.get("sample_rate", 16000))
2040
+ dg = DeepgramTTSStream(cfg.get("api_key"), cfg.get("model"), sample_rate=sr)
2041
+ try:
2042
+ await dg.connect()
2043
+ except Exception as exc:
2044
+ await ws.send_text(json.dumps({"type": "error", "error": f"Deepgram connect failed: {exc}"}))
2045
+ await ws.close()
2046
+ return
2047
+ await ws.send_text(json.dumps({"type": "connected", "url": dg.url(), "version": dg.version}))
2048
+
2049
+ async def relay():
2050
+ try:
2051
+ while True:
2052
+ data = await dg.recv()
2053
+ if data.get("type") == "audio":
2054
+ await ws.send_bytes(data["data"])
2055
+ else:
2056
+ await ws.send_text(json.dumps(data))
2057
+ except Exception:
2058
+ pass
2059
+
2060
+ task = asyncio.create_task(relay())
2061
+ try:
2062
+ while True:
2063
+ msg = await ws.receive()
2064
+ if msg.get("type") == "websocket.disconnect":
2065
+ break
2066
+ data = msg.get("text") or msg.get("bytes")
2067
+ if isinstance(data, str):
2068
+ try:
2069
+ obj = json.loads(data)
2070
+ except Exception:
2071
+ continue
2072
+ if obj.get("type") == "Speak":
2073
+ await dg.speak(obj.get("text", ""), obj.get("text_id", gen_id("tid")))
2074
+ if dg.version == 1:
2075
+ await dg.flush()
2076
+ elif obj.get("type") == "Flush":
2077
+ await dg.flush()
2078
+ elif obj.get("type") == "Clear":
2079
+ await dg.clear()
2080
+ elif obj.get("type") == "Close":
2081
+ break
2082
+ except WebSocketDisconnect:
2083
+ pass
2084
+ finally:
2085
+ task.cancel()
2086
+ await dg.close()
2087
+ try:
2088
+ await ws.close()
2089
+ except Exception:
2090
+ pass
2091
+
2092
+
2093
+ # ---- Twilio telephony -----------------------------------------------------
2094
+
2095
+ @app.post("/twilio/voice")
2096
+ async def twilio_voice(request: Request):
2097
+ form = await request.form()
2098
+ call_sid = form.get("CallSid") or gen_id("call")
2099
+ agent_id = form.get("agent_id") or request.query_params.get("agent_id", "")
2100
+ api_key = request.query_params.get("api_key") or extract_api_key(request) or ""
2101
+ ws_url = settings.api_base_url.replace("http", "ws", 1).rstrip("/")
2102
+ stream_url = f"{ws_url}/twilio/stream/{call_sid}?api_key={api_key}&agent_id={agent_id}"
2103
+ twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
2104
+ <Response>
2105
+ <Connect>
2106
+ <Stream url="{stream_url}">
2107
+ <Parameter name="agentId" value="{agent_id}"/>
2108
+ </Stream>
2109
+ </Connect>
2110
+ </Response>"""
2111
+ return Response(content=twiml, media_type="application/xml")
2112
+
2113
+
2114
+ @app.websocket("/twilio/stream/{call_sid}")
2115
+ async def twilio_stream(ws: WebSocket, call_sid: str):
2116
+ await ws.accept()
2117
+ project = ws_project(ws)
2118
+ if not project:
2119
+ await ws.close()
2120
+ return
2121
+ agent_id = ws.query_params.get("agent_id", "")
2122
+ agent = db_one("SELECT * FROM agents WHERE id=? AND project_id=?", (agent_id, project["id"])) if agent_id else None
2123
+ if not agent:
2124
+ await ws.close()
2125
+ return
2126
+ agent["tools"] = json.loads(agent["tools"] or "[]")
2127
+ agent["config"] = json.loads(agent["config"] or "{}")
2128
+
2129
+ db_exec("INSERT INTO calls (id,project_id,agent_id,status,direction,transport,started_at) VALUES (?,?,?,?,?,?,?)",
2130
+ (call_sid, project["id"], agent["id"], "in-progress", "inbound", "twilio", utcnow()))
2131
+ CALL_START[call_sid] = time.time()
2132
+ await dispatch_event(project["id"], call_sid, "call.connected", {"agent_id": agent["id"], "transport": "twilio"})
2133
+
2134
+ transport = TwilioTransport(ws)
2135
+ pipeline = VoicePipeline(call_sid, project["id"], agent, transport, client_sample_rate=16000)
2136
+ await pipeline.start()
2137
+ await pipeline.run()
2138
+
2139
+
2140
+ # ---- Tool handler registration (code, not DB) -----------------------------
2141
+
2142
+ @app.post("/tool/{tool_id}/handler")
2143
+ async def attach_handler(tool_id: str, request: Request):
2144
+ project = project_from_request(request)
2145
+ tool = db_one("SELECT * FROM tools WHERE id=? AND project_id=?", (tool_id, project["id"]))
2146
+ if not tool:
2147
+ raise HTTPException(status_code=404, detail="Tool not found")
2148
+ # Registering an inline code handler is not allowed over HTTP (security).
2149
+ return {"ok": True, "note": "Attach code handlers via TOOL_HANDLERS[name] in the engine."}
2150
+
2151
+
2152
+ # ---------------------------------------------------------------------------
2153
+
2154
+ def main():
2155
+ init_db()
2156
+ uvicorn.run(app, host=settings.host, port=settings.port, log_level=settings.log_level.lower())
2157
+
2158
+
2159
+ if __name__ == "__main__":
2160
+ main()
requirements.txt CHANGED
@@ -1,11 +1,17 @@
1
- uvicorn==0.24.0
2
- pillow==10.1.0
3
- numpy==1.24.3
4
- opencv-python==4.8.1.78
5
- torch==2.1.0 --index-url https://download.pytorch.org/whl/cpu
6
- rembg>=2.0.28,<2.0.70
7
- onnxruntime==1.17.0
8
- huggingface_hub==0.24.7
9
- python-multipart>=0.0.18
10
- aiofiles==23.2.1
11
- requests==2.31.0
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]>=0.32.1,<1.0
3
+ pydantic==2.10.4
4
+ pydantic-settings==2.7.0
5
+ httpx==0.28.1
6
+ python-dotenv==1.0.1
7
+ numpy==2.1.3
8
+ websockets>=13.0,<15.0
9
+ python-multipart==0.0.20
10
+
11
+ # VAD for the turn-based fallback pipeline (optional; energy+ZCR fallback is built-in)
12
+ webrtcvad-wheels==2.0.14
13
+
14
+ # Local Whisper STT (optional). Uncomment if you plan to use stt_provider=whisper.
15
+ # openai-whisper==20240930
16
+ # torch==2.5.1
17
+ # torchaudio==2.5.1