avartha-python-sdk 0.0.6__py3-none-any.whl → 0.0.7__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.
avartha/__init__.py CHANGED
@@ -30,7 +30,12 @@ from ._openai_http_errors import UnprocessableEntityError as OpenAIUnprocessable
30
30
  from ._pacing import WebSocketLimits
31
31
  from ._realtime_asr import AudioFormat, CommitStrategy, RealtimeAudioOptions, RealtimeEvents
32
32
  from ._realtime_tts import DEFAULT_OUTPUT_FORMAT as DEFAULT_TTS_OUTPUT_FORMAT
33
- from ._realtime_tts import AsyncMultiContextSession, MultiContextSession, audio_bytes
33
+ from ._realtime_tts import (
34
+ AsyncMultiContextSession,
35
+ MultiContextSession,
36
+ TextToSpeechOutputFormat,
37
+ audio_bytes,
38
+ )
34
39
  from ._sentinels import NOT_GIVEN, NotGiven, Omit, not_given, omit
35
40
  from .client import AsyncAvartha, Avartha
36
41
  from .control import AsyncControl, Control
@@ -91,6 +96,7 @@ __all__ = [
91
96
  "PlatformAPIError",
92
97
  "RealtimeAudioOptions",
93
98
  "RealtimeEvents",
99
+ "TextToSpeechOutputFormat",
94
100
  "VoiceSettings",
95
101
  "WebSocketLimits",
96
102
  "audio_bytes",
@@ -0,0 +1,555 @@
1
+ """Typed resources for Avartha's conversation routes.
2
+
3
+ Their parameters are the ones the platform accepts, which is a small subset of ElevenLabs
4
+ 2.68.0. It refuses unknown query parameters, so the vendor's other filters would only fail.
5
+ """
6
+
7
+ import json
8
+ from collections.abc import Mapping
9
+ from typing import Any, Never
10
+
11
+ from ._exceptions import UnsupportedOperationError
12
+ from ._speech_config import RequestOptions
13
+ from ._speech_http import (
14
+ AsyncHttpResponse,
15
+ AsyncSpeechHTTPClient,
16
+ HttpResponse,
17
+ SpeechHTTPClient,
18
+ segment,
19
+ )
20
+ from .types.conversational_ai import (
21
+ ConversationInitiationClientDataRequestInput,
22
+ ConversationSignedUrlResponseModel,
23
+ GetConversationResponseModel,
24
+ GetConversationsPageResponseModel,
25
+ TelephonyCallConfigInput,
26
+ TokenResponseModel,
27
+ TwilioOutboundCallResponse,
28
+ )
29
+
30
+ _BASE = "v1/convai"
31
+ _OUTBOUND_CALL = f"{_BASE}/twilio/outbound-call"
32
+ _IDEMPOTENCY_HEADER = "Idempotency-Key"
33
+ # A 409 is left to the caller: the key was reused for a different call, or the workspace is at
34
+ # its call limit, and ringing someone moments later wouldn't be expected.
35
+ _DIAL_RETRY_STATUSES = (408, 429)
36
+
37
+
38
+ def _encoded(value: Mapping[str, Any] | None) -> str | None:
39
+ return None if not value else json.dumps(value, separators=(",", ":"))
40
+
41
+
42
+ def _mint_params(
43
+ agent_id: str,
44
+ origin: str | None,
45
+ conversation_config_override: Mapping[str, Any] | None,
46
+ dynamic_variables: Mapping[str, Any] | None,
47
+ ) -> dict[str, Any]:
48
+ return {
49
+ "agent_id": agent_id,
50
+ "origin": origin,
51
+ "conversation_config_override": _encoded(conversation_config_override),
52
+ "dynamic_variables": _encoded(dynamic_variables),
53
+ }
54
+
55
+
56
+ def _dial_body(
57
+ agent_id: str,
58
+ agent_phone_number_id: str,
59
+ to_number: str,
60
+ conversation_initiation_client_data: ConversationInitiationClientDataRequestInput | None,
61
+ call_recording_enabled: bool | None,
62
+ telephony_call_config: TelephonyCallConfigInput | None,
63
+ ) -> dict[str, Any]:
64
+ optional = {
65
+ "conversation_initiation_client_data": conversation_initiation_client_data,
66
+ "call_recording_enabled": call_recording_enabled,
67
+ "telephony_call_config": telephony_call_config,
68
+ }
69
+ return {
70
+ "agent_id": agent_id,
71
+ "agent_phone_number_id": agent_phone_number_id,
72
+ "to_number": to_number,
73
+ **{name: value for name, value in optional.items() if value is not None},
74
+ }
75
+
76
+
77
+ def _dial_options(
78
+ idempotency_key: str | None, request_options: RequestOptions | None
79
+ ) -> RequestOptions:
80
+ """Attach the key, and never retry without one: each keyless request rings someone."""
81
+ options: RequestOptions = {**(request_options or {})}
82
+ headers = dict(options.get("additional_headers") or {})
83
+ key_header = _IDEMPOTENCY_HEADER.lower()
84
+ if idempotency_key is not None:
85
+ headers = {name: value for name, value in headers.items() if name.lower() != key_header}
86
+ headers[_IDEMPOTENCY_HEADER] = idempotency_key
87
+ options["additional_headers"] = headers
88
+ if not any(name.lower() == key_header and value is not None for name, value in headers.items()):
89
+ options["max_retries"] = 0
90
+ return options
91
+
92
+
93
+ def _refuse_register_call() -> Never:
94
+ raise UnsupportedOperationError(
95
+ "conversational_ai.twilio.register_call is not supported. Avartha answers calls to a "
96
+ "connected number with the agent assigned to it, and outbound_call places calls."
97
+ )
98
+
99
+
100
+ class RawConversations:
101
+ def __init__(self, client: SpeechHTTPClient) -> None:
102
+ self._client = client
103
+
104
+ def get_signed_url(
105
+ self,
106
+ *,
107
+ agent_id: str,
108
+ origin: str | None = None,
109
+ conversation_config_override: Mapping[str, Any] | None = None,
110
+ dynamic_variables: Mapping[str, Any] | None = None,
111
+ request_options: RequestOptions | None = None,
112
+ ) -> HttpResponse[ConversationSignedUrlResponseModel]:
113
+ return self._client.get(
114
+ f"{_BASE}/conversation/get-signed-url",
115
+ ConversationSignedUrlResponseModel,
116
+ params=_mint_params(agent_id, origin, conversation_config_override, dynamic_variables),
117
+ request_options=request_options,
118
+ )
119
+
120
+ def get_webrtc_token(
121
+ self,
122
+ *,
123
+ agent_id: str,
124
+ origin: str | None = None,
125
+ conversation_config_override: Mapping[str, Any] | None = None,
126
+ dynamic_variables: Mapping[str, Any] | None = None,
127
+ request_options: RequestOptions | None = None,
128
+ ) -> HttpResponse[TokenResponseModel]:
129
+ return self._client.get(
130
+ f"{_BASE}/conversation/token",
131
+ TokenResponseModel,
132
+ params=_mint_params(agent_id, origin, conversation_config_override, dynamic_variables),
133
+ request_options=request_options,
134
+ )
135
+
136
+ def list(
137
+ self, *, cursor: str | None = None, request_options: RequestOptions | None = None
138
+ ) -> HttpResponse[GetConversationsPageResponseModel]:
139
+ return self._client.get(
140
+ f"{_BASE}/conversations",
141
+ GetConversationsPageResponseModel,
142
+ params={"cursor": cursor},
143
+ request_options=request_options,
144
+ )
145
+
146
+ def get(
147
+ self,
148
+ conversation_id: str,
149
+ *,
150
+ format: str | None = None,
151
+ request_options: RequestOptions | None = None,
152
+ ) -> HttpResponse[GetConversationResponseModel]:
153
+ return self._client.get(
154
+ f"{_BASE}/conversations/{segment(conversation_id)}",
155
+ GetConversationResponseModel,
156
+ params={"format": format},
157
+ request_options=request_options,
158
+ )
159
+
160
+ def end(
161
+ self, conversation_id: str, *, request_options: RequestOptions | None = None
162
+ ) -> HttpResponse[GetConversationResponseModel]:
163
+ return self._client.post(
164
+ f"{_BASE}/conversations/{segment(conversation_id)}/end",
165
+ GetConversationResponseModel,
166
+ params={},
167
+ request_options=request_options,
168
+ )
169
+
170
+
171
+ class Conversations:
172
+ """Mint connection grants and read or end conversations."""
173
+
174
+ def __init__(self, client: SpeechHTTPClient) -> None:
175
+ self.with_raw_response = RawConversations(client)
176
+
177
+ def get_signed_url(
178
+ self,
179
+ *,
180
+ agent_id: str,
181
+ origin: str | None = None,
182
+ conversation_config_override: Mapping[str, Any] | None = None,
183
+ dynamic_variables: Mapping[str, Any] | None = None,
184
+ request_options: RequestOptions | None = None,
185
+ ) -> ConversationSignedUrlResponseModel:
186
+ """Mint a single-use WebSocket URL that expires in 30 seconds."""
187
+ return self.with_raw_response.get_signed_url(
188
+ agent_id=agent_id,
189
+ origin=origin,
190
+ conversation_config_override=conversation_config_override,
191
+ dynamic_variables=dynamic_variables,
192
+ request_options=request_options,
193
+ ).data
194
+
195
+ def get_webrtc_token(
196
+ self,
197
+ *,
198
+ agent_id: str,
199
+ origin: str | None = None,
200
+ conversation_config_override: Mapping[str, Any] | None = None,
201
+ dynamic_variables: Mapping[str, Any] | None = None,
202
+ request_options: RequestOptions | None = None,
203
+ ) -> TokenResponseModel:
204
+ """Mint a grant for a browser's WebRTC connection."""
205
+ return self.with_raw_response.get_webrtc_token(
206
+ agent_id=agent_id,
207
+ origin=origin,
208
+ conversation_config_override=conversation_config_override,
209
+ dynamic_variables=dynamic_variables,
210
+ request_options=request_options,
211
+ ).data
212
+
213
+ def list(
214
+ self, *, cursor: str | None = None, request_options: RequestOptions | None = None
215
+ ) -> GetConversationsPageResponseModel:
216
+ """Return up to 100 conversations; pass next_cursor as cursor for the next page."""
217
+ return self.with_raw_response.list(cursor=cursor, request_options=request_options).data
218
+
219
+ def get(
220
+ self,
221
+ conversation_id: str,
222
+ *,
223
+ format: str | None = None,
224
+ request_options: RequestOptions | None = None,
225
+ ) -> GetConversationResponseModel:
226
+ """Return a conversation's status, retained transcript, and billing."""
227
+ return self.with_raw_response.get(
228
+ conversation_id, format=format, request_options=request_options
229
+ ).data
230
+
231
+ def end(
232
+ self, conversation_id: str, *, request_options: RequestOptions | None = None
233
+ ) -> GetConversationResponseModel:
234
+ """End a conversation; ending one that has finished is safe. Avartha extension."""
235
+ return self.with_raw_response.end(conversation_id, request_options=request_options).data
236
+
237
+
238
+ class AsyncRawConversations:
239
+ def __init__(self, client: AsyncSpeechHTTPClient) -> None:
240
+ self._client = client
241
+
242
+ async def get_signed_url(
243
+ self,
244
+ *,
245
+ agent_id: str,
246
+ origin: str | None = None,
247
+ conversation_config_override: Mapping[str, Any] | None = None,
248
+ dynamic_variables: Mapping[str, Any] | None = None,
249
+ request_options: RequestOptions | None = None,
250
+ ) -> AsyncHttpResponse[ConversationSignedUrlResponseModel]:
251
+ return await self._client.get(
252
+ f"{_BASE}/conversation/get-signed-url",
253
+ ConversationSignedUrlResponseModel,
254
+ params=_mint_params(agent_id, origin, conversation_config_override, dynamic_variables),
255
+ request_options=request_options,
256
+ )
257
+
258
+ async def get_webrtc_token(
259
+ self,
260
+ *,
261
+ agent_id: str,
262
+ origin: str | None = None,
263
+ conversation_config_override: Mapping[str, Any] | None = None,
264
+ dynamic_variables: Mapping[str, Any] | None = None,
265
+ request_options: RequestOptions | None = None,
266
+ ) -> AsyncHttpResponse[TokenResponseModel]:
267
+ return await self._client.get(
268
+ f"{_BASE}/conversation/token",
269
+ TokenResponseModel,
270
+ params=_mint_params(agent_id, origin, conversation_config_override, dynamic_variables),
271
+ request_options=request_options,
272
+ )
273
+
274
+ async def list(
275
+ self, *, cursor: str | None = None, request_options: RequestOptions | None = None
276
+ ) -> AsyncHttpResponse[GetConversationsPageResponseModel]:
277
+ return await self._client.get(
278
+ f"{_BASE}/conversations",
279
+ GetConversationsPageResponseModel,
280
+ params={"cursor": cursor},
281
+ request_options=request_options,
282
+ )
283
+
284
+ async def get(
285
+ self,
286
+ conversation_id: str,
287
+ *,
288
+ format: str | None = None,
289
+ request_options: RequestOptions | None = None,
290
+ ) -> AsyncHttpResponse[GetConversationResponseModel]:
291
+ return await self._client.get(
292
+ f"{_BASE}/conversations/{segment(conversation_id)}",
293
+ GetConversationResponseModel,
294
+ params={"format": format},
295
+ request_options=request_options,
296
+ )
297
+
298
+ async def end(
299
+ self, conversation_id: str, *, request_options: RequestOptions | None = None
300
+ ) -> AsyncHttpResponse[GetConversationResponseModel]:
301
+ return await self._client.post(
302
+ f"{_BASE}/conversations/{segment(conversation_id)}/end",
303
+ GetConversationResponseModel,
304
+ params={},
305
+ request_options=request_options,
306
+ )
307
+
308
+
309
+ class AsyncConversations:
310
+ """Mint connection grants and read or end conversations."""
311
+
312
+ def __init__(self, client: AsyncSpeechHTTPClient) -> None:
313
+ self.with_raw_response = AsyncRawConversations(client)
314
+
315
+ async def get_signed_url(
316
+ self,
317
+ *,
318
+ agent_id: str,
319
+ origin: str | None = None,
320
+ conversation_config_override: Mapping[str, Any] | None = None,
321
+ dynamic_variables: Mapping[str, Any] | None = None,
322
+ request_options: RequestOptions | None = None,
323
+ ) -> ConversationSignedUrlResponseModel:
324
+ """Mint a single-use WebSocket URL that expires in 30 seconds."""
325
+ response = await self.with_raw_response.get_signed_url(
326
+ agent_id=agent_id,
327
+ origin=origin,
328
+ conversation_config_override=conversation_config_override,
329
+ dynamic_variables=dynamic_variables,
330
+ request_options=request_options,
331
+ )
332
+ return response.data
333
+
334
+ async def get_webrtc_token(
335
+ self,
336
+ *,
337
+ agent_id: str,
338
+ origin: str | None = None,
339
+ conversation_config_override: Mapping[str, Any] | None = None,
340
+ dynamic_variables: Mapping[str, Any] | None = None,
341
+ request_options: RequestOptions | None = None,
342
+ ) -> TokenResponseModel:
343
+ """Mint a grant for a browser's WebRTC connection."""
344
+ response = await self.with_raw_response.get_webrtc_token(
345
+ agent_id=agent_id,
346
+ origin=origin,
347
+ conversation_config_override=conversation_config_override,
348
+ dynamic_variables=dynamic_variables,
349
+ request_options=request_options,
350
+ )
351
+ return response.data
352
+
353
+ async def list(
354
+ self, *, cursor: str | None = None, request_options: RequestOptions | None = None
355
+ ) -> GetConversationsPageResponseModel:
356
+ """Return up to 100 conversations; pass next_cursor as cursor for the next page."""
357
+ response = await self.with_raw_response.list(cursor=cursor, request_options=request_options)
358
+ return response.data
359
+
360
+ async def get(
361
+ self,
362
+ conversation_id: str,
363
+ *,
364
+ format: str | None = None,
365
+ request_options: RequestOptions | None = None,
366
+ ) -> GetConversationResponseModel:
367
+ """Return a conversation's status, retained transcript, and billing."""
368
+ response = await self.with_raw_response.get(
369
+ conversation_id, format=format, request_options=request_options
370
+ )
371
+ return response.data
372
+
373
+ async def end(
374
+ self, conversation_id: str, *, request_options: RequestOptions | None = None
375
+ ) -> GetConversationResponseModel:
376
+ """End a conversation; ending one that has finished is safe. Avartha extension."""
377
+ response = await self.with_raw_response.end(
378
+ conversation_id, request_options=request_options
379
+ )
380
+ return response.data
381
+
382
+
383
+ class RawTwilio:
384
+ def __init__(self, client: SpeechHTTPClient) -> None:
385
+ self._client = client
386
+
387
+ def outbound_call(
388
+ self,
389
+ *,
390
+ agent_id: str,
391
+ agent_phone_number_id: str,
392
+ to_number: str,
393
+ conversation_initiation_client_data: ConversationInitiationClientDataRequestInput
394
+ | None = None,
395
+ call_recording_enabled: bool | None = None,
396
+ telephony_call_config: TelephonyCallConfigInput | None = None,
397
+ idempotency_key: str | None = None,
398
+ request_options: RequestOptions | None = None,
399
+ ) -> HttpResponse[TwilioOutboundCallResponse]:
400
+ return self._client.post(
401
+ _OUTBOUND_CALL,
402
+ TwilioOutboundCallResponse,
403
+ params={},
404
+ body=_dial_body(
405
+ agent_id,
406
+ agent_phone_number_id,
407
+ to_number,
408
+ conversation_initiation_client_data,
409
+ call_recording_enabled,
410
+ telephony_call_config,
411
+ ),
412
+ retry_statuses=_DIAL_RETRY_STATUSES,
413
+ request_options=_dial_options(idempotency_key, request_options),
414
+ )
415
+
416
+
417
+ class Twilio:
418
+ """Place phone calls from one of the workspace's Twilio numbers."""
419
+
420
+ def __init__(self, client: SpeechHTTPClient) -> None:
421
+ self.with_raw_response = RawTwilio(client)
422
+
423
+ def outbound_call(
424
+ self,
425
+ *,
426
+ agent_id: str,
427
+ agent_phone_number_id: str,
428
+ to_number: str,
429
+ conversation_initiation_client_data: ConversationInitiationClientDataRequestInput
430
+ | None = None,
431
+ call_recording_enabled: bool | None = None,
432
+ telephony_call_config: TelephonyCallConfigInput | None = None,
433
+ idempotency_key: str | None = None,
434
+ request_options: RequestOptions | None = None,
435
+ ) -> TwilioOutboundCallResponse:
436
+ """Ring to_number and connect the agent when someone answers.
437
+
438
+ Pass idempotency_key, an Avartha extension, so a repeated request returns the first
439
+ attempt instead of ringing again; only then does the SDK retry. Check success: False
440
+ means Twilio refused the call or didn't confirm it, and an unconfirmed call may still
441
+ ring.
442
+ """
443
+ return self.with_raw_response.outbound_call(
444
+ agent_id=agent_id,
445
+ agent_phone_number_id=agent_phone_number_id,
446
+ to_number=to_number,
447
+ conversation_initiation_client_data=conversation_initiation_client_data,
448
+ call_recording_enabled=call_recording_enabled,
449
+ telephony_call_config=telephony_call_config,
450
+ idempotency_key=idempotency_key,
451
+ request_options=request_options,
452
+ ).data
453
+
454
+ def register_call(self, **kwargs: Any) -> Never:
455
+ _refuse_register_call()
456
+
457
+
458
+ class AsyncRawTwilio:
459
+ def __init__(self, client: AsyncSpeechHTTPClient) -> None:
460
+ self._client = client
461
+
462
+ async def outbound_call(
463
+ self,
464
+ *,
465
+ agent_id: str,
466
+ agent_phone_number_id: str,
467
+ to_number: str,
468
+ conversation_initiation_client_data: ConversationInitiationClientDataRequestInput
469
+ | None = None,
470
+ call_recording_enabled: bool | None = None,
471
+ telephony_call_config: TelephonyCallConfigInput | None = None,
472
+ idempotency_key: str | None = None,
473
+ request_options: RequestOptions | None = None,
474
+ ) -> AsyncHttpResponse[TwilioOutboundCallResponse]:
475
+ return await self._client.post(
476
+ _OUTBOUND_CALL,
477
+ TwilioOutboundCallResponse,
478
+ params={},
479
+ body=_dial_body(
480
+ agent_id,
481
+ agent_phone_number_id,
482
+ to_number,
483
+ conversation_initiation_client_data,
484
+ call_recording_enabled,
485
+ telephony_call_config,
486
+ ),
487
+ retry_statuses=_DIAL_RETRY_STATUSES,
488
+ request_options=_dial_options(idempotency_key, request_options),
489
+ )
490
+
491
+
492
+ class AsyncTwilio:
493
+ """Place phone calls from one of the workspace's Twilio numbers."""
494
+
495
+ def __init__(self, client: AsyncSpeechHTTPClient) -> None:
496
+ self.with_raw_response = AsyncRawTwilio(client)
497
+
498
+ async def outbound_call(
499
+ self,
500
+ *,
501
+ agent_id: str,
502
+ agent_phone_number_id: str,
503
+ to_number: str,
504
+ conversation_initiation_client_data: ConversationInitiationClientDataRequestInput
505
+ | None = None,
506
+ call_recording_enabled: bool | None = None,
507
+ telephony_call_config: TelephonyCallConfigInput | None = None,
508
+ idempotency_key: str | None = None,
509
+ request_options: RequestOptions | None = None,
510
+ ) -> TwilioOutboundCallResponse:
511
+ """Ring to_number and connect the agent when someone answers.
512
+
513
+ Pass idempotency_key, an Avartha extension, so a repeated request returns the first
514
+ attempt instead of ringing again; only then does the SDK retry. Check success: False
515
+ means Twilio refused the call or didn't confirm it, and an unconfirmed call may still
516
+ ring.
517
+ """
518
+ response = await self.with_raw_response.outbound_call(
519
+ agent_id=agent_id,
520
+ agent_phone_number_id=agent_phone_number_id,
521
+ to_number=to_number,
522
+ conversation_initiation_client_data=conversation_initiation_client_data,
523
+ call_recording_enabled=call_recording_enabled,
524
+ telephony_call_config=telephony_call_config,
525
+ idempotency_key=idempotency_key,
526
+ request_options=request_options,
527
+ )
528
+ return response.data
529
+
530
+ def register_call(self, **kwargs: Any) -> Never:
531
+ _refuse_register_call()
532
+
533
+
534
+ class _ConversationalAi:
535
+ def __getattr__(self, name: str) -> Never:
536
+ if name.startswith("_"):
537
+ raise AttributeError(name)
538
+ raise NotImplementedError(
539
+ f"conversational_ai.{name} is not implemented. ElevenLabs-schema agents, tools, and "
540
+ "knowledge bases will be implemented in a future release; "
541
+ "conversational_ai.conversations and conversational_ai.twilio are available, "
542
+ "and Control.voice_agents manages Avartha voice agents."
543
+ )
544
+
545
+
546
+ class ConversationalAi(_ConversationalAi):
547
+ def __init__(self, client: SpeechHTTPClient) -> None:
548
+ self.conversations = Conversations(client)
549
+ self.twilio = Twilio(client)
550
+
551
+
552
+ class AsyncConversationalAi(_ConversationalAi):
553
+ def __init__(self, client: AsyncSpeechHTTPClient) -> None:
554
+ self.conversations = AsyncConversations(client)
555
+ self.twilio = AsyncTwilio(client)
avartha/_openai_http.py CHANGED
@@ -133,7 +133,10 @@ def _check_status(response: httpx.Response) -> None:
133
133
  body = response.json()
134
134
  except ValueError:
135
135
  body = response.text
136
- error_body = body.get("error", body) if isinstance(body, dict) else body
136
+ # OpenAI nests error details; the platform also returns flat envelopes whose
137
+ # string `error` is a message, with code/type/param alongside it.
138
+ nested = body.get("error") if isinstance(body, dict) else None
139
+ error_body = nested if isinstance(nested, dict) else body
137
140
  cls = {
138
141
  400: BadRequestError,
139
142
  401: AuthenticationError,