livellm 1.2.0__py3-none-any.whl → 1.3.5__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/__init__.py CHANGED
@@ -1,15 +1,19 @@
1
1
  """LiveLLM Client - Python client for the LiveLLM Proxy and Realtime APIs."""
2
2
 
3
- from .livellm import LivellmClient
3
+ from .livellm import LivellmClient, LivellmWsClient, BaseLivellmClient
4
+ from .transcripton import TranscriptionWsClient
4
5
  from . import models
5
6
 
6
- __version__ = "1.1.0"
7
+ __version__ = "1.2.0"
7
8
 
8
9
  __all__ = [
9
10
  # Version
10
11
  "__version__",
11
12
  # Classes
12
13
  "LivellmClient",
14
+ "LivellmWsClient",
15
+ "BaseLivellmClient",
16
+ "TranscriptionWsClient",
13
17
  # Models
14
18
  *models.__all__,
15
19
  ]
livellm/livellm.py CHANGED
@@ -6,205 +6,17 @@ import warnings
6
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:
14
17
 
15
- def __init__(
16
- self,
17
- base_url: str,
18
- timeout: Optional[float] = None,
19
- configs: Optional[List[Settings]] = None
20
- ):
21
- base_url = base_url.rstrip("/")
22
- self.base_url = f"{base_url}/livellm"
23
- self.timeout = timeout
24
- self.client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) \
25
- if self.timeout else httpx.AsyncClient(base_url=self.base_url)
26
- self.settings = []
27
- self.headers = {
28
- "Content-Type": "application/json",
29
- }
30
- if configs:
31
- self.update_configs_post_init(configs)
32
-
33
-
34
- def update_configs_post_init(self, configs: List[Settings]) -> SuccessResponse:
35
- """
36
- Update the configs after the client is initialized.
37
- Args:
38
- configs: The configs to update.
39
- """
40
- with httpx.Client(base_url=self.base_url, timeout=self.timeout) as client:
41
- for config in configs:
42
- response = client.post(f"{self.base_url}/providers/config", json=config.model_dump())
43
- response.raise_for_status()
44
- self.settings.append(config)
45
- return SuccessResponse(success=True, message="Configs updated successfully")
46
-
47
-
48
- async def delete(self, endpoint: str) -> dict:
49
- """
50
- Delete a resource from the given endpoint and return the response.
51
- Args:
52
- endpoint: The endpoint to delete from.
53
- Returns:
54
- The response from the endpoint.
55
- """
56
- response = await self.client.delete(endpoint, headers=self.headers)
57
- response.raise_for_status()
58
- return response.json()
59
-
60
- async def post_multipart(
61
- self,
62
- files: dict,
63
- data: dict,
64
- endpoint: str
65
- ) -> dict:
66
- """
67
- Post a multipart request to the given endpoint and return the response.
68
- Args:
69
- files: The files to send in the request.
70
- data: The data to send in the request.
71
- endpoint: The endpoint to post to.
72
- Returns:
73
- The response from the endpoint.
74
- """
75
- # Don't pass Content-Type header for multipart - httpx will set it automatically
76
- response = await self.client.post(endpoint, files=files, data=data)
77
- response.raise_for_status()
78
- return response.json()
79
-
80
-
81
- async def get(
82
- self,
83
- endpoint: str
84
- ) -> dict:
85
- """
86
- Get a request from the given endpoint and return the response.
87
- Args:
88
- endpoint: The endpoint to get from.
89
- Returns:
90
- The response from the endpoint.
91
- """
92
- response = await self.client.get(endpoint, headers=self.headers)
93
- response.raise_for_status()
94
- return response.json()
95
-
96
- async def post(
97
- self,
98
- json_data: dict,
99
- endpoint: str,
100
- expect_stream: bool = False,
101
- expect_json: bool = True
102
- ) -> Union[dict, bytes, AsyncIterator[Union[dict, bytes]]]:
103
- """
104
- Post a request to the given endpoint and return the response.
105
- If expect_stream is True, return an AsyncIterator of the response.
106
- If expect_json is True, return the response as a JSON object.
107
- Otherwise, return the response as bytes.
108
- Args:
109
- json_data: The JSON data to send in the request.
110
- endpoint: The endpoint to post to.
111
- expect_stream: Whether to expect a stream response.
112
- expect_json: Whether to expect a JSON response.
113
- Returns:
114
- The response from the endpoint.
115
- Raises:
116
- Exception: If the response is not 200 or 201.
117
- """
118
- response = await self.client.post(endpoint, json=json_data, headers=self.headers)
119
- if response.status_code not in [200, 201]:
120
- error_response = await response.aread()
121
- error_response = error_response.decode("utf-8")
122
- raise Exception(f"Failed to post to {endpoint}: {error_response}")
123
- if expect_stream:
124
- async def json_stream_response() -> AsyncIterator[dict]:
125
- async for chunk in response.aiter_lines():
126
- chunk = chunk.strip()
127
- if not chunk:
128
- continue
129
- yield json.loads(chunk)
130
- async def bytes_stream_response() -> AsyncIterator[bytes]:
131
- async for chunk in response.aiter_bytes():
132
- yield chunk
133
- stream_response = json_stream_response if expect_json else bytes_stream_response
134
- return stream_response()
135
- else:
136
- if expect_json:
137
- return response.json()
138
- else:
139
- return response.content
140
18
 
141
-
142
- async def ping(self) -> SuccessResponse:
143
- result = await self.get("ping")
144
- return SuccessResponse(**result)
145
-
146
- async def update_config(self, config: Settings) -> SuccessResponse:
147
- result = await self.post(config.model_dump(), "providers/config", expect_json=True)
148
- self.settings.append(config)
149
- return SuccessResponse(**result)
150
-
151
- async def update_configs(self, configs: List[Settings]) -> SuccessResponse:
152
- for config in configs:
153
- await self.update_config(config)
154
- return SuccessResponse(success=True, message="Configs updated successfully")
155
-
156
- async def get_configs(self) -> List[Settings]:
157
- result = await self.get("providers/configs")
158
- return [Settings(**config) for config in result]
159
-
160
- async def delete_config(self, config_uid: str) -> SuccessResponse:
161
- result = await self.delete(f"providers/config/{config_uid}")
162
- return SuccessResponse(**result)
163
-
164
- async def cleanup(self):
165
- """
166
- Delete all the created settings resources and close the client.
167
- Should be called when you're done using the client.
168
- """
169
- for config in self.settings:
170
- config: Settings = config
171
- await self.delete_config(config.uid)
172
- await self.client.aclose()
173
-
174
- async def __aenter__(self):
175
- """Async context manager entry."""
176
- return self
177
-
178
- async def __aexit__(self, exc_type, exc_val, exc_tb):
179
- """Async context manager exit."""
180
- await self.cleanup()
181
-
182
- def __del__(self):
183
- """
184
- Destructor to clean up resources when the client is garbage collected.
185
- This will close the HTTP client and attempt to delete configs if cleanup wasn't called.
186
- Note: It's recommended to use the async context manager or call cleanup() explicitly.
187
- """
188
- # Warn user if cleanup wasn't called
189
- if self.settings:
190
- warnings.warn(
191
- "LivellmClient is being garbage collected without explicit cleanup. "
192
- "Provider configs may not be deleted from the server. "
193
- "Consider using 'async with' or calling 'await client.cleanup()' explicitly.",
194
- ResourceWarning,
195
- stacklevel=2
196
- )
197
-
198
- # Close the httpx client synchronously
199
- # httpx.AsyncClient stores a sync Transport that needs cleanup
200
- try:
201
- with httpx.Client(base_url=self.base_url) as client:
202
- for config in self.settings:
203
- config: Settings = config
204
- client.delete("providers/config/{config.uid}", headers=self.headers)
205
- except Exception:
206
- # Silently fail - we're in a destructor
207
- pass
19
+ class BaseLivellmClient(ABC):
208
20
 
209
21
  @overload
210
22
  async def agent_run(
@@ -225,6 +37,11 @@ class LivellmClient:
225
37
  ) -> AgentResponse:
226
38
  ...
227
39
 
40
+
41
+ @abstractmethod
42
+ async def handle_agent_run(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AgentResponse:
43
+ ...
44
+
228
45
  async def agent_run(
229
46
  self,
230
47
  request: Optional[Union[AgentRequest, AgentFallbackRequest]] = None,
@@ -269,9 +86,8 @@ class LivellmClient:
269
86
  raise TypeError(
270
87
  f"First positional argument must be AgentRequest or AgentFallbackRequest, got {type(request)}"
271
88
  )
272
- result = await self.post(request.model_dump(), "agent/run", expect_json=True)
273
- return AgentResponse(**result)
274
-
89
+ return await self.handle_agent_run(request)
90
+
275
91
  # Otherwise, use keyword arguments
276
92
  if provider_uid is None or model is None or messages is None:
277
93
  raise ValueError(
@@ -286,8 +102,7 @@ class LivellmClient:
286
102
  tools=tools or [],
287
103
  gen_config=kwargs or None
288
104
  )
289
- result = await self.post(agent_request.model_dump(), "agent/run", expect_json=True)
290
- return AgentResponse(**result)
105
+ return await self.handle_agent_run(agent_request)
291
106
 
292
107
  @overload
293
108
  def agent_run_stream(
@@ -308,6 +123,11 @@ class LivellmClient:
308
123
  ) -> AsyncIterator[AgentResponse]:
309
124
  ...
310
125
 
126
+
127
+ @abstractmethod
128
+ async def handle_agent_run_stream(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AsyncIterator[AgentResponse]:
129
+ ...
130
+
311
131
  async def agent_run_stream(
312
132
  self,
313
133
  request: Optional[Union[AgentRequest, AgentFallbackRequest]] = None,
@@ -355,9 +175,7 @@ class LivellmClient:
355
175
  raise TypeError(
356
176
  f"First positional argument must be AgentRequest or AgentFallbackRequest, got {type(request)}"
357
177
  )
358
- stream = await self.post(request.model_dump(), "agent/run_stream", expect_stream=True, expect_json=True)
359
- async for chunk in stream:
360
- yield AgentResponse(**chunk)
178
+ stream = self.handle_agent_run_stream(request)
361
179
  else:
362
180
  # Otherwise, use keyword arguments
363
181
  if provider_uid is None or model is None or messages is None:
@@ -373,9 +191,10 @@ class LivellmClient:
373
191
  tools=tools or [],
374
192
  gen_config=kwargs or None
375
193
  )
376
- stream = await self.post(agent_request.model_dump(), "agent/run_stream", expect_stream=True, expect_json=True)
377
- async for chunk in stream:
378
- yield AgentResponse(**chunk)
194
+ stream = self.handle_agent_run_stream(agent_request)
195
+
196
+ async for chunk in stream:
197
+ yield chunk
379
198
 
380
199
  @overload
381
200
  async def speak(
@@ -399,6 +218,11 @@ class LivellmClient:
399
218
  ) -> bytes:
400
219
  ...
401
220
 
221
+
222
+ @abstractmethod
223
+ async def handle_speak(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> bytes:
224
+ ...
225
+
402
226
  async def speak(
403
227
  self,
404
228
  request: Optional[Union[SpeakRequest, AudioFallbackRequest]] = None,
@@ -451,7 +275,7 @@ class LivellmClient:
451
275
  raise TypeError(
452
276
  f"First positional argument must be SpeakRequest or AudioFallbackRequest, got {type(request)}"
453
277
  )
454
- return await self.post(request.model_dump(), "audio/speak", expect_json=False)
278
+ return await self.handle_speak(request)
455
279
 
456
280
  # Otherwise, use keyword arguments
457
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:
@@ -470,7 +294,7 @@ class LivellmClient:
470
294
  chunk_size=chunk_size,
471
295
  gen_config=kwargs or None
472
296
  )
473
- return await self.post(speak_request.model_dump(), "audio/speak", expect_json=False)
297
+ return await self.handle_speak(speak_request)
474
298
 
475
299
  @overload
476
300
  def speak_stream(
@@ -494,6 +318,11 @@ class LivellmClient:
494
318
  ) -> AsyncIterator[bytes]:
495
319
  ...
496
320
 
321
+
322
+ @abstractmethod
323
+ async def handle_speak_stream(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> AsyncIterator[bytes]:
324
+ ...
325
+
497
326
  async def speak_stream(
498
327
  self,
499
328
  request: Optional[Union[SpeakRequest, AudioFallbackRequest]] = None,
@@ -549,9 +378,7 @@ class LivellmClient:
549
378
  raise TypeError(
550
379
  f"First positional argument must be SpeakRequest or AudioFallbackRequest, got {type(request)}"
551
380
  )
552
- speak_stream = await self.post(request.model_dump(), "audio/speak_stream", expect_stream=True, expect_json=False)
553
- async for chunk in speak_stream:
554
- yield chunk
381
+ speak_stream = self.handle_speak_stream(request)
555
382
  else:
556
383
  # Otherwise, use keyword arguments
557
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:
@@ -570,9 +397,9 @@ class LivellmClient:
570
397
  chunk_size=chunk_size,
571
398
  gen_config=kwargs or None
572
399
  )
573
- speak_stream = await self.post(speak_request.model_dump(), "audio/speak_stream", expect_stream=True, expect_json=False)
574
- async for chunk in speak_stream:
575
- yield chunk
400
+ speak_stream = self.handle_speak_stream(speak_request)
401
+ async for chunk in speak_stream:
402
+ yield chunk
576
403
 
577
404
  @overload
578
405
  async def transcribe(
@@ -593,6 +420,11 @@ class LivellmClient:
593
420
  ) -> TranscribeResponse:
594
421
  ...
595
422
 
423
+
424
+ @abstractmethod
425
+ async def handle_transcribe(self, request: Union[TranscribeRequest, TranscribeFallbackRequest]) -> TranscribeResponse:
426
+ ...
427
+
596
428
  async def transcribe(
597
429
  self,
598
430
  request: Optional[Union[TranscribeRequest, TranscribeFallbackRequest]] = None,
@@ -636,9 +468,8 @@ class LivellmClient:
636
468
  f"First positional argument must be TranscribeRequest or TranscribeFallbackRequest, got {type(request)}"
637
469
  )
638
470
  # JSON-based request
639
- result = await self.post(request.model_dump(), "audio/transcribe_json", expect_json=True)
640
- return TranscribeResponse(**result)
641
-
471
+ return await self.handle_transcribe(request)
472
+
642
473
  # Otherwise, use keyword arguments with multipart form-data request
643
474
  if provider_uid is None or file is None or model is None:
644
475
  raise ValueError(
@@ -646,18 +477,394 @@ class LivellmClient:
646
477
  "Alternatively, pass a TranscribeRequest object as the first positional argument."
647
478
  )
648
479
 
649
- files = {
650
- "file": file
651
- }
652
- data = {
653
- "provider_uid": provider_uid,
654
- "model": model,
655
- "language": language,
656
- "gen_config": json.dumps(kwargs) if kwargs else None
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
+ max_size: Optional[int] = None
498
+ ):
499
+ # Convert HTTP(S) URL to WS(S) URL
500
+ base_url = base_url.rstrip("/")
501
+ if base_url.startswith("https://"):
502
+ ws_url = base_url.replace("https://", "wss://")
503
+ elif base_url.startswith("http://"):
504
+ ws_url = base_url.replace("http://", "ws://")
505
+ else:
506
+ ws_url = base_url
507
+
508
+ # Root WebSocket base URL (without path) and main /ws endpoint
509
+ self._ws_root_base_url = ws_url
510
+ self.base_url = f"{ws_url}/livellm/ws"
511
+ self.timeout = timeout
512
+ self.websocket = None
513
+ # Lazily-created clients
514
+ self._transcription = None
515
+ self.max_size = max_size or 1024 * 1024 * 10 # 10MB is default max size
516
+
517
+ async def connect(self):
518
+ """Establish WebSocket connection."""
519
+ if self.websocket is not None:
520
+ return self.websocket
521
+
522
+ self.websocket = await websockets.connect(
523
+ self.base_url,
524
+ open_timeout=self.timeout,
525
+ close_timeout=self.timeout,
526
+ max_size=self.max_size
527
+ )
528
+
529
+ return self.websocket
530
+
531
+ async def disconnect(self):
532
+ """Close WebSocket connection."""
533
+ if self.websocket is not None:
534
+ await self.websocket.close()
535
+ self.websocket = None
536
+
537
+ async def get_response(self, action: WsAction, payload: dict) -> WsResponse:
538
+ """Send a request and wait for response."""
539
+ if self.websocket is None:
540
+ await self.connect()
541
+
542
+ request = WsRequest(action=action, payload=payload)
543
+ await self.websocket.send(json.dumps(request.model_dump()))
544
+
545
+ response_data = await self.websocket.recv()
546
+ response = WsResponse(**json.loads(response_data))
547
+
548
+ if response.status == WsStatus.ERROR:
549
+ raise Exception(f"WebSocket request failed: {response.error}")
550
+
551
+ return response
552
+
553
+ async def get_response_stream(self, action: WsAction, payload: dict) -> AsyncIterator[WsResponse]:
554
+ """Send a request and stream responses."""
555
+ if self.websocket is None:
556
+ await self.connect()
557
+
558
+ request = WsRequest(action=action, payload=payload)
559
+ await self.websocket.send(json.dumps(request.model_dump()))
560
+
561
+ while True:
562
+ response_data = await self.websocket.recv()
563
+ response = WsResponse(**json.loads(response_data))
564
+
565
+ if response.status == WsStatus.ERROR:
566
+ raise Exception(f"WebSocket stream failed: {response.error}")
567
+
568
+ yield response
569
+
570
+ if response.status == WsStatus.SUCCESS:
571
+ break
572
+
573
+ # Implement abstract methods from BaseLivellmClient
574
+
575
+ async def handle_agent_run(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AgentResponse:
576
+ """Handle agent run via WebSocket."""
577
+ response = await self.get_response(
578
+ WsAction.AGENT_RUN,
579
+ request.model_dump()
580
+ )
581
+ return AgentResponse(**response.data)
582
+
583
+ async def handle_agent_run_stream(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AsyncIterator[AgentResponse]:
584
+ """Handle streaming agent run via WebSocket."""
585
+ async for response in self.get_response_stream(WsAction.AGENT_RUN_STREAM, request.model_dump()):
586
+ yield AgentResponse(**response.data)
587
+
588
+ async def handle_speak(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> EncodedSpeakResponse:
589
+ """Handle speak request via WebSocket."""
590
+ response = await self.get_response(
591
+ WsAction.AUDIO_SPEAK,
592
+ request.model_dump()
593
+ )
594
+ return EncodedSpeakResponse(**response.data)
595
+
596
+ async def handle_speak_stream(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> AsyncIterator[EncodedSpeakResponse]:
597
+ """Handle streaming speak request via WebSocket."""
598
+ async for response in self.get_response_stream(WsAction.AUDIO_SPEAK_STREAM, request.model_dump()):
599
+ yield EncodedSpeakResponse(**response.data)
600
+
601
+ async def handle_transcribe(self, request: Union[TranscribeRequest, TranscribeFallbackRequest]) -> TranscribeResponse:
602
+ """Handle transcribe request via WebSocket."""
603
+ response = await self.get_response(
604
+ WsAction.AUDIO_TRANSCRIBE,
605
+ request.model_dump()
606
+ )
607
+ return TranscribeResponse(**response.data)
608
+
609
+ # Context manager support
610
+
611
+ async def __aenter__(self):
612
+ await self.connect()
613
+ return self
614
+
615
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
616
+ await self.disconnect()
617
+
618
+ @property
619
+ def transcription(self) -> TranscriptionWsClient:
620
+ """
621
+ Lazily-initialized WebSocket transcription client that shares the same
622
+ server base URL and timeout as this realtime client.
623
+ """
624
+ if self._transcription is None:
625
+ # Use the ws root (e.g. ws://host:port) and let TranscriptionWsClient
626
+ # append its own /livellm/ws/transcription path.
627
+ self._transcription = TranscriptionWsClient(
628
+ base_url=self._ws_root_base_url,
629
+ timeout=self.timeout,
630
+ )
631
+ return self._transcription
632
+
633
+ class LivellmClient(BaseLivellmClient):
634
+ """HTTP-based LiveLLM client for request-response communication."""
635
+
636
+ def __init__(
637
+ self,
638
+ base_url: str,
639
+ timeout: Optional[float] = None,
640
+ configs: Optional[List[Settings]] = None
641
+ ):
642
+ # Root server URL (http/https, without trailing slash)
643
+ self._root_base_url = base_url.rstrip("/")
644
+ # HTTP API base URL for this client
645
+ self.base_url = f"{self._root_base_url}/livellm"
646
+ self.timeout = timeout
647
+ self.client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) \
648
+ if self.timeout else httpx.AsyncClient(base_url=self.base_url)
649
+ self.settings = []
650
+ self.headers = {
651
+ "Content-Type": "application/json",
657
652
  }
658
- result = await self.post_multipart(files, data, "audio/transcribe")
659
- return TranscribeResponse(**result)
660
-
653
+ # Lazily-created realtime (WebSocket) client
654
+ self._realtime = None
655
+ if configs:
656
+ self.update_configs_post_init(configs)
661
657
 
658
+ @property
659
+ def realtime(self) -> LivellmWsClient:
660
+ """
661
+ Lazily-initialized WebSocket client for realtime operations (agent, audio, etc.)
662
+ that shares the same server base URL and timeout as this HTTP client.
663
+
664
+ Example:
665
+ client = LivellmClient(base_url=\"http://localhost:8000\")
666
+ async with client.realtime as session:
667
+ response = await session.agent_run(...)
668
+ """
669
+ if self._realtime is None:
670
+ # Pass the same root base URL; LivellmWsClient will handle ws/wss conversion.
671
+ self._realtime = LivellmWsClient(self._root_base_url, timeout=self.timeout)
672
+ return self._realtime
673
+
674
+ def update_configs_post_init(self, configs: List[Settings]) -> SuccessResponse:
675
+ """
676
+ Update the configs after the client is initialized.
677
+ Args:
678
+ configs: The configs to update.
679
+ """
680
+ with httpx.Client(base_url=self.base_url, timeout=self.timeout) as client:
681
+ for config in configs:
682
+ response = client.post(f"{self.base_url}/providers/config", json=config.model_dump())
683
+ response.raise_for_status()
684
+ self.settings.append(config)
685
+ return SuccessResponse(success=True, message="Configs updated successfully")
686
+
687
+
688
+ async def delete(self, endpoint: str) -> dict:
689
+ """
690
+ Delete a resource from the given endpoint and return the response.
691
+ Args:
692
+ endpoint: The endpoint to delete from.
693
+ Returns:
694
+ The response from the endpoint.
695
+ """
696
+ response = await self.client.delete(endpoint, headers=self.headers)
697
+ response.raise_for_status()
698
+ return response.json()
699
+
700
+ async def post_multipart(
701
+ self,
702
+ files: dict,
703
+ data: dict,
704
+ endpoint: str
705
+ ) -> dict:
706
+ """
707
+ Post a multipart request to the given endpoint and return the response.
708
+ Args:
709
+ files: The files to send in the request.
710
+ data: The data to send in the request.
711
+ endpoint: The endpoint to post to.
712
+ Returns:
713
+ The response from the endpoint.
714
+ """
715
+ # Don't pass Content-Type header for multipart - httpx will set it automatically
716
+ response = await self.client.post(endpoint, files=files, data=data)
717
+ response.raise_for_status()
718
+ return response.json()
719
+
720
+
721
+ async def get(
722
+ self,
723
+ endpoint: str
724
+ ) -> dict:
725
+ """
726
+ Get a request from the given endpoint and return the response.
727
+ Args:
728
+ endpoint: The endpoint to get from.
729
+ Returns:
730
+ The response from the endpoint.
731
+ """
732
+ response = await self.client.get(endpoint, headers=self.headers)
733
+ response.raise_for_status()
734
+ return response.json()
735
+
736
+ async def post(
737
+ self,
738
+ json_data: dict,
739
+ endpoint: str,
740
+ expect_stream: bool = False,
741
+ expect_json: bool = True
742
+ ) -> Union[dict, bytes, AsyncIterator[Union[dict, bytes]]]:
743
+ """
744
+ Post a request to the given endpoint and return the response.
745
+ If expect_stream is True, return an AsyncIterator of the response.
746
+ If expect_json is True, return the response as a JSON object.
747
+ Otherwise, return the response as bytes.
748
+ Args:
749
+ json_data: The JSON data to send in the request.
750
+ endpoint: The endpoint to post to.
751
+ expect_stream: Whether to expect a stream response.
752
+ expect_json: Whether to expect a JSON response.
753
+ Returns:
754
+ The response from the endpoint.
755
+ Raises:
756
+ Exception: If the response is not 200 or 201.
757
+ """
758
+ response = await self.client.post(endpoint, json=json_data, headers=self.headers)
759
+ if response.status_code not in [200, 201]:
760
+ error_response = await response.aread()
761
+ error_response = error_response.decode("utf-8")
762
+ raise Exception(f"Failed to post to {endpoint}: {error_response}")
763
+ if expect_stream:
764
+ async def json_stream_response() -> AsyncIterator[dict]:
765
+ async for chunk in response.aiter_lines():
766
+ chunk = chunk.strip()
767
+ if not chunk:
768
+ continue
769
+ yield json.loads(chunk)
770
+ async def bytes_stream_response() -> AsyncIterator[bytes]:
771
+ async for chunk in response.aiter_bytes():
772
+ yield chunk
773
+ stream_response = json_stream_response if expect_json else bytes_stream_response
774
+ return stream_response()
775
+ else:
776
+ if expect_json:
777
+ return response.json()
778
+ else:
779
+ return response.content
780
+
781
+ async def ping(self) -> SuccessResponse:
782
+ result = await self.get("ping")
783
+ return SuccessResponse(**result)
784
+
785
+ async def update_config(self, config: Settings) -> SuccessResponse:
786
+ result = await self.post(config.model_dump(), "providers/config", expect_json=True)
787
+ self.settings.append(config)
788
+ return SuccessResponse(**result)
789
+
790
+ async def update_configs(self, configs: List[Settings]) -> SuccessResponse:
791
+ for config in configs:
792
+ await self.update_config(config)
793
+ return SuccessResponse(success=True, message="Configs updated successfully")
794
+
795
+ async def get_configs(self) -> List[Settings]:
796
+ result = await self.get("providers/configs")
797
+ return [Settings(**config) for config in result]
798
+
799
+ async def delete_config(self, config_uid: str) -> SuccessResponse:
800
+ result = await self.delete(f"providers/config/{config_uid}")
801
+ return SuccessResponse(**result)
802
+
803
+ async def cleanup(self):
804
+ """
805
+ Delete all the created settings resources and close the client.
806
+ Should be called when you're done using the client.
807
+ """
808
+ for config in self.settings:
809
+ config: Settings = config
810
+ await self.delete_config(config.uid)
811
+ await self.client.aclose()
812
+ # Also close any realtime WebSocket client if it was created
813
+ if self._realtime is not None:
814
+ await self._realtime.disconnect()
815
+
816
+ def __del__(self):
817
+ """
818
+ Destructor to clean up resources when the client is garbage collected.
819
+ This will close the HTTP client and attempt to delete configs if cleanup wasn't called.
820
+ Note: It's recommended to use the async context manager or call cleanup() explicitly.
821
+ """
822
+ # Warn user if cleanup wasn't called
823
+ if self.settings:
824
+ warnings.warn(
825
+ "LivellmClient is being garbage collected without explicit cleanup. "
826
+ "Provider configs may not be deleted from the server. "
827
+ "Consider using 'async with' or calling 'await client.cleanup()' explicitly.",
828
+ ResourceWarning,
829
+ stacklevel=2
830
+ )
662
831
 
832
+ # Close the httpx client synchronously
833
+ # httpx.AsyncClient stores a sync Transport that needs cleanup
834
+ try:
835
+ with httpx.Client(base_url=self.base_url) as client:
836
+ for config in self.settings:
837
+ config: Settings = config
838
+ client.delete("providers/config/{config.uid}", headers=self.headers)
839
+ except Exception:
840
+ # Silently fail - we're in a destructor
841
+ pass
842
+
843
+ # Implement abstract methods from BaseLivellmClient
844
+
845
+ async def handle_agent_run(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AgentResponse:
846
+ """Handle agent run via HTTP."""
847
+ result = await self.post(request.model_dump(), "agent/run", expect_json=True)
848
+ return AgentResponse(**result)
849
+
850
+ async def handle_agent_run_stream(self, request: Union[AgentRequest, AgentFallbackRequest]) -> AsyncIterator[AgentResponse]:
851
+ """Handle streaming agent run via HTTP."""
852
+ stream = await self.post(request.model_dump(), "agent/run_stream", expect_stream=True, expect_json=True)
853
+ async for chunk in stream:
854
+ yield AgentResponse(**chunk)
855
+
856
+ async def handle_speak(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> bytes:
857
+ """Handle speak request via HTTP."""
858
+ return await self.post(request.model_dump(), "audio/speak", expect_json=False)
859
+
860
+ async def handle_speak_stream(self, request: Union[SpeakRequest, AudioFallbackRequest]) -> AsyncIterator[bytes]:
861
+ """Handle streaming speak request via HTTP."""
862
+ speak_stream = await self.post(request.model_dump(), "audio/speak_stream", expect_stream=True, expect_json=False)
863
+ async for chunk in speak_stream:
864
+ yield chunk
865
+
866
+ async def handle_transcribe(self, request: Union[TranscribeRequest, TranscribeFallbackRequest]) -> TranscribeResponse:
867
+ """Handle transcribe request via HTTP."""
868
+ result = await self.post(request.model_dump(), "audio/transcribe_json", expect_json=True)
869
+ return TranscribeResponse(**result)
663
870
 
@@ -5,6 +5,7 @@ from .agent.chat import Message, MessageRole, TextMessage, BinaryMessage
5
5
  from .agent.tools import Tool, ToolInput, ToolKind, WebSearchInput, MCPStreamableServerInput
6
6
  from .audio.speak import SpeakMimeType, SpeakRequest, SpeakStreamResponse
7
7
  from .audio.transcribe import TranscribeRequest, TranscribeResponse, File
8
+ from .transcription import TranscriptionInitWsRequest, TranscriptionAudioChunkWsRequest, TranscriptionWsResponse
8
9
 
9
10
 
10
11
  __all__ = [
@@ -38,4 +39,8 @@ __all__ = [
38
39
  "TranscribeRequest",
39
40
  "TranscribeResponse",
40
41
  "File",
42
+ # Real-time Transcription
43
+ "TranscriptionInitWsRequest",
44
+ "TranscriptionAudioChunkWsRequest",
45
+ "TranscriptionWsResponse",
41
46
  ]
@@ -2,6 +2,7 @@ from pydantic import BaseModel, Field, field_validator
2
2
  from typing import Optional, TypeAlias, Tuple, AsyncIterator
3
3
  from enum import Enum
4
4
  from ..common import BaseRequest
5
+ import base64
5
6
 
6
7
  SpeakStreamResponse: TypeAlias = Tuple[AsyncIterator[bytes], str, int]
7
8
 
@@ -21,3 +22,23 @@ class SpeakRequest(BaseRequest):
21
22
  sample_rate: int = Field(..., description="The target sample rate of the output audio")
22
23
  chunk_size: int = Field(default=20, description="Chunk size in milliseconds for streaming (default: 20ms)")
23
24
  gen_config: Optional[dict] = Field(default=None, description="The configuration for the generation")
25
+
26
+ class EncodedSpeakResponse(BaseModel):
27
+ audio: bytes | str = Field(..., description="The audio data as a base64 encoded string")
28
+ content_type: SpeakMimeType = Field(..., description="The content type of the audio")
29
+ sample_rate: int = Field(..., description="The sample rate of the audio")
30
+
31
+ @field_validator("audio", mode="after")
32
+ @classmethod
33
+ def validate_audio(cls, v: bytes | str) -> bytes:
34
+ """
35
+ Ensure that `audio` is always returned as raw bytes.
36
+
37
+ - If the server returns a base64-encoded *string*, decode it.
38
+ - If the server already returned raw bytes, pass them through.
39
+ """
40
+ if isinstance(v, str):
41
+ # Server sent base64-encoded string → decode to raw bytes
42
+ return base64.b64decode(v)
43
+ # Already bytes → assume it's raw audio
44
+ return v
@@ -0,0 +1,32 @@
1
+ from pydantic import BaseModel, Field, field_validator
2
+ from livellm.models.audio.speak import SpeakMimeType
3
+ import base64
4
+
5
+ class TranscriptionInitWsRequest(BaseModel):
6
+ provider_uid: str = Field(..., description="The provider uid")
7
+ model: str = Field(..., description="The model")
8
+ language: str = Field(default="auto", description="The language")
9
+ input_sample_rate: int = Field(default=24000, description="The input sample rate")
10
+ input_audio_format: SpeakMimeType = Field(default=SpeakMimeType.PCM, description="The input audio format (pcm, ulaw, alaw)")
11
+ gen_config: dict = Field(default={}, description="The generation configuration")
12
+
13
+
14
+ class TranscriptionAudioChunkWsRequest(BaseModel):
15
+ audio: str = Field(..., description="The audio (base64 encoded)")
16
+
17
+ @field_validator('audio', mode='before')
18
+ @classmethod
19
+ def validate_audio(cls, v: str | bytes) -> str:
20
+ """
21
+ encode audio to base64 string if needed
22
+ """
23
+ if isinstance(v, bytes):
24
+ return base64.b64encode(v).decode("utf-8")
25
+ elif isinstance(v, str):
26
+ return v # already base64 encoded
27
+ else:
28
+ raise ValueError(f"Invalid audio type: {type(v)}")
29
+
30
+ class TranscriptionWsResponse(BaseModel):
31
+ transcription: str = Field(..., description="The transcription")
32
+ is_end: bool = Field(..., description="Whether the response is the end of the transcription")
livellm/models/ws.py ADDED
@@ -0,0 +1,28 @@
1
+ from pydantic import BaseModel, Field
2
+ from enum import Enum
3
+ from typing import Union, Optional
4
+
5
+ class WsAction(str, Enum):
6
+ AGENT_RUN = "agent_run"
7
+ AGENT_RUN_STREAM = "agent_run_stream"
8
+ AUDIO_SPEAK = "audio_speak"
9
+ AUDIO_SPEAK_STREAM = "audio_speak_stream"
10
+ AUDIO_TRANSCRIBE = "audio_transcribe"
11
+ TRANSCRIPTION_SESSION = "transcription_session"
12
+
13
+
14
+ class WsStatus(str, Enum):
15
+ STREAMING = "streaming"
16
+ SUCCESS = "success"
17
+ ERROR = "error"
18
+
19
+ class WsRequest(BaseModel):
20
+ action: WsAction = Field(..., description="The action to perform")
21
+ payload: Union[dict, BaseModel] = Field(..., description="The payload for the action")
22
+
23
+
24
+ class WsResponse(BaseModel):
25
+ status: WsStatus = Field(..., description="The status of the response")
26
+ action: WsAction = Field(..., description="The action that was performed")
27
+ data: Union[dict, BaseModel] = Field(..., description="The data for the response")
28
+ error: Optional[str] = Field(default=None, description="The error message if the response is an error")
@@ -0,0 +1,116 @@
1
+ from livellm.models.transcription import (
2
+ TranscriptionInitWsRequest,
3
+ TranscriptionAudioChunkWsRequest,
4
+ TranscriptionWsResponse)
5
+ from livellm.models.ws import WsResponse, WsStatus
6
+ from typing import Optional, AsyncIterator
7
+ import websockets
8
+ import asyncio
9
+ import json
10
+
11
+
12
+ class TranscriptionWsClient:
13
+ def __init__(self, base_url: str, timeout: Optional[float] = None, max_size: Optional[int] = None):
14
+ self.base_url = base_url.rstrip("/")
15
+ self.url = f"{base_url}/livellm/ws/transcription"
16
+ self.timeout = timeout
17
+ self.websocket = None
18
+ self.max_size = max_size or 1024 * 1024 * 10 # 10MB is default max size
19
+
20
+ async def connect(self):
21
+ """
22
+ Connect to the transcription websocket server.
23
+ """
24
+ self.websocket = await websockets.connect(
25
+ self.url,
26
+ open_timeout=self.timeout,
27
+ close_timeout=self.timeout,
28
+ max_size=self.max_size
29
+ )
30
+
31
+ async def disconnect(self):
32
+ """
33
+ Disconnect from the transcription websocket server.
34
+ """
35
+ if self.websocket is not None:
36
+ await self.websocket.close()
37
+ self.websocket = None
38
+
39
+ async def __aenter__(self):
40
+ await self.connect()
41
+ return self
42
+
43
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
44
+ await self.disconnect()
45
+
46
+ async def start_session(
47
+ self,
48
+ request: TranscriptionInitWsRequest,
49
+ source: AsyncIterator[TranscriptionAudioChunkWsRequest]
50
+ ) -> AsyncIterator[TranscriptionWsResponse]:
51
+ """
52
+ Start a transcription session.
53
+
54
+ Args:
55
+ request: The initialization request for the transcription session.
56
+ source: An async iterator that yields audio chunks to transcribe.
57
+
58
+ Returns:
59
+ An async iterator of transcription session responses.
60
+
61
+ Example:
62
+ ```python
63
+ async def audio_source():
64
+ with open("audio.pcm", "rb") as f:
65
+ while chunk := f.read(4096):
66
+ yield TranscriptionAudioChunkWsRequest(audio=chunk)
67
+
68
+ async with TranscriptionWsClient(url) as client:
69
+ async for response in client.start_session(init_request, audio_source()):
70
+ print(response.transcription)
71
+ if response.is_end:
72
+ break
73
+ ```
74
+ """
75
+ # Send initialization request
76
+ await self.websocket.send(request.model_dump_json())
77
+
78
+ # Wait for initialization response
79
+ response_data = await self.websocket.recv()
80
+ response = WsResponse(**json.loads(response_data))
81
+ if response.status == WsStatus.ERROR:
82
+ raise Exception(f"Failed to start transcription session: {response.error}")
83
+
84
+ # Start sending audio chunks in background
85
+ async def send_chunks():
86
+ try:
87
+ async for chunk in source:
88
+ await self.websocket.send(chunk.model_dump_json())
89
+ except Exception as e:
90
+ # If there's an error sending chunks, close the websocket
91
+ print(f"Error sending chunks: {e}")
92
+ await self.websocket.close()
93
+ raise e
94
+
95
+ send_task = asyncio.create_task(send_chunks())
96
+
97
+ # Receive transcription responses
98
+ try:
99
+ while not send_task.done():
100
+ response_data = await self.websocket.recv()
101
+ transcription_response = TranscriptionWsResponse(**json.loads(response_data))
102
+ yield transcription_response
103
+
104
+ # Stop if we received the final transcription
105
+ if transcription_response.is_end:
106
+ break
107
+ except websockets.ConnectionClosed:
108
+ pass
109
+ finally:
110
+ # Cancel the send task if still running
111
+ if not send_task.done():
112
+ send_task.cancel()
113
+ try:
114
+ await send_task
115
+ except asyncio.CancelledError:
116
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: livellm
3
- Version: 1.2.0
3
+ Version: 1.3.5
4
4
  Summary: Python client for the LiveLLM Server
5
5
  Project-URL: Homepage, https://github.com/qalby-tech/livellm-client-py
6
6
  Project-URL: Repository, https://github.com/qalby-tech/livellm-client-py
@@ -17,6 +17,7 @@ Classifier: Typing :: Typed
17
17
  Requires-Python: >=3.10
18
18
  Requires-Dist: httpx>=0.27.0
19
19
  Requires-Dist: pydantic>=2.0.0
20
+ Requires-Dist: websockets>=15.0.1
20
21
  Provides-Extra: testing
21
22
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'testing'
22
23
  Requires-Dist: pytest-cov>=4.1.0; extra == 'testing'
@@ -32,12 +33,13 @@ Python client library for the LiveLLM Server - a unified proxy for AI agent, aud
32
33
 
33
34
  ## Features
34
35
 
35
- - 🚀 **Async-first** - Built on httpx for high-performance operations
36
+ - 🚀 **Async-first** - Built on httpx and websockets for high-performance operations
36
37
  - 🔒 **Type-safe** - Full type hints and Pydantic validation
37
38
  - 🎯 **Multi-provider** - OpenAI, Google, Anthropic, Groq, ElevenLabs
38
39
  - 🔄 **Streaming** - Real-time streaming for agent and audio
39
40
  - 🛠️ **Flexible API** - Use request objects or keyword arguments
40
41
  - 🎙️ **Audio services** - Text-to-speech and transcription
42
+ - 🎤 **Real-Time Transcription** - WebSocket-based live audio transcription with bidirectional streaming
41
43
  - ⚡ **Fallback strategies** - Sequential and parallel handling
42
44
  - 🧹 **Auto cleanup** - Context managers and garbage collection
43
45
 
@@ -326,6 +328,123 @@ transcription = await client.transcribe(
326
328
  )
327
329
  ```
328
330
 
331
+ ### Real-Time Transcription (WebSocket)
332
+
333
+ The realtime transcription API is available either **directly** via `TranscriptionWsClient` or **through** `LivellmClient.realtime.transcription`.
334
+
335
+ #### Using `TranscriptionWsClient` directly
336
+
337
+ ```python
338
+ import asyncio
339
+ from livellm import TranscriptionWsClient
340
+ from livellm.models import (
341
+ TranscriptionInitWsRequest,
342
+ TranscriptionAudioChunkWsRequest,
343
+ SpeakMimeType,
344
+ )
345
+
346
+ async def transcribe_live_direct():
347
+ base_url = "ws://localhost:8000" # WebSocket base URL
348
+
349
+ async with TranscriptionWsClient(base_url, timeout=30) as client:
350
+ # Define audio source (file, microphone, stream, etc.)
351
+ async def audio_source():
352
+ with open("audio.pcm", "rb") as f:
353
+ while chunk := f.read(4096):
354
+ yield TranscriptionAudioChunkWsRequest(audio=chunk)
355
+ await asyncio.sleep(0.1) # Simulate real-time
356
+
357
+ # Initialize transcription session
358
+ init_request = TranscriptionInitWsRequest(
359
+ provider_uid="openai",
360
+ model="gpt-4o-mini-transcribe",
361
+ language="en", # or "auto" for detection
362
+ input_sample_rate=24000,
363
+ input_audio_format=SpeakMimeType.PCM,
364
+ gen_config={},
365
+ )
366
+
367
+ # Stream audio and receive transcriptions
368
+ async for response in client.start_session(init_request, audio_source()):
369
+ print(f"Transcription: {response.transcription}")
370
+ if response.is_end:
371
+ print("Transcription complete!")
372
+ break
373
+
374
+ asyncio.run(transcribe_live_direct())
375
+ ```
376
+
377
+ #### Using `LivellmClient.realtime.transcription` (and running agents while listening)
378
+
379
+ ```python
380
+ import asyncio
381
+ from livellm import LivellmClient
382
+ from livellm.models import (
383
+ TextMessage,
384
+ TranscriptionInitWsRequest,
385
+ TranscriptionAudioChunkWsRequest,
386
+ SpeakMimeType,
387
+ )
388
+
389
+ async def transcribe_and_chat():
390
+ # Central HTTP client; .realtime and .transcription expose WebSocket APIs
391
+ client = LivellmClient(base_url="http://localhost:8000", timeout=30)
392
+
393
+ async with client.realtime as realtime:
394
+ async with realtime.transcription as t_client:
395
+ async def audio_source():
396
+ with open("audio.pcm", "rb") as f:
397
+ while chunk := f.read(4096):
398
+ yield TranscriptionAudioChunkWsRequest(audio=chunk)
399
+ await asyncio.sleep(0.1)
400
+
401
+ init_request = TranscriptionInitWsRequest(
402
+ provider_uid="openai",
403
+ model="gpt-4o-mini-transcribe",
404
+ language="en",
405
+ input_sample_rate=24000,
406
+ input_audio_format=SpeakMimeType.PCM,
407
+ gen_config={},
408
+ )
409
+
410
+ # Listen for transcriptions and, for each chunk, run an agent request
411
+ async for resp in t_client.start_session(init_request, audio_source()):
412
+ print("User said:", resp.transcription)
413
+
414
+ # You can call agent_run (or speak, etc.) while the transcription stream is active
415
+ agent_response = await realtime.agent_run(
416
+ provider_uid="openai",
417
+ model="gpt-4",
418
+ messages=[
419
+ TextMessage(role="user", content=resp.transcription),
420
+ ],
421
+ temperature=0.7,
422
+ )
423
+ print("Agent:", agent_response.output)
424
+
425
+ if resp.is_end:
426
+ print("Transcription session complete")
427
+ break
428
+
429
+ asyncio.run(transcribe_and_chat())
430
+ ```
431
+
432
+ **Supported Audio Formats:**
433
+ - **PCM**: 16-bit uncompressed (recommended)
434
+ - **μ-law**: 8-bit telephony format (North America/Japan)
435
+ - **A-law**: 8-bit telephony format (Europe/rest of world)
436
+
437
+ **Use Cases:**
438
+ - 🎙️ Voice assistants and chatbots
439
+ - 📝 Live captioning and subtitles
440
+ - 🎤 Meeting transcription
441
+ - 🗣️ Voice commands and control
442
+
443
+ **See also:**
444
+ - [TRANSCRIPTION_CLIENT.md](TRANSCRIPTION_CLIENT.md) - Complete transcription guide
445
+ - [example_transcription.py](example_transcription.py) - Python examples
446
+ - [example_transcription_browser.html](example_transcription_browser.html) - Browser demo
447
+
329
448
  ### Fallback Strategies
330
449
 
331
450
  Handle failures automatically with sequential or parallel fallback:
@@ -418,6 +537,12 @@ response = await client.ping()
418
537
  - `speak_stream(request | **kwargs)` - Text-to-speech (streaming)
419
538
  - `transcribe(request | **kwargs)` - Speech-to-text
420
539
 
540
+ **Real-Time Transcription (TranscriptionWsClient)**
541
+ - `connect()` - Establish WebSocket connection
542
+ - `disconnect()` - Close WebSocket connection
543
+ - `start_session(init_request, audio_source)` - Start bidirectional streaming transcription
544
+ - `async with client:` - Auto connection management (recommended)
545
+
421
546
  **Cleanup**
422
547
  - `cleanup()` - Release resources
423
548
  - `async with client:` - Auto cleanup (recommended)
@@ -437,6 +562,8 @@ response = await client.ping()
437
562
  - `AgentRequest(provider_uid, model, messages, tools?, gen_config?)`
438
563
  - `SpeakRequest(provider_uid, model, text, voice, mime_type, sample_rate, gen_config?)`
439
564
  - `TranscribeRequest(provider_uid, file, model, language?, gen_config?)`
565
+ - `TranscriptionInitWsRequest(provider_uid, model, language?, input_sample_rate?, input_audio_format?, gen_config?)`
566
+ - `TranscriptionAudioChunkWsRequest(audio)` - Audio chunk for streaming
440
567
 
441
568
  **Tools**
442
569
  - `WebSearchInput(kind=ToolKind.WEB_SEARCH, search_context_size)`
@@ -450,6 +577,7 @@ response = await client.ping()
450
577
  **Responses**
451
578
  - `AgentResponse(output, usage{input_tokens, output_tokens}, ...)`
452
579
  - `TranscribeResponse(text, language)`
580
+ - `TranscriptionWsResponse(transcription, is_end)` - Real-time transcription result
453
581
 
454
582
  ## Error Handling
455
583
 
@@ -486,6 +614,15 @@ mypy livellm
486
614
  - Python 3.10+
487
615
  - httpx >= 0.27.0
488
616
  - pydantic >= 2.0.0
617
+ - websockets >= 15.0.1
618
+
619
+ ## Documentation
620
+
621
+ - [README.md](README.md) - Main documentation (you are here)
622
+ - [TRANSCRIPTION_CLIENT.md](TRANSCRIPTION_CLIENT.md) - Complete real-time transcription guide
623
+ - [CLIENT_EXAMPLES.md](CLIENT_EXAMPLES.md) - Usage examples for all features
624
+ - [example_transcription.py](example_transcription.py) - Python transcription examples
625
+ - [example_transcription_browser.html](example_transcription_browser.html) - Browser demo
489
626
 
490
627
  ## Links
491
628
 
@@ -0,0 +1,20 @@
1
+ livellm/__init__.py,sha256=p2Szx7PELGYi-PTnSNnRPGVbU438ZBTFXYAQoMToUfE,440
2
+ livellm/livellm.py,sha256=I-XloiBFxR41Xvd7UCzg5ipztcrfT6EVyt8KAni-rgo,32291
3
+ livellm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ livellm/transcripton.py,sha256=2ttPzc8A6uQL5VouLLb4aj7Q-cMCx3V1HXH2qhpUMfM,4195
5
+ livellm/models/__init__.py,sha256=tCX_Q3ALjO0jbqhLFsVoITSm1AV--3pF5ZZc_l0VC1o,1447
6
+ livellm/models/common.py,sha256=YqRwP6ChWbRdoen4MU6RO4u6HeM0mQJbXiiRV4DuauM,1740
7
+ livellm/models/fallback.py,sha256=zGG_MjdbaTx0fqKZTEg3ullej-CJznPfwaon0jEvRvI,1170
8
+ livellm/models/transcription.py,sha256=fl2iiD2ET_KbsB2hUyruUjkZEvIRZ-cD22MIMewtYRA,1423
9
+ livellm/models/ws.py,sha256=Ij1gCPzr86XW7mwo6tT99ImdcXCYn6jkvnNPLvimYhU,1037
10
+ livellm/models/agent/__init__.py,sha256=KVm6AgQoWEaoq47QAG4Ou4NimoXOTkjXC-0-gnMRLZ8,476
11
+ livellm/models/agent/agent.py,sha256=-UcGv5Bzw5ALmWX4lIqpbWqMVjCsjBc0KIE6_JKbCXM,1106
12
+ livellm/models/agent/chat.py,sha256=zGfeEHx0luwq23pqWF1megcuEDUl6IhV4keLJeZry_A,1028
13
+ livellm/models/agent/tools.py,sha256=wVWfx6_jxL3IcmX_Nt_PonZ3RQLtpfqJnszHz32BQiU,1403
14
+ livellm/models/audio/__init__.py,sha256=sz2NxCOfFGVvp-XQUsdgOR_TYBO1Wb-8LLXaZDEiAZk,282
15
+ livellm/models/audio/speak.py,sha256=lDITZ7fiLRuDhA-LxCPQ6Yraxr33B6Lg7VyR4CkuGk8,1872
16
+ livellm/models/audio/transcribe.py,sha256=Leji2lk5zfq4GE-fw-z2dZR8BuijzW8TJ12GHw_UZJY,2085
17
+ livellm-1.3.5.dist-info/METADATA,sha256=goAVH_VH2pe_xGnKslqbgBgoui03iDWaSwDDQERDIbs,18701
18
+ livellm-1.3.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ livellm-1.3.5.dist-info/licenses/LICENSE,sha256=yapGO2C_00ymEx6TADdbU8Oyc1bWOrZY-fjP-agmFL4,1071
20
+ livellm-1.3.5.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- livellm/__init__.py,sha256=JG_0-UCfQI_3D0Y2PzobZLS5OhJwK76i8t81ye0KpfY,279
2
- livellm/livellm.py,sha256=w6Dc0eewOSJie4rmfMq-afck6Coh30-KmkRNh9_Eeko,24003
3
- livellm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- livellm/models/__init__.py,sha256=JBUd1GkeDexLSdjSOcUet78snu0NNxnhU7mBN3BhqIA,1199
5
- livellm/models/common.py,sha256=YqRwP6ChWbRdoen4MU6RO4u6HeM0mQJbXiiRV4DuauM,1740
6
- livellm/models/fallback.py,sha256=zGG_MjdbaTx0fqKZTEg3ullej-CJznPfwaon0jEvRvI,1170
7
- livellm/models/agent/__init__.py,sha256=KVm6AgQoWEaoq47QAG4Ou4NimoXOTkjXC-0-gnMRLZ8,476
8
- livellm/models/agent/agent.py,sha256=-UcGv5Bzw5ALmWX4lIqpbWqMVjCsjBc0KIE6_JKbCXM,1106
9
- livellm/models/agent/chat.py,sha256=zGfeEHx0luwq23pqWF1megcuEDUl6IhV4keLJeZry_A,1028
10
- livellm/models/agent/tools.py,sha256=wVWfx6_jxL3IcmX_Nt_PonZ3RQLtpfqJnszHz32BQiU,1403
11
- livellm/models/audio/__init__.py,sha256=sz2NxCOfFGVvp-XQUsdgOR_TYBO1Wb-8LLXaZDEiAZk,282
12
- livellm/models/audio/speak.py,sha256=KvENOE_Lf8AWBhzCMqu1dqGYv4WqaLf7fuWz8OYfJo8,1006
13
- livellm/models/audio/transcribe.py,sha256=Leji2lk5zfq4GE-fw-z2dZR8BuijzW8TJ12GHw_UZJY,2085
14
- livellm-1.2.0.dist-info/METADATA,sha256=aF-sHBOn1GDj8-u6RNwYYdto5dyRbeIHjWbAMBiFR0Q,13284
15
- livellm-1.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- livellm-1.2.0.dist-info/licenses/LICENSE,sha256=yapGO2C_00ymEx6TADdbU8Oyc1bWOrZY-fjP-agmFL4,1071
17
- livellm-1.2.0.dist-info/RECORD,,