livellm 1.1.1__py3-none-any.whl → 1.3.0__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.
livellm/livellm.py CHANGED
@@ -3,14 +3,632 @@ import asyncio
3
3
  import httpx
4
4
  import json
5
5
  import warnings
6
- from typing import List, Optional, AsyncIterator, Union
6
+ from typing import List, Optional, AsyncIterator, Union, overload
7
7
  from .models.common import Settings, SuccessResponse
8
8
  from .models.agent.agent import AgentRequest, AgentResponse
9
- from .models.audio.speak import SpeakRequest
9
+ from .models.audio.speak import SpeakRequest, EncodedSpeakResponse
10
10
  from .models.audio.transcribe import TranscribeRequest, TranscribeResponse, File
11
11
  from .models.fallback import AgentFallbackRequest, AudioFallbackRequest, TranscribeFallbackRequest
12
+ import websockets
13
+ from .models.ws import WsRequest, WsResponse, WsStatus, WsAction
14
+ from .transcripton import TranscriptionWsClient
15
+ from abc import ABC, abstractmethod
12
16
 
13
- class LivellmClient:
17
+
18
+
19
+ class BaseLivellmClient(ABC):
20
+
21
+ @overload
22
+ async def agent_run(
23
+ self,
24
+ request: Union[AgentRequest, AgentFallbackRequest],
25
+ ) -> AgentResponse:
26
+ ...
27
+
28
+ @overload
29
+ async def agent_run(
30
+ self,
31
+ *,
32
+ provider_uid: str,
33
+ model: str,
34
+ messages: list,
35
+ tools: Optional[list] = None,
36
+ **kwargs
37
+ ) -> AgentResponse:
38
+ ...
39
+
40
+
41
+ @abstractmethod
42
+ async def handle_agent_run(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AgentResponse:
43
+ ...
44
+
45
+ async def agent_run(
46
+ self,
47
+ request: Optional[Union[AgentRequest, AgentFallbackRequest]] = None,
48
+ *,
49
+ provider_uid: Optional[str] = None,
50
+ model: Optional[str] = None,
51
+ messages: Optional[list] = None,
52
+ tools: Optional[list] = None,
53
+ **kwargs
54
+ ) -> AgentResponse:
55
+ """
56
+ Run an agent request.
57
+
58
+ Can be called in two ways:
59
+
60
+ 1. With a request object:
61
+ await client.agent_run(AgentRequest(...))
62
+ await client.agent_run(AgentFallbackRequest(...))
63
+
64
+ 2. With individual parameters (keyword arguments):
65
+ await client.agent_run(
66
+ provider_uid="...",
67
+ model="gpt-4",
68
+ messages=[TextMessage(...)],
69
+ tools=[]
70
+ )
71
+
72
+ Args:
73
+ request: An AgentRequest or AgentFallbackRequest object
74
+ provider_uid: The provider UID string
75
+ model: The model to use
76
+ messages: List of messages
77
+ tools: Optional list of tools
78
+ gen_config: Optional generation configuration
79
+
80
+ Returns:
81
+ AgentResponse with the agent's output
82
+ """
83
+ # Check if first argument is a request object
84
+ if request is not None:
85
+ if not isinstance(request, (AgentRequest, AgentFallbackRequest)):
86
+ raise TypeError(
87
+ f"First positional argument must be AgentRequest or AgentFallbackRequest, got {type(request)}"
88
+ )
89
+ return await self.handle_agent_run(request)
90
+
91
+ # Otherwise, use keyword arguments
92
+ if provider_uid is None or model is None or messages is None:
93
+ raise ValueError(
94
+ "provider_uid, model, and messages are required. "
95
+ "Alternatively, pass an AgentRequest object as the first positional argument."
96
+ )
97
+
98
+ agent_request = AgentRequest(
99
+ provider_uid=provider_uid,
100
+ model=model,
101
+ messages=messages,
102
+ tools=tools or [],
103
+ gen_config=kwargs or None
104
+ )
105
+ return await self.handle_agent_run(agent_request)
106
+
107
+ @overload
108
+ def agent_run_stream(
109
+ self,
110
+ request: Union[AgentRequest, AgentFallbackRequest],
111
+ ) -> AsyncIterator[AgentResponse]:
112
+ ...
113
+
114
+ @overload
115
+ def agent_run_stream(
116
+ self,
117
+ *,
118
+ provider_uid: str,
119
+ model: str,
120
+ messages: list,
121
+ tools: Optional[list] = None,
122
+ **kwargs
123
+ ) -> AsyncIterator[AgentResponse]:
124
+ ...
125
+
126
+
127
+ @abstractmethod
128
+ async def handle_agent_run_stream(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AsyncIterator[AgentResponse]:
129
+ ...
130
+
131
+ async def agent_run_stream(
132
+ self,
133
+ request: Optional[Union[AgentRequest, AgentFallbackRequest]] = None,
134
+ *,
135
+ provider_uid: Optional[str] = None,
136
+ model: Optional[str] = None,
137
+ messages: Optional[list] = None,
138
+ tools: Optional[list] = None,
139
+ **kwargs
140
+ ) -> AsyncIterator[AgentResponse]:
141
+ """
142
+ Run an agent request with streaming response.
143
+
144
+ Can be called in two ways:
145
+
146
+ 1. With a request object:
147
+ async for chunk in client.agent_run_stream(AgentRequest(...)):
148
+ ...
149
+ async for chunk in client.agent_run_stream(AgentFallbackRequest(...)):
150
+ ...
151
+
152
+ 2. With individual parameters (keyword arguments):
153
+ async for chunk in client.agent_run_stream(
154
+ provider_uid="...",
155
+ model="gpt-4",
156
+ messages=[TextMessage(...)],
157
+ tools=[]
158
+ ):
159
+ ...
160
+
161
+ Args:
162
+ request: An AgentRequest or AgentFallbackRequest object
163
+ provider_uid: The provider UID string
164
+ model: The model to use
165
+ messages: List of messages
166
+ tools: Optional list of tools
167
+ gen_config: Optional generation configuration
168
+
169
+ Returns:
170
+ AsyncIterator of AgentResponse chunks
171
+ """
172
+ # Check if first argument is a request object
173
+ if request is not None:
174
+ if not isinstance(request, (AgentRequest, AgentFallbackRequest)):
175
+ raise TypeError(
176
+ f"First positional argument must be AgentRequest or AgentFallbackRequest, got {type(request)}"
177
+ )
178
+ stream = self.handle_agent_run_stream(request)
179
+ else:
180
+ # Otherwise, use keyword arguments
181
+ if provider_uid is None or model is None or messages is None:
182
+ raise ValueError(
183
+ "provider_uid, model, and messages are required. "
184
+ "Alternatively, pass an AgentRequest object as the first positional argument."
185
+ )
186
+
187
+ agent_request = AgentRequest(
188
+ provider_uid=provider_uid,
189
+ model=model,
190
+ messages=messages,
191
+ tools=tools or [],
192
+ gen_config=kwargs or None
193
+ )
194
+ stream = self.handle_agent_run_stream(agent_request)
195
+
196
+ async for chunk in stream:
197
+ yield chunk
198
+
199
+ @overload
200
+ async def speak(
201
+ self,
202
+ request: Union[SpeakRequest, AudioFallbackRequest],
203
+ ) -> bytes:
204
+ ...
205
+
206
+ @overload
207
+ async def speak(
208
+ self,
209
+ *,
210
+ provider_uid: str,
211
+ model: str,
212
+ text: str,
213
+ voice: str,
214
+ mime_type: str,
215
+ sample_rate: int,
216
+ chunk_size: int = 20,
217
+ **kwargs
218
+ ) -> bytes:
219
+ ...
220
+
221
+
222
+ @abstractmethod
223
+ async def handle_speak(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> bytes:
224
+ ...
225
+
226
+ async def speak(
227
+ self,
228
+ request: Optional[Union[SpeakRequest, AudioFallbackRequest]] = None,
229
+ *,
230
+ provider_uid: Optional[str] = None,
231
+ model: Optional[str] = None,
232
+ text: Optional[str] = None,
233
+ voice: Optional[str] = None,
234
+ mime_type: Optional[str] = None,
235
+ sample_rate: Optional[int] = None,
236
+ chunk_size: int = 20,
237
+ **kwargs
238
+ ) -> bytes:
239
+ """
240
+ Generate speech from text.
241
+
242
+ Can be called in two ways:
243
+
244
+ 1. With a request object:
245
+ await client.speak(SpeakRequest(...))
246
+ await client.speak(AudioFallbackRequest(...))
247
+
248
+ 2. With individual parameters (keyword arguments):
249
+ await client.speak(
250
+ provider_uid="...",
251
+ model="tts-1",
252
+ text="Hello, world!",
253
+ voice="alloy",
254
+ mime_type="audio/pcm",
255
+ sample_rate=24000
256
+ )
257
+
258
+ Args:
259
+ request: A SpeakRequest or AudioFallbackRequest object
260
+ provider_uid: The provider UID string
261
+ model: The model to use for TTS
262
+ text: The text to convert to speech
263
+ voice: The voice to use
264
+ mime_type: The MIME type of the output audio
265
+ sample_rate: The sample rate of the output audio
266
+ chunk_size: Chunk size in milliseconds (default: 20ms)
267
+ gen_config: Optional generation configuration
268
+
269
+ Returns:
270
+ Audio data as bytes
271
+ """
272
+ # Check if first argument is a request object
273
+ if request is not None:
274
+ if not isinstance(request, (SpeakRequest, AudioFallbackRequest)):
275
+ raise TypeError(
276
+ f"First positional argument must be SpeakRequest or AudioFallbackRequest, got {type(request)}"
277
+ )
278
+ return await self.handle_speak(request)
279
+
280
+ # Otherwise, use keyword arguments
281
+ if provider_uid is None or model is None or text is None or voice is None or mime_type is None or sample_rate is None:
282
+ raise ValueError(
283
+ "provider_uid, model, text, voice, mime_type, and sample_rate are required. "
284
+ "Alternatively, pass a SpeakRequest object as the first positional argument."
285
+ )
286
+
287
+ speak_request = SpeakRequest(
288
+ provider_uid=provider_uid,
289
+ model=model,
290
+ text=text,
291
+ voice=voice,
292
+ mime_type=mime_type,
293
+ sample_rate=sample_rate,
294
+ chunk_size=chunk_size,
295
+ gen_config=kwargs or None
296
+ )
297
+ return await self.handle_speak(speak_request)
298
+
299
+ @overload
300
+ def speak_stream(
301
+ self,
302
+ request: Union[SpeakRequest, AudioFallbackRequest],
303
+ ) -> AsyncIterator[bytes]:
304
+ ...
305
+
306
+ @overload
307
+ def speak_stream(
308
+ self,
309
+ *,
310
+ provider_uid: str,
311
+ model: str,
312
+ text: str,
313
+ voice: str,
314
+ mime_type: str,
315
+ sample_rate: int,
316
+ chunk_size: int = 20,
317
+ **kwargs
318
+ ) -> AsyncIterator[bytes]:
319
+ ...
320
+
321
+
322
+ @abstractmethod
323
+ async def handle_speak_stream(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> AsyncIterator[bytes]:
324
+ ...
325
+
326
+ async def speak_stream(
327
+ self,
328
+ request: Optional[Union[SpeakRequest, AudioFallbackRequest]] = None,
329
+ *,
330
+ provider_uid: Optional[str] = None,
331
+ model: Optional[str] = None,
332
+ text: Optional[str] = None,
333
+ voice: Optional[str] = None,
334
+ mime_type: Optional[str] = None,
335
+ sample_rate: Optional[int] = None,
336
+ chunk_size: int = 20,
337
+ **kwargs
338
+ ) -> AsyncIterator[bytes]:
339
+ """
340
+ Generate speech from text with streaming response.
341
+
342
+ Can be called in two ways:
343
+
344
+ 1. With a request object:
345
+ async for chunk in client.speak_stream(SpeakRequest(...)):
346
+ ...
347
+ async for chunk in client.speak_stream(AudioFallbackRequest(...)):
348
+ ...
349
+
350
+ 2. With individual parameters (keyword arguments):
351
+ async for chunk in client.speak_stream(
352
+ provider_uid="...",
353
+ model="tts-1",
354
+ text="Hello, world!",
355
+ voice="alloy",
356
+ mime_type="audio/pcm",
357
+ sample_rate=24000
358
+ ):
359
+ ...
360
+
361
+ Args:
362
+ request: A SpeakRequest or AudioFallbackRequest object
363
+ provider_uid: The provider UID string
364
+ model: The model to use for TTS
365
+ text: The text to convert to speech
366
+ voice: The voice to use
367
+ mime_type: The MIME type of the output audio
368
+ sample_rate: The sample rate of the output audio
369
+ chunk_size: Chunk size in milliseconds (default: 20ms)
370
+ gen_config: Optional generation configuration
371
+
372
+ Returns:
373
+ AsyncIterator of audio data chunks as bytes
374
+ """
375
+ # Check if first argument is a request object
376
+ if request is not None:
377
+ if not isinstance(request, (SpeakRequest, AudioFallbackRequest)):
378
+ raise TypeError(
379
+ f"First positional argument must be SpeakRequest or AudioFallbackRequest, got {type(request)}"
380
+ )
381
+ speak_stream = self.handle_speak_stream(request)
382
+ else:
383
+ # Otherwise, use keyword arguments
384
+ if provider_uid is None or model is None or text is None or voice is None or mime_type is None or sample_rate is None:
385
+ raise ValueError(
386
+ "provider_uid, model, text, voice, mime_type, and sample_rate are required. "
387
+ "Alternatively, pass a SpeakRequest object as the first positional argument."
388
+ )
389
+
390
+ speak_request = SpeakRequest(
391
+ provider_uid=provider_uid,
392
+ model=model,
393
+ text=text,
394
+ voice=voice,
395
+ mime_type=mime_type,
396
+ sample_rate=sample_rate,
397
+ chunk_size=chunk_size,
398
+ gen_config=kwargs or None
399
+ )
400
+ speak_stream = self.handle_speak_stream(speak_request)
401
+ async for chunk in speak_stream:
402
+ yield chunk
403
+
404
+ @overload
405
+ async def transcribe(
406
+ self,
407
+ request: Union[TranscribeRequest, TranscribeFallbackRequest],
408
+ ) -> TranscribeResponse:
409
+ ...
410
+
411
+ @overload
412
+ async def transcribe(
413
+ self,
414
+ *,
415
+ provider_uid: str,
416
+ file: File,
417
+ model: str,
418
+ language: Optional[str] = None,
419
+ **kwargs
420
+ ) -> TranscribeResponse:
421
+ ...
422
+
423
+
424
+ @abstractmethod
425
+ async def handle_transcribe(self, request: Union[TranscribeRequest, TranscribeFallbackRequest]) -> TranscribeResponse:
426
+ ...
427
+
428
+ async def transcribe(
429
+ self,
430
+ request: Optional[Union[TranscribeRequest, TranscribeFallbackRequest]] = None,
431
+ *,
432
+ provider_uid: Optional[str] = None,
433
+ file: Optional[File] = None,
434
+ model: Optional[str] = None,
435
+ language: Optional[str] = None,
436
+ **kwargs
437
+ ) -> TranscribeResponse:
438
+ """
439
+ Transcribe audio to text.
440
+
441
+ Can be called in two ways:
442
+
443
+ 1. With a request object:
444
+ await client.transcribe(TranscribeRequest(...))
445
+
446
+ 2. With individual parameters (keyword arguments):
447
+ await client.transcribe(
448
+ provider_uid="...",
449
+ file=("filename", audio_bytes, "audio/wav"),
450
+ model="whisper-1"
451
+ )
452
+
453
+ Args:
454
+ request: A TranscribeRequest or TranscribeFallbackRequest object
455
+ provider_uid: The provider UID string
456
+ file: The audio file as a tuple (filename, content, content_type)
457
+ model: The model to use for transcription
458
+ language: Optional language code
459
+ gen_config: Optional generation configuration
460
+
461
+ Returns:
462
+ TranscribeResponse with transcription text and detected language
463
+ """
464
+ # Check if first argument is a request object
465
+ if request is not None:
466
+ if not isinstance(request, (TranscribeRequest, TranscribeFallbackRequest)):
467
+ raise TypeError(
468
+ f"First positional argument must be TranscribeRequest or TranscribeFallbackRequest, got {type(request)}"
469
+ )
470
+ # JSON-based request
471
+ return await self.handle_transcribe(request)
472
+
473
+ # Otherwise, use keyword arguments with multipart form-data request
474
+ if provider_uid is None or file is None or model is None:
475
+ raise ValueError(
476
+ "provider_uid, file, and model are required. "
477
+ "Alternatively, pass a TranscribeRequest object as the first positional argument."
478
+ )
479
+
480
+ transcribe_request = TranscribeRequest(
481
+ provider_uid=provider_uid,
482
+ file=file,
483
+ model=model,
484
+ language=language,
485
+ gen_config=kwargs or None
486
+ )
487
+ return await self.handle_transcribe(transcribe_request)
488
+
489
+
490
+ class LivellmWsClient(BaseLivellmClient):
491
+ """WebSocket-based LiveLLM client for real-time bidirectional communication."""
492
+
493
+ def __init__(
494
+ self,
495
+ base_url: str,
496
+ timeout: Optional[float] = None
497
+ ):
498
+ # Convert HTTP(S) URL to WS(S) URL
499
+ base_url = base_url.rstrip("/")
500
+ if base_url.startswith("https://"):
501
+ ws_url = base_url.replace("https://", "wss://")
502
+ elif base_url.startswith("http://"):
503
+ ws_url = base_url.replace("http://", "ws://")
504
+ else:
505
+ ws_url = base_url
506
+
507
+ # Root WebSocket base URL (without path) and main /ws endpoint
508
+ self._ws_root_base_url = ws_url
509
+ self.base_url = f"{ws_url}/livellm/ws"
510
+ self.timeout = timeout
511
+ self.websocket = None
512
+ # Lazily-created clients
513
+ self._transcription = None
514
+
515
+ async def connect(self):
516
+ """Establish WebSocket connection."""
517
+ if self.websocket is not None:
518
+ return self.websocket
519
+
520
+ self.websocket = await websockets.connect(
521
+ self.base_url,
522
+ open_timeout=self.timeout,
523
+ close_timeout=self.timeout
524
+ )
525
+
526
+ return self.websocket
527
+
528
+ async def disconnect(self):
529
+ """Close WebSocket connection."""
530
+ if self.websocket is not None:
531
+ await self.websocket.close()
532
+ self.websocket = None
533
+
534
+ async def get_response(self, action: WsAction, payload: dict) -> WsResponse:
535
+ """Send a request and wait for response."""
536
+ if self.websocket is None:
537
+ await self.connect()
538
+
539
+ request = WsRequest(action=action, payload=payload)
540
+ await self.websocket.send(json.dumps(request.model_dump()))
541
+
542
+ response_data = await self.websocket.recv()
543
+ response = WsResponse(**json.loads(response_data))
544
+
545
+ if response.status == WsStatus.ERROR:
546
+ raise Exception(f"WebSocket request failed: {response.error}")
547
+
548
+ return response
549
+
550
+ async def get_response_stream(self, action: WsAction, payload: dict) -> AsyncIterator[WsResponse]:
551
+ """Send a request and stream responses."""
552
+ if self.websocket is None:
553
+ await self.connect()
554
+
555
+ request = WsRequest(action=action, payload=payload)
556
+ await self.websocket.send(json.dumps(request.model_dump()))
557
+
558
+ while True:
559
+ response_data = await self.websocket.recv()
560
+ response = WsResponse(**json.loads(response_data))
561
+
562
+ if response.status == WsStatus.ERROR:
563
+ raise Exception(f"WebSocket stream failed: {response.error}")
564
+
565
+ yield response
566
+
567
+ if response.status == WsStatus.SUCCESS:
568
+ break
569
+
570
+ # Implement abstract methods from BaseLivellmClient
571
+
572
+ async def handle_agent_run(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AgentResponse:
573
+ """Handle agent run via WebSocket."""
574
+ response = await self.get_response(
575
+ WsAction.AGENT_RUN,
576
+ request.model_dump()
577
+ )
578
+ return AgentResponse(**response.data)
579
+
580
+ async def handle_agent_run_stream(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AsyncIterator[AgentResponse]:
581
+ """Handle streaming agent run via WebSocket."""
582
+ async for response in self.get_response_stream(WsAction.AGENT_RUN_STREAM, request.model_dump()):
583
+ yield AgentResponse(**response.data)
584
+
585
+ async def handle_speak(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> EncodedSpeakResponse:
586
+ """Handle speak request via WebSocket."""
587
+ response = await self.get_response(
588
+ WsAction.AUDIO_SPEAK,
589
+ request.model_dump()
590
+ )
591
+ return EncodedSpeakResponse(**response.data)
592
+
593
+ async def handle_speak_stream(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> AsyncIterator[EncodedSpeakResponse]:
594
+ """Handle streaming speak request via WebSocket."""
595
+ async for response in self.get_response_stream(WsAction.AUDIO_SPEAK_STREAM, request.model_dump()):
596
+ yield EncodedSpeakResponse(**response.data)
597
+
598
+ async def handle_transcribe(self, request: Union[TranscribeRequest, TranscribeFallbackRequest]) -> TranscribeResponse:
599
+ """Handle transcribe request via WebSocket."""
600
+ response = await self.get_response(
601
+ WsAction.AUDIO_TRANSCRIBE,
602
+ request.model_dump()
603
+ )
604
+ return TranscribeResponse(**response.data)
605
+
606
+ # Context manager support
607
+
608
+ async def __aenter__(self):
609
+ await self.connect()
610
+ return self
611
+
612
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
613
+ await self.disconnect()
614
+
615
+ @property
616
+ def transcription(self) -> TranscriptionWsClient:
617
+ """
618
+ Lazily-initialized WebSocket transcription client that shares the same
619
+ server base URL and timeout as this realtime client.
620
+ """
621
+ if self._transcription is None:
622
+ # Use the ws root (e.g. ws://host:port) and let TranscriptionWsClient
623
+ # append its own /livellm/ws/transcription path.
624
+ self._transcription = TranscriptionWsClient(
625
+ base_url=self._ws_root_base_url,
626
+ timeout=self.timeout,
627
+ )
628
+ return self._transcription
629
+
630
+ class LivellmClient(BaseLivellmClient):
631
+ """HTTP-based LiveLLM client for request-response communication."""
14
632
 
15
633
  def __init__(
16
634
  self,
@@ -18,8 +636,10 @@ class LivellmClient:
18
636
  timeout: Optional[float] = None,
19
637
  configs: Optional[List[Settings]] = None
20
638
  ):
21
- base_url = base_url.rstrip("/")
22
- self.base_url = f"{base_url}/livellm"
639
+ # Root server URL (http/https, without trailing slash)
640
+ self._root_base_url = base_url.rstrip("/")
641
+ # HTTP API base URL for this client
642
+ self.base_url = f"{self._root_base_url}/livellm"
23
643
  self.timeout = timeout
24
644
  self.client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) \
25
645
  if self.timeout else httpx.AsyncClient(base_url=self.base_url)
@@ -27,10 +647,27 @@ class LivellmClient:
27
647
  self.headers = {
28
648
  "Content-Type": "application/json",
29
649
  }
650
+ # Lazily-created realtime (WebSocket) client
651
+ self._realtime = None
30
652
  if configs:
31
653
  self.update_configs_post_init(configs)
32
-
33
654
 
655
+ @property
656
+ def realtime(self) -> LivellmWsClient:
657
+ """
658
+ Lazily-initialized WebSocket client for realtime operations (agent, audio, etc.)
659
+ that shares the same server base URL and timeout as this HTTP client.
660
+
661
+ Example:
662
+ client = LivellmClient(base_url=\"http://localhost:8000\")
663
+ async with client.realtime as session:
664
+ response = await session.agent_run(...)
665
+ """
666
+ if self._realtime is None:
667
+ # Pass the same root base URL; LivellmWsClient will handle ws/wss conversion.
668
+ self._realtime = LivellmWsClient(self._root_base_url, timeout=self.timeout)
669
+ return self._realtime
670
+
34
671
  def update_configs_post_init(self, configs: List[Settings]) -> SuccessResponse:
35
672
  """
36
673
  Update the configs after the client is initialized.
@@ -121,22 +758,22 @@ class LivellmClient:
121
758
  error_response = error_response.decode("utf-8")
122
759
  raise Exception(f"Failed to post to {endpoint}: {error_response}")
123
760
  if expect_stream:
124
- async def stream_response() -> AsyncIterator[Union[dict, bytes]]:
761
+ async def json_stream_response() -> AsyncIterator[dict]:
125
762
  async for chunk in response.aiter_lines():
126
- if expect_json:
127
- chunk = chunk.strip()
128
- if not chunk:
129
- continue
130
- yield json.loads(chunk)
131
- else:
132
- yield chunk
763
+ chunk = chunk.strip()
764
+ if not chunk:
765
+ continue
766
+ yield json.loads(chunk)
767
+ async def bytes_stream_response() -> AsyncIterator[bytes]:
768
+ async for chunk in response.aiter_bytes():
769
+ yield chunk
770
+ stream_response = json_stream_response if expect_json else bytes_stream_response
133
771
  return stream_response()
134
772
  else:
135
773
  if expect_json:
136
774
  return response.json()
137
775
  else:
138
776
  return response.content
139
-
140
777
 
141
778
  async def ping(self) -> SuccessResponse:
142
779
  result = await self.get("ping")
@@ -169,15 +806,10 @@ class LivellmClient:
169
806
  config: Settings = config
170
807
  await self.delete_config(config.uid)
171
808
  await self.client.aclose()
172
-
173
- async def __aenter__(self):
174
- """Async context manager entry."""
175
- return self
176
-
177
- async def __aexit__(self, exc_type, exc_val, exc_tb):
178
- """Async context manager exit."""
179
- await self.cleanup()
180
-
809
+ # Also close any realtime WebSocket client if it was created
810
+ if self._realtime is not None:
811
+ await self._realtime.disconnect()
812
+
181
813
  def __del__(self):
182
814
  """
183
815
  Destructor to clean up resources when the client is garbage collected.
@@ -205,61 +837,31 @@ class LivellmClient:
205
837
  # Silently fail - we're in a destructor
206
838
  pass
207
839
 
208
- async def agent_run(
209
- self,
210
- request: Union[AgentRequest, AgentFallbackRequest]
211
- ) -> AgentResponse:
840
+ # Implement abstract methods from BaseLivellmClient
841
+
842
+ async def handle_agent_run(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AgentResponse:
843
+ """Handle agent run via HTTP."""
212
844
  result = await self.post(request.model_dump(), "agent/run", expect_json=True)
213
845
  return AgentResponse(**result)
214
846
 
215
- async def agent_run_stream(
216
- self,
217
- request: Union[AgentRequest, AgentFallbackRequest]
218
- ) -> AsyncIterator[AgentResponse]:
847
+ async def handle_agent_run_stream(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AsyncIterator[AgentResponse]:
848
+ """Handle streaming agent run via HTTP."""
219
849
  stream = await self.post(request.model_dump(), "agent/run_stream", expect_stream=True, expect_json=True)
220
850
  async for chunk in stream:
221
851
  yield AgentResponse(**chunk)
222
852
 
223
- async def speak(
224
- self,
225
- request: Union[SpeakRequest, AudioFallbackRequest]
226
- ) -> bytes:
853
+ async def handle_speak(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> bytes:
854
+ """Handle speak request via HTTP."""
227
855
  return await self.post(request.model_dump(), "audio/speak", expect_json=False)
228
856
 
229
- async def speak_stream(
230
- self,
231
- request: Union[SpeakRequest, AudioFallbackRequest]
232
- ) -> AsyncIterator[bytes]:
233
- return await self.post(request.model_dump(), "audio/speak_stream", expect_stream=True, expect_json=False)
234
-
235
-
236
- async def transcribe(
237
- self,
238
- provider_uid: str,
239
- file: File,
240
- model: str,
241
- language: Optional[str] = None,
242
- gen_config: Optional[dict] = None
243
- ) -> TranscribeResponse:
244
- files = {
245
- "file": file
246
- }
247
- data = {
248
- "provider_uid": provider_uid,
249
- "model": model,
250
- "language": language,
251
- "gen_config": json.dumps(gen_config) if gen_config else None
252
- }
253
- result = await self.post_multipart(files, data, "audio/transcribe")
254
- return TranscribeResponse(**result)
857
+ async def handle_speak_stream(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> AsyncIterator[bytes]:
858
+ """Handle streaming speak request via HTTP."""
859
+ speak_stream = await self.post(request.model_dump(), "audio/speak_stream", expect_stream=True, expect_json=False)
860
+ async for chunk in speak_stream:
861
+ yield chunk
255
862
 
256
- async def transcribe_json(
257
- self,
258
- request: Union[TranscribeRequest, TranscribeFallbackRequest]
259
- ) -> TranscribeResponse:
863
+ async def handle_transcribe(self, request: Union[TranscribeRequest, TranscribeFallbackRequest]) -> TranscribeResponse:
864
+ """Handle transcribe request via HTTP."""
260
865
  result = await self.post(request.model_dump(), "audio/transcribe_json", expect_json=True)
261
866
  return TranscribeResponse(**result)
262
-
263
-
264
-
265
867