sayna-client 0.0.1__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.

Potentially problematic release.


This version of sayna-client might be problematic. Click here for more details.

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,169 @@
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
+ class SaynaHttpClient:
12
+ """Generic HTTP client for making REST API requests to Sayna server."""
13
+
14
+ def __init__(self, base_url: str, api_key: Optional[str] = None) -> None:
15
+ """Initialize the HTTP client.
16
+
17
+ Args:
18
+ base_url: Base URL for the Sayna API (e.g., 'https://api.sayna.com')
19
+ api_key: Optional API key for authentication
20
+ """
21
+ self.base_url = base_url.rstrip("/")
22
+ self.api_key = api_key
23
+ self._session: Optional[aiohttp.ClientSession] = None
24
+
25
+ async def _ensure_session(self) -> aiohttp.ClientSession:
26
+ """Ensure an HTTP session exists."""
27
+ if self._session is None or self._session.closed:
28
+ headers = {"Content-Type": "application/json"}
29
+ if self.api_key:
30
+ headers["Authorization"] = f"Bearer {self.api_key}"
31
+
32
+ self._session = aiohttp.ClientSession(headers=headers)
33
+
34
+ return self._session
35
+
36
+ async def close(self) -> None:
37
+ """Close the HTTP session."""
38
+ if self._session and not self._session.closed:
39
+ await self._session.close()
40
+ self._session = None
41
+
42
+ async def get(
43
+ self,
44
+ endpoint: str,
45
+ params: Optional[dict[str, Any]] = None,
46
+ ) -> dict[str, Any]:
47
+ """Make a GET request.
48
+
49
+ Args:
50
+ endpoint: API endpoint path (e.g., '/voices')
51
+ params: Optional query parameters
52
+
53
+ Returns:
54
+ JSON response as a dictionary
55
+
56
+ Raises:
57
+ SaynaServerError: If the server returns an error
58
+ SaynaValidationError: If the request is invalid
59
+ """
60
+ session = await self._ensure_session()
61
+ url = f"{self.base_url}{endpoint}"
62
+
63
+ async with session.get(url, params=params) as response:
64
+ return await self._handle_response(response)
65
+
66
+ async def post(
67
+ self,
68
+ endpoint: str,
69
+ data: Optional[dict[str, Any]] = None,
70
+ json_data: Optional[dict[str, Any]] = None,
71
+ ) -> dict[str, Any]:
72
+ """Make a POST request.
73
+
74
+ Args:
75
+ endpoint: API endpoint path (e.g., '/speak')
76
+ data: Optional form data
77
+ json_data: Optional JSON payload
78
+
79
+ Returns:
80
+ JSON response as a dictionary
81
+
82
+ Raises:
83
+ SaynaServerError: If the server returns an error
84
+ SaynaValidationError: If the request is invalid
85
+ """
86
+ session = await self._ensure_session()
87
+ url = f"{self.base_url}{endpoint}"
88
+
89
+ async with session.post(url, data=data, json=json_data) as response:
90
+ return await self._handle_response(response)
91
+
92
+ async def post_binary(
93
+ self,
94
+ endpoint: str,
95
+ json_data: Optional[dict[str, Any]] = None,
96
+ ) -> tuple[bytes, dict[str, str]]:
97
+ """Make a POST request expecting binary response.
98
+
99
+ Args:
100
+ endpoint: API endpoint path (e.g., '/speak')
101
+ json_data: Optional JSON payload
102
+
103
+ Returns:
104
+ Tuple of (binary_data, response_headers)
105
+
106
+ Raises:
107
+ SaynaServerError: If the server returns an error
108
+ SaynaValidationError: If the request is invalid
109
+ """
110
+ session = await self._ensure_session()
111
+ url = f"{self.base_url}{endpoint}"
112
+
113
+ async with session.post(url, json=json_data) as response:
114
+ if response.status >= 400:
115
+ # Try to parse error message
116
+ try:
117
+ error_data = await response.json()
118
+ error_msg = error_data.get("error", f"HTTP {response.status}")
119
+ except Exception:
120
+ error_msg = f"HTTP {response.status}: {response.reason}"
121
+
122
+ if response.status >= 500:
123
+ raise SaynaServerError(error_msg)
124
+ else:
125
+ raise SaynaValidationError(error_msg)
126
+
127
+ binary_data = await response.read()
128
+ headers = dict(response.headers)
129
+ return binary_data, headers
130
+
131
+ async def _handle_response(self, response: aiohttp.ClientResponse) -> dict[str, Any]:
132
+ """Handle HTTP response and raise appropriate errors.
133
+
134
+ Args:
135
+ response: aiohttp response object
136
+
137
+ Returns:
138
+ Parsed JSON response
139
+
140
+ Raises:
141
+ SaynaServerError: If the server returns a 5xx error
142
+ SaynaValidationError: If the request is invalid (4xx error)
143
+ """
144
+ if response.status >= 400:
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 >= 500:
152
+ raise SaynaServerError(error_msg)
153
+ else:
154
+ raise SaynaValidationError(error_msg)
155
+
156
+ try:
157
+ json_response: dict[str, Any] = await response.json()
158
+ return json_response
159
+ except json.JSONDecodeError as e:
160
+ raise SaynaServerError(f"Failed to decode JSON response: {e}") from e
161
+
162
+ async def __aenter__(self) -> "SaynaHttpClient":
163
+ """Async context manager entry."""
164
+ await self._ensure_session()
165
+ return self
166
+
167
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
168
+ """Async context manager exit."""
169
+ await self.close()
sayna_client/py.typed ADDED
File without changes
sayna_client/types.py ADDED
@@ -0,0 +1,267 @@
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
+ recording_file_key: Optional[str] = Field(
53
+ default=None,
54
+ description="Storage key for the recording file (required when enable_recording is true)",
55
+ )
56
+ sayna_participant_identity: Optional[str] = Field(
57
+ default="sayna-ai", description="Identity assigned to the agent participant"
58
+ )
59
+ sayna_participant_name: Optional[str] = Field(
60
+ default="Sayna AI", description="Display name for the agent participant"
61
+ )
62
+ listen_participants: Optional[list[str]] = Field(
63
+ default_factory=list,
64
+ description="List of participant identities to monitor (empty = all participants)",
65
+ )
66
+
67
+
68
+ # ============================================================================
69
+ # Outgoing Messages (Client -> Server)
70
+ # ============================================================================
71
+
72
+
73
+ class ConfigMessage(BaseModel):
74
+ """Configuration message sent to initialize the Sayna WebSocket connection."""
75
+
76
+ type: Literal["config"] = "config"
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
+ livekit_room_name: Optional[str] = Field(
128
+ default=None, description="LiveKit room name (present only when LiveKit is enabled)"
129
+ )
130
+ livekit_url: str = Field(..., description="LiveKit WebSocket URL configured on the server")
131
+ sayna_participant_identity: Optional[str] = Field(
132
+ default=None,
133
+ description="Identity assigned to the agent participant when LiveKit is enabled",
134
+ )
135
+ sayna_participant_name: Optional[str] = Field(
136
+ default=None,
137
+ description="Display name assigned to the agent participant when LiveKit is enabled",
138
+ )
139
+
140
+
141
+ class STTResultMessage(BaseModel):
142
+ """Speech-to-text transcription result."""
143
+
144
+ type: Literal["stt_result"] = "stt_result"
145
+ transcript: str = Field(..., description="Transcribed text")
146
+ is_final: bool = Field(..., description="Whether this is a final transcription")
147
+ is_speech_final: bool = Field(..., description="Whether speech has concluded")
148
+ confidence: float = Field(..., description="Transcription confidence score (0-1)")
149
+
150
+
151
+ class ErrorMessage(BaseModel):
152
+ """Error message from the Sayna server."""
153
+
154
+ type: Literal["error"] = "error"
155
+ message: str = Field(..., description="Error description")
156
+
157
+
158
+ class SaynaMessage(BaseModel):
159
+ """Message data from a Sayna session participant."""
160
+
161
+ message: Optional[str] = Field(default=None, description="Message content")
162
+ data: Optional[str] = Field(default=None, description="Additional data payload")
163
+ identity: str = Field(..., description="Participant identity")
164
+ topic: str = Field(..., description="Message topic")
165
+ room: str = Field(..., description="Room identifier")
166
+ timestamp: int = Field(..., description="Unix timestamp in milliseconds")
167
+
168
+
169
+ class MessageMessage(BaseModel):
170
+ """Wrapper for participant messages."""
171
+
172
+ type: Literal["message"] = "message"
173
+ message: SaynaMessage = Field(..., description="The message data")
174
+
175
+
176
+ class Participant(BaseModel):
177
+ """Information about a session participant."""
178
+
179
+ identity: str = Field(..., description="Unique participant identity")
180
+ name: Optional[str] = Field(default=None, description="Optional display name")
181
+ room: str = Field(..., description="Room identifier")
182
+ timestamp: int = Field(..., description="Unix timestamp in milliseconds")
183
+
184
+
185
+ class ParticipantDisconnectedMessage(BaseModel):
186
+ """Message received when a participant disconnects."""
187
+
188
+ type: Literal["participant_disconnected"] = "participant_disconnected"
189
+ participant: Participant = Field(..., description="The disconnected participant")
190
+
191
+
192
+ class TTSPlaybackCompleteMessage(BaseModel):
193
+ """Message received when the TTS playback is complete."""
194
+
195
+ type: Literal["tts_playback_complete"] = "tts_playback_complete"
196
+ timestamp: int = Field(..., description="Unix timestamp in milliseconds")
197
+
198
+
199
+ # ============================================================================
200
+ # REST API Types
201
+ # ============================================================================
202
+
203
+
204
+ class HealthResponse(BaseModel):
205
+ """Response from GET / health endpoint."""
206
+
207
+ status: str = Field(..., description="Health status (should be 'OK')")
208
+
209
+
210
+ class VoiceDescriptor(BaseModel):
211
+ """Voice descriptor from a TTS provider."""
212
+
213
+ id: str = Field(..., description="Provider-specific identifier for the voice profile")
214
+ sample: str = Field(default="", description="URL to a preview audio sample")
215
+ name: str = Field(..., description="Human-readable name supplied by the provider")
216
+ accent: str = Field(default="Unknown", description="Detected accent associated with the voice")
217
+ gender: str = Field(
218
+ default="Unknown", description="Inferred gender label from provider metadata"
219
+ )
220
+ language: str = Field(default="Unknown", description="Primary language for synthesis")
221
+
222
+
223
+ class VoicesResponse(RootModel[dict[str, list[VoiceDescriptor]]]):
224
+ """Response from GET /voices endpoint.
225
+
226
+ Dictionary where keys are provider names and values are lists of voice descriptors.
227
+ """
228
+
229
+ root: dict[str, list[VoiceDescriptor]]
230
+
231
+
232
+ class LiveKitTokenRequest(BaseModel):
233
+ """Request body for POST /livekit/token."""
234
+
235
+ room_name: str = Field(..., description="LiveKit room to join or create")
236
+ participant_name: str = Field(..., description="Display name assigned to the participant")
237
+ participant_identity: str = Field(..., description="Unique identifier for the participant")
238
+
239
+
240
+ class LiveKitTokenResponse(BaseModel):
241
+ """Response from POST /livekit/token."""
242
+
243
+ token: str = Field(..., description="JWT granting LiveKit permissions")
244
+ room_name: str = Field(..., description="Echo of the requested room")
245
+ participant_identity: str = Field(..., description="Echo of the requested identity")
246
+ livekit_url: str = Field(..., description="WebSocket endpoint for the LiveKit server")
247
+
248
+
249
+ class SpeakRequest(BaseModel):
250
+ """Request body for POST /speak."""
251
+
252
+ text: str = Field(..., description="Text to convert to speech")
253
+ tts_config: TTSConfig = Field(..., description="Provider configuration without API credentials")
254
+
255
+
256
+ # ============================================================================
257
+ # Union Types
258
+ # ============================================================================
259
+
260
+ OutgoingMessage = Union[
261
+ ReadyMessage,
262
+ STTResultMessage,
263
+ ErrorMessage,
264
+ MessageMessage,
265
+ ParticipantDisconnectedMessage,
266
+ TTSPlaybackCompleteMessage,
267
+ ]