sayna-client 0.0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sayna_client/__init__.py +86 -0
- sayna_client/client.py +919 -0
- sayna_client/errors.py +81 -0
- sayna_client/http_client.py +235 -0
- sayna_client/types.py +377 -0
- sayna_client/webhook_receiver.py +345 -0
- sayna_client-0.0.9.dist-info/METADATA +553 -0
- sayna_client-0.0.9.dist-info/RECORD +10 -0
- sayna_client-0.0.9.dist-info/WHEEL +5 -0
- sayna_client-0.0.9.dist-info/top_level.txt +1 -0
sayna_client/errors.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Custom exceptions for the Sayna SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SaynaError(Exception):
|
|
7
|
+
"""Base error class for all Sayna SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str) -> None:
|
|
10
|
+
"""Initialize the error.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
message: Error description
|
|
14
|
+
"""
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SaynaNotConnectedError(SaynaError):
|
|
20
|
+
"""Error raised when attempting to use the client before it's connected."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str = "Not connected to Sayna WebSocket") -> None:
|
|
23
|
+
"""Initialize the error.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
message: Error description
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SaynaNotReadyError(SaynaError):
|
|
32
|
+
"""Error raised when attempting operations before the client is ready."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
message: str = "Sayna voice providers are not ready. Wait for the connection to be established.",
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize the error.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
message: Error description
|
|
42
|
+
"""
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SaynaConnectionError(SaynaError):
|
|
47
|
+
"""Error raised when WebSocket connection fails."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str, cause: Any = None) -> None:
|
|
50
|
+
"""Initialize the error.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
message: Error description
|
|
54
|
+
cause: The underlying exception that caused this error
|
|
55
|
+
"""
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.cause = cause
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SaynaValidationError(SaynaError):
|
|
61
|
+
"""Error raised when invalid parameters are provided."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str) -> None:
|
|
64
|
+
"""Initialize the error.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
message: Error description
|
|
68
|
+
"""
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SaynaServerError(SaynaError):
|
|
73
|
+
"""Error raised when the server returns an error."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str) -> None:
|
|
76
|
+
"""Initialize the error.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
message: Error description
|
|
80
|
+
"""
|
|
81
|
+
super().__init__(message)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Generic HTTP client for Sayna REST API calls."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from sayna_client.errors import SaynaServerError, SaynaValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# HTTP status code constants
|
|
12
|
+
_HTTP_CLIENT_ERROR = 400
|
|
13
|
+
_HTTP_SERVER_ERROR = 500
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SaynaHttpClient:
|
|
17
|
+
"""Generic HTTP client for making REST API requests to Sayna server."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None) -> None:
|
|
20
|
+
"""Initialize the HTTP client.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
base_url: Base URL for the Sayna API (e.g., 'https://api.sayna.com')
|
|
24
|
+
api_key: Optional API key for authentication
|
|
25
|
+
"""
|
|
26
|
+
self.base_url = base_url.rstrip("/")
|
|
27
|
+
self.api_key = api_key
|
|
28
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
29
|
+
|
|
30
|
+
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
31
|
+
"""Ensure an HTTP session exists."""
|
|
32
|
+
if self._session is None or self._session.closed:
|
|
33
|
+
headers = {"Content-Type": "application/json"}
|
|
34
|
+
if self.api_key:
|
|
35
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
36
|
+
|
|
37
|
+
self._session = aiohttp.ClientSession(headers=headers)
|
|
38
|
+
|
|
39
|
+
return self._session
|
|
40
|
+
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
"""Close the HTTP session."""
|
|
43
|
+
if self._session and not self._session.closed:
|
|
44
|
+
await self._session.close()
|
|
45
|
+
self._session = None
|
|
46
|
+
|
|
47
|
+
async def get(
|
|
48
|
+
self,
|
|
49
|
+
endpoint: str,
|
|
50
|
+
params: Optional[dict[str, Any]] = None,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""Make a GET request.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
endpoint: API endpoint path (e.g., '/voices')
|
|
56
|
+
params: Optional query parameters
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
JSON response as a dictionary
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
SaynaServerError: If the server returns an error
|
|
63
|
+
SaynaValidationError: If the request is invalid
|
|
64
|
+
"""
|
|
65
|
+
session = await self._ensure_session()
|
|
66
|
+
url = f"{self.base_url}{endpoint}"
|
|
67
|
+
|
|
68
|
+
async with session.get(url, params=params) as response:
|
|
69
|
+
return await self._handle_response(response)
|
|
70
|
+
|
|
71
|
+
async def post(
|
|
72
|
+
self,
|
|
73
|
+
endpoint: str,
|
|
74
|
+
data: Optional[dict[str, Any]] = None,
|
|
75
|
+
json_data: Optional[dict[str, Any]] = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""Make a POST request.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
endpoint: API endpoint path (e.g., '/speak')
|
|
81
|
+
data: Optional form data
|
|
82
|
+
json_data: Optional JSON payload
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
JSON response as a dictionary
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
SaynaServerError: If the server returns an error
|
|
89
|
+
SaynaValidationError: If the request is invalid
|
|
90
|
+
"""
|
|
91
|
+
session = await self._ensure_session()
|
|
92
|
+
url = f"{self.base_url}{endpoint}"
|
|
93
|
+
|
|
94
|
+
async with session.post(url, data=data, json=json_data) as response:
|
|
95
|
+
return await self._handle_response(response)
|
|
96
|
+
|
|
97
|
+
async def delete(
|
|
98
|
+
self,
|
|
99
|
+
endpoint: str,
|
|
100
|
+
json_data: Optional[dict[str, Any]] = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Make a DELETE request.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
endpoint: API endpoint path (e.g., '/sip/hooks')
|
|
106
|
+
json_data: Optional JSON payload
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
JSON response as a dictionary
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
SaynaServerError: If the server returns an error
|
|
113
|
+
SaynaValidationError: If the request is invalid
|
|
114
|
+
"""
|
|
115
|
+
session = await self._ensure_session()
|
|
116
|
+
url = f"{self.base_url}{endpoint}"
|
|
117
|
+
|
|
118
|
+
async with session.delete(url, json=json_data) as response:
|
|
119
|
+
return await self._handle_response(response)
|
|
120
|
+
|
|
121
|
+
async def get_binary(
|
|
122
|
+
self,
|
|
123
|
+
endpoint: str,
|
|
124
|
+
params: Optional[dict[str, Any]] = None,
|
|
125
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
126
|
+
"""Make a GET request expecting binary response.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
endpoint: API endpoint path (e.g., '/recording/abc123')
|
|
130
|
+
params: Optional query parameters
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Tuple of (binary_data, response_headers)
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
SaynaServerError: If the server returns an error
|
|
137
|
+
SaynaValidationError: If the request is invalid
|
|
138
|
+
"""
|
|
139
|
+
session = await self._ensure_session()
|
|
140
|
+
url = f"{self.base_url}{endpoint}"
|
|
141
|
+
|
|
142
|
+
async with session.get(url, params=params) as response:
|
|
143
|
+
if response.status >= _HTTP_CLIENT_ERROR:
|
|
144
|
+
# Try to parse error message
|
|
145
|
+
try:
|
|
146
|
+
error_data = await response.json()
|
|
147
|
+
error_msg = error_data.get("error", f"HTTP {response.status}")
|
|
148
|
+
except Exception:
|
|
149
|
+
error_msg = f"HTTP {response.status}: {response.reason}"
|
|
150
|
+
|
|
151
|
+
if response.status >= _HTTP_SERVER_ERROR:
|
|
152
|
+
raise SaynaServerError(error_msg)
|
|
153
|
+
raise SaynaValidationError(error_msg)
|
|
154
|
+
|
|
155
|
+
binary_data = await response.read()
|
|
156
|
+
headers = dict(response.headers)
|
|
157
|
+
return binary_data, headers
|
|
158
|
+
|
|
159
|
+
async def post_binary(
|
|
160
|
+
self,
|
|
161
|
+
endpoint: str,
|
|
162
|
+
json_data: Optional[dict[str, Any]] = None,
|
|
163
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
164
|
+
"""Make a POST request expecting binary response.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
endpoint: API endpoint path (e.g., '/speak')
|
|
168
|
+
json_data: Optional JSON payload
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Tuple of (binary_data, response_headers)
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
SaynaServerError: If the server returns an error
|
|
175
|
+
SaynaValidationError: If the request is invalid
|
|
176
|
+
"""
|
|
177
|
+
session = await self._ensure_session()
|
|
178
|
+
url = f"{self.base_url}{endpoint}"
|
|
179
|
+
|
|
180
|
+
async with session.post(url, json=json_data) as response:
|
|
181
|
+
if response.status >= _HTTP_CLIENT_ERROR:
|
|
182
|
+
# Try to parse error message
|
|
183
|
+
try:
|
|
184
|
+
error_data = await response.json()
|
|
185
|
+
error_msg = error_data.get("error", f"HTTP {response.status}")
|
|
186
|
+
except Exception:
|
|
187
|
+
error_msg = f"HTTP {response.status}: {response.reason}"
|
|
188
|
+
|
|
189
|
+
if response.status >= _HTTP_SERVER_ERROR:
|
|
190
|
+
raise SaynaServerError(error_msg)
|
|
191
|
+
raise SaynaValidationError(error_msg)
|
|
192
|
+
|
|
193
|
+
binary_data = await response.read()
|
|
194
|
+
headers = dict(response.headers)
|
|
195
|
+
return binary_data, headers
|
|
196
|
+
|
|
197
|
+
async def _handle_response(self, response: aiohttp.ClientResponse) -> dict[str, Any]:
|
|
198
|
+
"""Handle HTTP response and raise appropriate errors.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
response: aiohttp response object
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Parsed JSON response
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
SaynaServerError: If the server returns a 5xx error
|
|
208
|
+
SaynaValidationError: If the request is invalid (4xx error)
|
|
209
|
+
"""
|
|
210
|
+
if response.status >= _HTTP_CLIENT_ERROR:
|
|
211
|
+
try:
|
|
212
|
+
error_data = await response.json()
|
|
213
|
+
error_msg = error_data.get("error", f"HTTP {response.status}")
|
|
214
|
+
except Exception:
|
|
215
|
+
error_msg = f"HTTP {response.status}: {response.reason}"
|
|
216
|
+
|
|
217
|
+
if response.status >= _HTTP_SERVER_ERROR:
|
|
218
|
+
raise SaynaServerError(error_msg)
|
|
219
|
+
raise SaynaValidationError(error_msg)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
json_response: dict[str, Any] = await response.json()
|
|
223
|
+
return json_response
|
|
224
|
+
except json.JSONDecodeError as e:
|
|
225
|
+
msg = f"Failed to decode JSON response: {e}"
|
|
226
|
+
raise SaynaServerError(msg) from e
|
|
227
|
+
|
|
228
|
+
async def __aenter__(self) -> "SaynaHttpClient":
|
|
229
|
+
"""Async context manager entry."""
|
|
230
|
+
await self._ensure_session()
|
|
231
|
+
return self
|
|
232
|
+
|
|
233
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
234
|
+
"""Async context manager exit."""
|
|
235
|
+
await self.close()
|
sayna_client/types.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Type definitions for the Sayna SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Optional, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, RootModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Pronunciation(BaseModel):
|
|
9
|
+
"""Word pronunciation override for text-to-speech."""
|
|
10
|
+
|
|
11
|
+
word: str = Field(..., description="The word to be pronounced differently")
|
|
12
|
+
pronunciation: str = Field(..., description="Phonetic pronunciation or alternative spelling")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class STTConfig(BaseModel):
|
|
16
|
+
"""Speech-to-Text (STT) configuration options."""
|
|
17
|
+
|
|
18
|
+
provider: str = Field(..., description="The STT provider to use (e.g., 'deepgram', 'google')")
|
|
19
|
+
language: str = Field(..., description="Language code for speech recognition (e.g., 'en-US')")
|
|
20
|
+
sample_rate: int = Field(..., description="Audio sample rate in Hz (e.g., 16000, 44100)")
|
|
21
|
+
channels: int = Field(..., description="Number of audio channels (1 for mono, 2 for stereo)")
|
|
22
|
+
punctuation: bool = Field(..., description="Whether to include punctuation in transcriptions")
|
|
23
|
+
encoding: str = Field(..., description="Audio encoding format (e.g., 'linear16', 'opus')")
|
|
24
|
+
model: str = Field(..., description="STT model identifier to use")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TTSConfig(BaseModel):
|
|
28
|
+
"""Text-to-Speech (TTS) configuration options."""
|
|
29
|
+
|
|
30
|
+
provider: str = Field(..., description="The TTS provider to use (e.g., 'elevenlabs', 'google')")
|
|
31
|
+
voice_id: str = Field(..., description="Voice identifier for the selected provider")
|
|
32
|
+
speaking_rate: float = Field(
|
|
33
|
+
..., description="Speech rate multiplier (e.g., 1.0 for normal, 1.5 for faster)"
|
|
34
|
+
)
|
|
35
|
+
audio_format: str = Field(..., description="Audio format for TTS output (e.g., 'mp3', 'pcm')")
|
|
36
|
+
sample_rate: int = Field(..., description="Audio sample rate in Hz (e.g., 16000, 44100)")
|
|
37
|
+
connection_timeout: int = Field(..., description="Connection timeout in milliseconds")
|
|
38
|
+
request_timeout: int = Field(..., description="Request timeout in milliseconds")
|
|
39
|
+
model: str = Field(..., description="TTS model identifier to use")
|
|
40
|
+
pronunciations: list[Pronunciation] = Field(
|
|
41
|
+
default_factory=list, description="Custom pronunciation overrides"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LiveKitConfig(BaseModel):
|
|
46
|
+
"""LiveKit room configuration for real-time communication."""
|
|
47
|
+
|
|
48
|
+
room_name: str = Field(..., description="LiveKit room name to join")
|
|
49
|
+
enable_recording: Optional[bool] = Field(
|
|
50
|
+
default=False, description="Whether to enable session recording"
|
|
51
|
+
)
|
|
52
|
+
sayna_participant_identity: Optional[str] = Field(
|
|
53
|
+
default="sayna-ai", description="Identity assigned to the agent participant"
|
|
54
|
+
)
|
|
55
|
+
sayna_participant_name: Optional[str] = Field(
|
|
56
|
+
default="Sayna AI", description="Display name for the agent participant"
|
|
57
|
+
)
|
|
58
|
+
listen_participants: Optional[list[str]] = Field(
|
|
59
|
+
default_factory=list,
|
|
60
|
+
description="List of participant identities to monitor (empty = all participants)",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Outgoing Messages (Client -> Server)
|
|
66
|
+
# ============================================================================
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ConfigMessage(BaseModel):
|
|
70
|
+
"""Configuration message sent to initialize the Sayna WebSocket connection."""
|
|
71
|
+
|
|
72
|
+
type: Literal["config"] = "config"
|
|
73
|
+
stream_id: Optional[str] = Field(
|
|
74
|
+
default=None,
|
|
75
|
+
description="Session identifier used for recording paths; server generates a UUID when omitted",
|
|
76
|
+
)
|
|
77
|
+
audio: Optional[bool] = Field(default=True, description="Whether audio streaming is enabled")
|
|
78
|
+
stt_config: Optional[STTConfig] = Field(
|
|
79
|
+
default=None, description="Speech-to-text configuration (required when audio=true)"
|
|
80
|
+
)
|
|
81
|
+
tts_config: Optional[TTSConfig] = Field(
|
|
82
|
+
default=None, description="Text-to-speech configuration (required when audio=true)"
|
|
83
|
+
)
|
|
84
|
+
livekit: Optional[LiveKitConfig] = Field(
|
|
85
|
+
default=None, description="Optional LiveKit room configuration"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SpeakMessage(BaseModel):
|
|
90
|
+
"""Message to request text-to-speech synthesis."""
|
|
91
|
+
|
|
92
|
+
type: Literal["speak"] = "speak"
|
|
93
|
+
text: str = Field(..., description="Text to synthesize")
|
|
94
|
+
flush: Optional[bool] = Field(
|
|
95
|
+
default=None, description="Whether to flush the TTS queue before speaking"
|
|
96
|
+
)
|
|
97
|
+
allow_interruption: Optional[bool] = Field(
|
|
98
|
+
default=None, description="Whether this speech can be interrupted"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ClearMessage(BaseModel):
|
|
103
|
+
"""Message to clear the TTS queue."""
|
|
104
|
+
|
|
105
|
+
type: Literal["clear"] = "clear"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SendMessageMessage(BaseModel):
|
|
109
|
+
"""Message to send data to the Sayna session."""
|
|
110
|
+
|
|
111
|
+
type: Literal["send_message"] = "send_message"
|
|
112
|
+
message: str = Field(..., description="Message content")
|
|
113
|
+
role: str = Field(..., description="Message role (e.g., 'user', 'assistant')")
|
|
114
|
+
topic: Optional[str] = Field(default=None, description="Optional topic identifier")
|
|
115
|
+
debug: Optional[dict[str, Any]] = Field(default=None, description="Optional debug metadata")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ============================================================================
|
|
119
|
+
# Incoming Messages (Server -> Client)
|
|
120
|
+
# ============================================================================
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ReadyMessage(BaseModel):
|
|
124
|
+
"""Message received when the Sayna connection is ready."""
|
|
125
|
+
|
|
126
|
+
type: Literal["ready"] = "ready"
|
|
127
|
+
stream_id: Optional[str] = Field(
|
|
128
|
+
default=None,
|
|
129
|
+
description="Session identifier returned by server; may be auto-generated if not provided in config",
|
|
130
|
+
)
|
|
131
|
+
livekit_room_name: Optional[str] = Field(
|
|
132
|
+
default=None, description="LiveKit room name (present only when LiveKit is enabled)"
|
|
133
|
+
)
|
|
134
|
+
livekit_url: str = Field(..., description="LiveKit WebSocket URL configured on the server")
|
|
135
|
+
sayna_participant_identity: Optional[str] = Field(
|
|
136
|
+
default=None,
|
|
137
|
+
description="Identity assigned to the agent participant when LiveKit is enabled",
|
|
138
|
+
)
|
|
139
|
+
sayna_participant_name: Optional[str] = Field(
|
|
140
|
+
default=None,
|
|
141
|
+
description="Display name assigned to the agent participant when LiveKit is enabled",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class STTResultMessage(BaseModel):
|
|
146
|
+
"""Speech-to-text transcription result."""
|
|
147
|
+
|
|
148
|
+
type: Literal["stt_result"] = "stt_result"
|
|
149
|
+
transcript: str = Field(..., description="Transcribed text")
|
|
150
|
+
is_final: bool = Field(..., description="Whether this is a final transcription")
|
|
151
|
+
is_speech_final: bool = Field(..., description="Whether speech has concluded")
|
|
152
|
+
confidence: float = Field(..., description="Transcription confidence score (0-1)")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ErrorMessage(BaseModel):
|
|
156
|
+
"""Error message from the Sayna server."""
|
|
157
|
+
|
|
158
|
+
type: Literal["error"] = "error"
|
|
159
|
+
message: str = Field(..., description="Error description")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SaynaMessage(BaseModel):
|
|
163
|
+
"""Message data from a Sayna session participant."""
|
|
164
|
+
|
|
165
|
+
message: Optional[str] = Field(default=None, description="Message content")
|
|
166
|
+
data: Optional[str] = Field(default=None, description="Additional data payload")
|
|
167
|
+
identity: str = Field(..., description="Participant identity")
|
|
168
|
+
topic: str = Field(..., description="Message topic")
|
|
169
|
+
room: str = Field(..., description="Room identifier")
|
|
170
|
+
timestamp: int = Field(..., description="Unix timestamp in milliseconds")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class MessageMessage(BaseModel):
|
|
174
|
+
"""Wrapper for participant messages."""
|
|
175
|
+
|
|
176
|
+
type: Literal["message"] = "message"
|
|
177
|
+
message: SaynaMessage = Field(..., description="The message data")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Participant(BaseModel):
|
|
181
|
+
"""Information about a session participant."""
|
|
182
|
+
|
|
183
|
+
identity: str = Field(..., description="Unique participant identity")
|
|
184
|
+
name: Optional[str] = Field(default=None, description="Optional display name")
|
|
185
|
+
room: str = Field(..., description="Room identifier")
|
|
186
|
+
timestamp: int = Field(..., description="Unix timestamp in milliseconds")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class ParticipantDisconnectedMessage(BaseModel):
|
|
190
|
+
"""Message received when a participant disconnects."""
|
|
191
|
+
|
|
192
|
+
type: Literal["participant_disconnected"] = "participant_disconnected"
|
|
193
|
+
participant: Participant = Field(..., description="The disconnected participant")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TTSPlaybackCompleteMessage(BaseModel):
|
|
197
|
+
"""Message received when the TTS playback is complete."""
|
|
198
|
+
|
|
199
|
+
type: Literal["tts_playback_complete"] = "tts_playback_complete"
|
|
200
|
+
timestamp: int = Field(..., description="Unix timestamp in milliseconds")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ============================================================================
|
|
204
|
+
# REST API Types
|
|
205
|
+
# ============================================================================
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class HealthResponse(BaseModel):
|
|
209
|
+
"""Response from GET / health endpoint."""
|
|
210
|
+
|
|
211
|
+
status: str = Field(..., description="Health status (should be 'OK')")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class VoiceDescriptor(BaseModel):
|
|
215
|
+
"""Voice descriptor from a TTS provider."""
|
|
216
|
+
|
|
217
|
+
id: str = Field(..., description="Provider-specific identifier for the voice profile")
|
|
218
|
+
sample: str = Field(default="", description="URL to a preview audio sample")
|
|
219
|
+
name: str = Field(..., description="Human-readable name supplied by the provider")
|
|
220
|
+
accent: str = Field(default="Unknown", description="Detected accent associated with the voice")
|
|
221
|
+
gender: str = Field(
|
|
222
|
+
default="Unknown", description="Inferred gender label from provider metadata"
|
|
223
|
+
)
|
|
224
|
+
language: str = Field(default="Unknown", description="Primary language for synthesis")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class VoicesResponse(RootModel[dict[str, list[VoiceDescriptor]]]):
|
|
228
|
+
"""Response from GET /voices endpoint.
|
|
229
|
+
|
|
230
|
+
Dictionary where keys are provider names and values are lists of voice descriptors.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
root: dict[str, list[VoiceDescriptor]]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class LiveKitTokenRequest(BaseModel):
|
|
237
|
+
"""Request body for POST /livekit/token."""
|
|
238
|
+
|
|
239
|
+
room_name: str = Field(..., description="LiveKit room to join or create")
|
|
240
|
+
participant_name: str = Field(..., description="Display name assigned to the participant")
|
|
241
|
+
participant_identity: str = Field(..., description="Unique identifier for the participant")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class LiveKitTokenResponse(BaseModel):
|
|
245
|
+
"""Response from POST /livekit/token."""
|
|
246
|
+
|
|
247
|
+
token: str = Field(..., description="JWT granting LiveKit permissions")
|
|
248
|
+
room_name: str = Field(..., description="Echo of the requested room")
|
|
249
|
+
participant_identity: str = Field(..., description="Echo of the requested identity")
|
|
250
|
+
livekit_url: str = Field(..., description="WebSocket endpoint for the LiveKit server")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class SpeakRequest(BaseModel):
|
|
254
|
+
"""Request body for POST /speak."""
|
|
255
|
+
|
|
256
|
+
text: str = Field(..., description="Text to convert to speech")
|
|
257
|
+
tts_config: TTSConfig = Field(..., description="Provider configuration without API credentials")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class SipHook(BaseModel):
|
|
261
|
+
"""A SIP webhook hook configuration.
|
|
262
|
+
|
|
263
|
+
Defines a mapping between a SIP domain pattern and a webhook URL
|
|
264
|
+
that will receive forwarded SIP events.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
host: str = Field(
|
|
268
|
+
...,
|
|
269
|
+
description="SIP domain pattern (case-insensitive) to match incoming SIP requests",
|
|
270
|
+
)
|
|
271
|
+
url: str = Field(
|
|
272
|
+
...,
|
|
273
|
+
description="HTTPS URL to forward webhook events to when the host pattern matches",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class SipHooksResponse(BaseModel):
|
|
278
|
+
"""Response from GET /sip/hooks and POST /sip/hooks endpoints.
|
|
279
|
+
|
|
280
|
+
Contains the list of all configured SIP webhook hooks.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
hooks: list[SipHook] = Field(
|
|
284
|
+
default_factory=list,
|
|
285
|
+
description="List of configured SIP hooks",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class SetSipHooksRequest(BaseModel):
|
|
290
|
+
"""Request body for POST /sip/hooks."""
|
|
291
|
+
|
|
292
|
+
hooks: list[SipHook] = Field(
|
|
293
|
+
...,
|
|
294
|
+
description="List of hooks to add or replace. Existing hooks with matching hosts are replaced.",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class DeleteSipHooksRequest(BaseModel):
|
|
299
|
+
"""Request body for DELETE /sip/hooks."""
|
|
300
|
+
|
|
301
|
+
hosts: list[str] = Field(
|
|
302
|
+
...,
|
|
303
|
+
min_length=1,
|
|
304
|
+
description="List of host names to remove (case-insensitive). Must contain at least one host.",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ============================================================================
|
|
309
|
+
# Webhook Types
|
|
310
|
+
# ============================================================================
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class WebhookSIPParticipant(BaseModel):
|
|
314
|
+
"""Participant information from a SIP webhook event."""
|
|
315
|
+
|
|
316
|
+
name: Optional[str] = Field(
|
|
317
|
+
default=None,
|
|
318
|
+
description="Display name of the SIP participant (may be None if not provided)",
|
|
319
|
+
)
|
|
320
|
+
identity: str = Field(..., description="Unique identity assigned to the participant")
|
|
321
|
+
sid: str = Field(..., description="Participant session ID from LiveKit")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class WebhookSIPRoom(BaseModel):
|
|
325
|
+
"""Room information from a SIP webhook event."""
|
|
326
|
+
|
|
327
|
+
name: str = Field(..., description="Name of the LiveKit room")
|
|
328
|
+
sid: str = Field(..., description="Room session ID from LiveKit")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class WebhookSIPOutput(BaseModel):
|
|
332
|
+
"""SIP webhook payload sent from Sayna service.
|
|
333
|
+
|
|
334
|
+
This represents a cryptographically signed webhook event forwarded by Sayna
|
|
335
|
+
when a SIP participant joins a LiveKit room. Use the WebhookReceiver class
|
|
336
|
+
to verify the signature and parse this payload securely.
|
|
337
|
+
|
|
338
|
+
See Also:
|
|
339
|
+
WebhookReceiver: Class for verifying and receiving webhooks
|
|
340
|
+
|
|
341
|
+
Example:
|
|
342
|
+
>>> from sayna_client import WebhookReceiver
|
|
343
|
+
>>> receiver = WebhookReceiver("your-secret-key")
|
|
344
|
+
>>> webhook = receiver.receive(headers, raw_body)
|
|
345
|
+
>>> print(f"From: {webhook.from_phone_number}")
|
|
346
|
+
>>> print(f"To: {webhook.to_phone_number}")
|
|
347
|
+
>>> print(f"Room: {webhook.room.name}")
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
participant: WebhookSIPParticipant = Field(..., description="SIP participant information")
|
|
351
|
+
room: WebhookSIPRoom = Field(..., description="LiveKit room information")
|
|
352
|
+
from_phone_number: str = Field(
|
|
353
|
+
...,
|
|
354
|
+
description="Caller's phone number (E.164 format, e.g., '+15559876543')",
|
|
355
|
+
)
|
|
356
|
+
to_phone_number: str = Field(
|
|
357
|
+
...,
|
|
358
|
+
description="Called phone number (E.164 format, e.g., '+15551234567')",
|
|
359
|
+
)
|
|
360
|
+
room_prefix: str = Field(..., description="Room name prefix configured in Sayna (e.g., 'sip-')")
|
|
361
|
+
sip_host: str = Field(
|
|
362
|
+
..., description="SIP domain extracted from the To header (e.g., 'example.com')"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ============================================================================
|
|
367
|
+
# Union Types
|
|
368
|
+
# ============================================================================
|
|
369
|
+
|
|
370
|
+
OutgoingMessage = Union[
|
|
371
|
+
ReadyMessage,
|
|
372
|
+
STTResultMessage,
|
|
373
|
+
ErrorMessage,
|
|
374
|
+
MessageMessage,
|
|
375
|
+
ParticipantDisconnectedMessage,
|
|
376
|
+
TTSPlaybackCompleteMessage,
|
|
377
|
+
]
|