intellema-vdk 0.2.0__py3-none-any.whl → 0.2.2__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.
Files changed (37) hide show
  1. intellema_vdk/__init__.py +67 -10
  2. intellema_vdk/config.py +14 -0
  3. intellema_vdk/providers/__init__.py +35 -0
  4. intellema_vdk/providers/livekit/__init__.py +19 -0
  5. intellema_vdk/providers/livekit/client.py +612 -0
  6. intellema_vdk/providers/livekit/exceptions.py +23 -0
  7. intellema_vdk/providers/protocols.py +33 -0
  8. intellema_vdk/providers/retell/__init__.py +17 -0
  9. intellema_vdk/providers/retell/client.py +468 -0
  10. intellema_vdk/providers/retell/exceptions.py +19 -0
  11. intellema_vdk/{retell_lib → providers/retell}/import_phone_number.py +1 -1
  12. intellema_vdk/stt/__init__.py +17 -0
  13. intellema_vdk/stt/client.py +482 -0
  14. intellema_vdk/stt/exceptions.py +19 -0
  15. intellema_vdk/tts/__init__.py +15 -0
  16. intellema_vdk/tts/__pycache__/__init__.cpython-312.pyc +0 -0
  17. intellema_vdk/tts/__pycache__/client.cpython-312.pyc +0 -0
  18. intellema_vdk/tts/__pycache__/exceptions.cpython-312.pyc +0 -0
  19. intellema_vdk/tts/__pycache__/providers.cpython-312.pyc +0 -0
  20. intellema_vdk/tts/client.py +541 -0
  21. intellema_vdk/tts/exceptions.py +15 -0
  22. intellema_vdk/tts/providers.py +293 -0
  23. intellema_vdk/utils/logger_config.py +41 -0
  24. intellema_vdk-0.2.2.dist-info/METADATA +311 -0
  25. intellema_vdk-0.2.2.dist-info/RECORD +29 -0
  26. {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/WHEEL +1 -1
  27. intellema_vdk/livekit_lib/__init__.py +0 -3
  28. intellema_vdk/livekit_lib/client.py +0 -280
  29. intellema_vdk/retell_lib/retell_client.py +0 -248
  30. intellema_vdk/speech_lib/__init__.py +0 -2
  31. intellema_vdk/speech_lib/stt_client.py +0 -108
  32. intellema_vdk/speech_lib/tts_streamer.py +0 -188
  33. intellema_vdk-0.2.0.dist-info/METADATA +0 -221
  34. intellema_vdk-0.2.0.dist-info/RECORD +0 -14
  35. /intellema_vdk/{retell_lib/__init__.py → stt/providers.py} +0 -0
  36. {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/licenses/LICENSE +0 -0
  37. {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,468 @@
1
+ import os # Used for operating system dependent functionality, like file paths.
2
+ import logging # Used for logging events.
3
+ import time # Used for time-related tasks.
4
+ import uuid # Used for generating unique identifiers.
5
+ import subprocess # Used for installing packages
6
+ import sys # Used for sys operations
7
+ from typing import List, Optional, Any, Dict, TYPE_CHECKING # Used for type hinting.
8
+
9
+ # Lazy imports - only load when RetellManager is instantiated
10
+ if TYPE_CHECKING:
11
+ from twilio.rest import Client as TwilioClient
12
+ from retell import Retell, APIError
13
+ import boto3
14
+ else:
15
+ TwilioClient = None
16
+ Retell = None
17
+ APIError = None
18
+ boto3 = None
19
+
20
+ # Import requests - should be available as a core dependency
21
+ import requests # Used for making HTTP requests.
22
+
23
+ from ...config import get_env # Used to get environment variables.
24
+ from ..protocols import VoiceProvider # Protocol for voice providers.
25
+ from .exceptions import (
26
+ RetellConfigurationError,
27
+ RetellPhoneNumberError,
28
+ RetellCallError,
29
+ RetellAPIError,
30
+ RetellError
31
+ )
32
+
33
+ # Setup logger for this module.
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class RetellManager(VoiceProvider):
38
+ """Manages voice calls using the Retell and Twilio APIs.
39
+
40
+ This class provides a high-level interface for making outbound calls,
41
+ managing phone numbers with Retell, and handling call recordings. It uses
42
+ Twilio for the underlying telephony infrastructure.
43
+
44
+ Attributes:
45
+ twilio_account_sid (str): The Twilio account SID.
46
+ twilio_auth_token (str): The Twilio auth token.
47
+ twilio_number (str): The Twilio phone number to use for calls.
48
+ retell_api_key (str): The Retell API key.
49
+ retell_agent_id (str): The ID of the Retell agent to use.
50
+ twilio_client (TwilioClient): The Twilio API client.
51
+ retell_client (Retell): The Retell API client.
52
+
53
+ Example:
54
+ >>> async def main():
55
+ ... retell_manager = VoiceClient("retell")
56
+ ... # Import a phone number (run once per number)
57
+ ... # retell_manager.import_phone_number()
58
+ ... call_id = await retell_manager.start_outbound_call(
59
+ ... phone_number="+15551234567",
60
+ ... prompt_content="Hello from Retell!"
61
+ ... )
62
+ ... print(f"Call started with ID: {call_id}")
63
+ ... # Wait for the call to connect and then end it.
64
+ ... await asyncio.sleep(30)
65
+ ... await retell_manager.delete_room(call_id)
66
+ ...
67
+ >>> if __name__ == "__main__":
68
+ ... import asyncio
69
+ ... asyncio.run(main())
70
+ """
71
+
72
+ def __init__(self) -> None:
73
+ """Initializes the RetellManager.
74
+
75
+ Retrieves configuration from environment variables and initializes
76
+ the Twilio and Retell API clients.
77
+
78
+ Raises:
79
+ RetellConfigurationError: If required environment variables are missing.
80
+ """
81
+ # Lazy import dependencies - only install when actually used
82
+ global TwilioClient, Retell, APIError, boto3
83
+
84
+ if TwilioClient is None:
85
+ try:
86
+ from twilio.rest import Client as _TwilioClient
87
+ TwilioClient = _TwilioClient
88
+ except ImportError:
89
+ print("Twilio SDK is not installed. Installing now...")
90
+ print("Run: pip install intellema-vdk[retell]")
91
+ try:
92
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "twilio>=8.0.0"])
93
+ from twilio.rest import Client as _TwilioClient
94
+ TwilioClient = _TwilioClient
95
+ print("✓ Twilio SDK installed successfully!")
96
+ except Exception as e:
97
+ raise RetellConfigurationError(
98
+ "Failed to install twilio. Please install manually:\n"
99
+ " pip install intellema-vdk[retell]\n"
100
+ "or:\n"
101
+ " pip install twilio>=8.0.0"
102
+ ) from e
103
+
104
+ if Retell is None:
105
+ try:
106
+ from retell import Retell as _Retell, APIError as _APIError
107
+ Retell = _Retell
108
+ APIError = _APIError
109
+ except ImportError:
110
+ print("Retell SDK is not installed. Installing now...")
111
+ try:
112
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "retell-sdk>=2.0.0"])
113
+ from retell import Retell as _Retell, APIError as _APIError
114
+ Retell = _Retell
115
+ APIError = _APIError
116
+ print("✓ Retell SDK installed successfully!")
117
+ except Exception as e:
118
+ raise RetellConfigurationError(
119
+ "Failed to install retell-sdk. Please install manually:\n"
120
+ " pip install intellema-vdk[retell]\n"
121
+ "or:\n"
122
+ " pip install retell-sdk>=2.0.0"
123
+ ) from e
124
+
125
+ if boto3 is None:
126
+ try:
127
+ import boto3 as _boto3
128
+ boto3 = _boto3
129
+ except ImportError:
130
+ print("boto3 (AWS SDK) is not installed. Installing now...")
131
+ print("This is required for AWS services like Polly TTS.")
132
+ try:
133
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "boto3>=1.28.0"])
134
+ import boto3 as _boto3
135
+ boto3 = _boto3
136
+ print("✓ boto3 installed successfully!")
137
+ except Exception as e:
138
+ raise RetellConfigurationError(
139
+ "Failed to install boto3. Please install manually:\n"
140
+ " pip install boto3>=1.28.0"
141
+ ) from e
142
+
143
+ self.twilio_account_sid = get_env("TWILIO_ACCOUNT_SID")
144
+ self.twilio_auth_token = get_env("TWILIO_AUTH_TOKEN")
145
+ self.twilio_number = get_env("TWILIO_PHONE_NUMBER")
146
+ self.retell_api_key = get_env("RETELL_API_KEY")
147
+ self.retell_agent_id = get_env("RETELL_AGENT_ID")
148
+
149
+ if not all([self.twilio_account_sid, self.twilio_auth_token, self.twilio_number, self.retell_api_key, self.retell_agent_id]):
150
+ raise RetellConfigurationError(
151
+ "Missing necessary environment variables for RetellManager")
152
+
153
+ self.twilio_client = TwilioClient(
154
+ self.twilio_account_sid, self.twilio_auth_token)
155
+ self.retell_client = Retell(api_key=self.retell_api_key)
156
+
157
+ def import_phone_number(self, termination_uri: Optional[str] = None, outbound_agent_id: Optional[str] = None, inbound_agent_id: Optional[str] = None, nickname: Optional[str] = None, sip_trunk_auth_username: Optional[str] = None, sip_trunk_auth_password: Optional[str] = None) -> Any:
158
+ """Imports and registers a Twilio phone number with Retell.
159
+
160
+ This is a necessary step before making outbound calls with the number.
161
+
162
+ Args:
163
+ termination_uri (Optional[str]): The Twilio SIP trunk termination URI
164
+ (e.g., "yourtrunk.pstn.twilio.com").
165
+ outbound_agent_id (Optional[str]): The agent ID for outbound calls.
166
+ Defaults to `self.retell_agent_id`.
167
+ inbound_agent_id (Optional[str]): The agent ID for inbound calls.
168
+ nickname (Optional[str]): A nickname for the phone number.
169
+ sip_trunk_auth_username (Optional[str]): Username for SIP trunk
170
+ authentication.
171
+ sip_trunk_auth_password (Optional[str]): Password for SIP trunk
172
+ authentication.
173
+
174
+ Returns:
175
+ Any: The phone number registration response from Retell.
176
+
177
+ Raises:
178
+ RetellAPIError: If the Retell API returns an error.
179
+ RetellPhoneNumberError: For other unexpected errors.
180
+ """
181
+ import_kwargs: Dict[str, Any] = {
182
+ "phone_number": self.twilio_number,
183
+ }
184
+
185
+ if termination_uri:
186
+ import_kwargs["termination_uri"] = termination_uri
187
+ if sip_trunk_auth_username and sip_trunk_auth_password:
188
+ import_kwargs["sip_trunk_auth_username"] = sip_trunk_auth_username
189
+ import_kwargs["sip_trunk_auth_password"] = sip_trunk_auth_password
190
+ if outbound_agent_id:
191
+ import_kwargs["outbound_agent_id"] = outbound_agent_id
192
+ elif self.retell_agent_id:
193
+ import_kwargs["outbound_agent_id"] = self.retell_agent_id
194
+ if inbound_agent_id:
195
+ import_kwargs["inbound_agent_id"] = inbound_agent_id
196
+ if nickname:
197
+ import_kwargs["nickname"] = nickname
198
+
199
+ try:
200
+ response = self.retell_client.phone_number.import_(**import_kwargs)
201
+ logger.info(
202
+ f"✓ Phone number {self.twilio_number} successfully imported to Retell!")
203
+ logger.info(f" Phone Number: {response.phone_number}")
204
+ logger.info(f" Type: {response.phone_number_type}")
205
+ if hasattr(response, 'outbound_agent_id') and response.outbound_agent_id:
206
+ logger.info(f" Outbound Agent: {response.outbound_agent_id}")
207
+ if hasattr(response, 'inbound_agent_id') and response.inbound_agent_id:
208
+ logger.info(f" Inbound Agent: {response.inbound_agent_id}")
209
+ return response
210
+ except APIError as e:
211
+ logger.error(
212
+ f"Retell API error importing phone number: {e}", exc_info=True)
213
+ raise RetellAPIError(
214
+ f"Retell API error importing phone number: {e}") from e
215
+ except Exception as e:
216
+ logger.error(
217
+ f"An unexpected error occurred while importing phone number: {e}", exc_info=True)
218
+ raise RetellPhoneNumberError(
219
+ f"An unexpected error occurred while importing phone number: {e}") from e
220
+
221
+ async def start_outbound_call(self, phone_number: str, prompt_content: Optional[str] = None, call_id: Optional[str] = None) -> str:
222
+ """Initiates an outbound call via Retell AI.
223
+
224
+ Args:
225
+ phone_number (str): The destination phone number.
226
+ prompt_content (Optional[str]): A prompt to override the agent's default.
227
+ call_id (Optional[str]): A metadata ID for the call.
228
+
229
+ Returns:
230
+ str: The Retell call ID.
231
+
232
+ Raises:
233
+ RetellCallError: If the call fails to start.
234
+ """
235
+ call_kwargs: Dict[str, Any] = {
236
+ "from_number": self.twilio_number,
237
+ "to_number": phone_number,
238
+ "override_agent_id": self.retell_agent_id,
239
+ }
240
+
241
+ if call_id:
242
+ call_kwargs["metadata"] = {"call_id": call_id}
243
+
244
+ # Set up dynamic variables for custom prompts.
245
+ if prompt_content:
246
+ enhanced_prompt = (
247
+ f"{prompt_content}\n\n"
248
+ "SYSTEM INSTRUCTION: Continually check the dynamic variable {{force_end}}. "
249
+ "If {{force_end}} == 'true', you must IMMEDIATELY say goodbye and "
250
+ "activate your end_call tool to terminate the call."
251
+ )
252
+ call_kwargs["retell_llm_dynamic_variables"] = {
253
+ "prompt_content": enhanced_prompt,
254
+ "force_end": "false"
255
+ }
256
+
257
+ try:
258
+ call_response = self.retell_client.call.create_phone_call(
259
+ **call_kwargs)
260
+ except Exception as e:
261
+ raise RetellCallError(
262
+ f"Failed to start outbound call: {e}") from e
263
+
264
+ logger.info("Call created successfully!")
265
+ logger.info(f"Retell Call ID: {call_response.call_id}")
266
+ logger.info(f"Call Status: {call_response.call_status}")
267
+
268
+ return call_response.call_id
269
+
270
+ async def delete_room(self, call_id: str) -> None:
271
+ """Ends the call by updating a dynamic variable.
272
+
273
+ This method signals the agent to end the call by setting the `force_end`
274
+ dynamic variable to "true".
275
+
276
+ Args:
277
+ call_id (str): The Retell call ID.
278
+
279
+ Raises:
280
+ RetellCallError: If the call cannot be ended.
281
+ """
282
+ try:
283
+ call_data = self.retell_client.call.retrieve(call_id)
284
+ logger.info(f"Current call status: {call_data.call_status}")
285
+
286
+ if call_data.call_status in ['registered', 'ongoing', 'dialing']:
287
+ logger.info(f"Triggering end for Retell call {call_id}...")
288
+ self.retell_client.call.update(
289
+ call_id,
290
+ override_dynamic_variables={"force_end": "true"}
291
+ )
292
+ logger.info("✓ force_end override sent to Retell API")
293
+ else:
294
+ logger.info(f"Call already ended: {call_data.call_status}")
295
+
296
+ except Exception as e:
297
+ logger.error(f"Error ending call {call_id}: {e}")
298
+ raise RetellCallError(f"Error ending call {call_id}: {e}") from e
299
+
300
+ async def start_stream(self, call_id: str, rtmp_urls: List[str]) -> None:
301
+ """Not supported for Retell phone calls.
302
+
303
+ Retell deprecated their Audio WebSocket API at the end of 2024.
304
+ For phone calls, audio is managed internally by Retell and cannot
305
+ be accessed externally for streaming.
306
+
307
+ Use `start_recording()` to retrieve recordings after the call ends.
308
+
309
+ Args:
310
+ call_id (str): The Retell Call ID.
311
+ rtmp_urls (List[str]): Not used.
312
+
313
+ Raises:
314
+ NotImplementedError: Always raised as streaming is not supported.
315
+ """
316
+ raise NotImplementedError(
317
+ "Real-time audio streaming is not supported for Retell phone calls. "
318
+ "Retell deprecated their Audio WebSocket API. "
319
+ "Use start_recording() to retrieve recordings after the call ends."
320
+ )
321
+
322
+
323
+ async def start_recording(self, call_id: str, output_filepath: Optional[str] = None, upload_to_s3: bool = True, wait_for_completion: bool = True) -> str:
324
+ """Retrieves the recording from a Retell call.
325
+
326
+ Retell automatically records calls. This method polls for the recording
327
+ URL to become available, then downloads and optionally uploads to S3.
328
+
329
+ Args:
330
+ call_id (str): The Retell Call ID.
331
+ output_filepath (Optional[str]): A filename for the recording.
332
+ upload_to_s3 (bool): If True, uploads the recording to S3.
333
+ wait_for_completion (bool): If True, waits for the recording to
334
+ be available before proceeding.
335
+
336
+ Returns:
337
+ str: The recording URL from Retell.
338
+
339
+ Raises:
340
+ RetellError: If the recording fails or cannot be downloaded.
341
+ RetellConfigurationError: If S3 upload is requested but not configured.
342
+ """
343
+ logger.info(f"Retrieving recording for call: {call_id}")
344
+
345
+ # Poll for call to end and recording to be available.
346
+ recording_url = None
347
+ max_attempts = 60 # 5 minutes max wait (5s intervals)
348
+ attempts = 0
349
+
350
+ if wait_for_completion:
351
+ logger.info("Waiting for call to end and recording to be available...")
352
+ while attempts < max_attempts:
353
+ try:
354
+ call_data = self.retell_client.call.retrieve(call_id)
355
+
356
+ if call_data.call_status == 'ended':
357
+ if hasattr(call_data, 'recording_url') and call_data.recording_url:
358
+ recording_url = call_data.recording_url
359
+ logger.info("Recording is available.")
360
+ break
361
+ else:
362
+ logger.info("Call ended but recording not yet available...")
363
+ elif call_data.call_status == 'error':
364
+ raise RetellError(f"Call failed with error status")
365
+ else:
366
+ logger.info(f"Call status: {call_data.call_status}, waiting...")
367
+
368
+ except Exception as e:
369
+ logger.warning(f"Error checking call status: {e}")
370
+
371
+ time.sleep(5)
372
+ attempts += 1
373
+
374
+ if not recording_url:
375
+ raise RetellError(
376
+ f"Recording not available after {max_attempts * 5} seconds. "
377
+ "Ensure the call has ended and recording is enabled for the agent."
378
+ )
379
+ else:
380
+ # Just try to get the recording URL once
381
+ call_data = self.retell_client.call.retrieve(call_id)
382
+ if hasattr(call_data, 'recording_url') and call_data.recording_url:
383
+ recording_url = call_data.recording_url
384
+ else:
385
+ return call_id # Return call_id, recording not yet available
386
+
387
+ logger.info(f"Recording URL: {recording_url}")
388
+
389
+ if not upload_to_s3:
390
+ return recording_url
391
+
392
+ # Download recording from Retell.
393
+ logger.info(f"Downloading recording from: {recording_url}")
394
+ response = requests.get(recording_url)
395
+ if response.status_code != 200:
396
+ raise RetellError(
397
+ f"Failed to download recording: {response.status_code} {response.text}")
398
+
399
+ # Upload to S3.
400
+ access_key = get_env("AWS_ACCESS_KEY_ID")
401
+ secret_key = get_env("AWS_SECRET_ACCESS_KEY")
402
+ bucket = get_env("AWS_S3_BUCKET")
403
+ region = get_env("AWS_REGION")
404
+
405
+ if not access_key or not secret_key or not bucket:
406
+ raise RetellConfigurationError(
407
+ "AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, "
408
+ "AWS_S3_BUCKET) are required for S3 upload."
409
+ )
410
+
411
+ # Determine file extension from URL or default to .wav (Retell uses .wav)
412
+ filename = output_filepath if output_filepath else f"{call_id}-{uuid.uuid4().hex[:6]}.wav"
413
+
414
+ s3 = boto3.client(
415
+ 's3',
416
+ aws_access_key_id=access_key,
417
+ aws_secret_access_key=secret_key,
418
+ region_name=region
419
+ )
420
+
421
+ logger.info(f"Uploading to S3: s3://{bucket}/{filename}")
422
+ s3.put_object(Bucket=bucket, Key=filename, Body=response.content)
423
+ logger.info(f"Upload complete: s3://{bucket}/{filename}")
424
+
425
+ # Save locally as well.
426
+ local_dir = "recordings"
427
+ os.makedirs(local_dir, exist_ok=True)
428
+ local_path = os.path.join(local_dir, filename)
429
+ with open(local_path, 'wb') as f:
430
+ f.write(response.content)
431
+ logger.info(f"Recording saved locally: {local_path}")
432
+
433
+ return recording_url
434
+
435
+ async def mute_participant(self, call_id: str, identity: str, track_sid: str, muted: bool) -> None:
436
+ """Mutes the participant on the Twilio call.
437
+
438
+ Note: Twilio's REST API does not support muting 1:1 calls directly.
439
+ This method will raise NotImplementedError.
440
+
441
+ Args:
442
+ call_id (str): The Retell call ID.
443
+ identity (str): Unused.
444
+ track_sid (str): Unused.
445
+ muted (bool): True to mute, False to unmute.
446
+ """
447
+ logger.warning("Twilio REST API does not support muting 1:1 calls directly.")
448
+ raise NotImplementedError("Twilio REST API does not support muting 1:1 calls directly.")
449
+
450
+ async def kick_participant(self, call_id: str, identity: str) -> None:
451
+ """Ends the call.
452
+
453
+ This is an alias for `delete_room`.
454
+
455
+ Args:
456
+ call_id (str): The call ID.
457
+ identity (str): Unused in this context.
458
+ """
459
+ await self.delete_room(call_id)
460
+
461
+ async def send_alert(self, call_id: str, message: str, participant_identity: Optional[str] = None) -> None:
462
+ """Not supported by this provider.
463
+
464
+ Raises:
465
+ NotImplementedError: This method is not implemented.
466
+ """
467
+ raise NotImplementedError(
468
+ "send_alert is not currently supported in RetellManager")
@@ -0,0 +1,19 @@
1
+ class RetellError(Exception):
2
+ """Base exception for all Retell-related errors."""
3
+ pass
4
+
5
+ class RetellConfigurationError(RetellError):
6
+ """Raised when configuration (API keys, secrets) is missing or invalid."""
7
+ pass
8
+
9
+ class RetellAPIError(RetellError):
10
+ """Raised when the Retell API returns an error response."""
11
+ pass
12
+
13
+ class RetellPhoneNumberError(RetellError):
14
+ """Raised when there are issues importing or managing phone numbers."""
15
+ pass
16
+
17
+ class RetellCallError(RetellError):
18
+ """Raised when starting, updating, or ending a call fails."""
19
+ pass
@@ -4,7 +4,7 @@ import sys
4
4
  # Add the project root to the python path so we can import intellema_vdk
5
5
  sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
6
6
 
7
- from intellema_vdk.retell_lib.retell_client import RetellManager
7
+ from intellema_vdk import RetellManager
8
8
 
9
9
  def import_twilio_number():
10
10
  """
@@ -0,0 +1,17 @@
1
+ from .client import STTManager
2
+ from .exceptions import (
3
+ STTError,
4
+ STTConfigurationError,
5
+ STTFileError,
6
+ STTTranscriptionError,
7
+ STTAgentError,
8
+ )
9
+
10
+ __all__ = [
11
+ "STTManager",
12
+ "STTError",
13
+ "STTConfigurationError",
14
+ "STTFileError",
15
+ "STTTranscriptionError",
16
+ "STTAgentError",
17
+ ]