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 +6 -2
- livellm/livellm.py +434 -227
- livellm/models/__init__.py +5 -0
- livellm/models/audio/speak.py +21 -0
- livellm/models/transcription.py +32 -0
- livellm/models/ws.py +28 -0
- livellm/transcripton.py +116 -0
- {livellm-1.2.0.dist-info → livellm-1.3.5.dist-info}/METADATA +139 -2
- livellm-1.3.5.dist-info/RECORD +20 -0
- livellm-1.2.0.dist-info/RECORD +0 -17
- {livellm-1.2.0.dist-info → livellm-1.3.5.dist-info}/WHEEL +0 -0
- {livellm-1.2.0.dist-info → livellm-1.3.5.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
640
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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
|
|
livellm/models/__init__.py
CHANGED
|
@@ -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
|
]
|
livellm/models/audio/speak.py
CHANGED
|
@@ -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")
|
livellm/transcripton.py
ADDED
|
@@ -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.
|
|
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,,
|
livellm-1.2.0.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|