livellm 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
livellm/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """LiveLLM Client - Python client for the LiveLLM Proxy and Realtime APIs."""
2
+
3
+ from .livellm import LivellmClient
4
+ from . import models
5
+
6
+ __version__ = "1.1.0"
7
+
8
+ __all__ = [
9
+ # Version
10
+ "__version__",
11
+ # Classes
12
+ "LivellmClient",
13
+ # Models
14
+ *models.__all__,
15
+ ]
livellm/livellm.py ADDED
@@ -0,0 +1,236 @@
1
+ """LiveLLM Client - Python client for the LiveLLM Proxy and Realtime APIs."""
2
+ import httpx
3
+ import json
4
+ from typing import List, Optional, AsyncIterator, Union
5
+ from .models.common import Settings, SuccessResponse
6
+ from .models.agent.agent import AgentRequest, AgentResponse
7
+ from .models.audio.speak import SpeakRequest
8
+ from .models.audio.transcribe import TranscribeRequest, TranscribeResponse, File
9
+ from .models.fallback import AgentFallbackRequest, AudioFallbackRequest, TranscribeFallbackRequest
10
+
11
+ class LivellmClient:
12
+
13
+ def __init__(
14
+ self,
15
+ base_url: str,
16
+ timeout: Optional[float] = None,
17
+ configs: Optional[List[Settings]] = None
18
+ ):
19
+ base_url = base_url.rstrip("/")
20
+ self.base_url = f"{base_url}/livellm"
21
+ self.timeout = timeout
22
+ self.client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) \
23
+ if self.timeout else httpx.AsyncClient(base_url=self.base_url)
24
+ self.settings = []
25
+ self.headers = {
26
+ "Content-Type": "application/json",
27
+ }
28
+ if configs:
29
+ self.update_configs_post_init(configs)
30
+
31
+
32
+ def update_configs_post_init(self, configs: List[Settings]) -> SuccessResponse:
33
+ """
34
+ Update the configs after the client is initialized.
35
+ Args:
36
+ configs: The configs to update.
37
+ """
38
+ with httpx.Client(base_url=self.base_url, timeout=self.timeout) as client:
39
+ for config in configs:
40
+ response = client.post(f"{self.base_url}/providers/config", json=config.model_dump())
41
+ response.raise_for_status()
42
+ self.settings.append(config)
43
+ return SuccessResponse(success=True, message="Configs updated successfully")
44
+
45
+
46
+ async def delete(self, endpoint: str) -> dict:
47
+ """
48
+ Delete a resource from the given endpoint and return the response.
49
+ Args:
50
+ endpoint: The endpoint to delete from.
51
+ Returns:
52
+ The response from the endpoint.
53
+ """
54
+ response = await self.client.delete(endpoint, headers=self.headers)
55
+ response.raise_for_status()
56
+ return response.json()
57
+
58
+ async def post_multipart(
59
+ self,
60
+ files: dict,
61
+ data: dict,
62
+ endpoint: str
63
+ ) -> dict:
64
+ """
65
+ Post a multipart request to the given endpoint and return the response.
66
+ Args:
67
+ files: The files to send in the request.
68
+ data: The data to send in the request.
69
+ endpoint: The endpoint to post to.
70
+ Returns:
71
+ The response from the endpoint.
72
+ """
73
+ # Don't pass Content-Type header for multipart - httpx will set it automatically
74
+ response = await self.client.post(endpoint, files=files, data=data)
75
+ response.raise_for_status()
76
+ return response.json()
77
+
78
+
79
+ async def get(
80
+ self,
81
+ endpoint: str
82
+ ) -> dict:
83
+ """
84
+ Get a request from the given endpoint and return the response.
85
+ Args:
86
+ endpoint: The endpoint to get from.
87
+ Returns:
88
+ The response from the endpoint.
89
+ """
90
+ response = await self.client.get(endpoint, headers=self.headers)
91
+ response.raise_for_status()
92
+ return response.json()
93
+
94
+ async def post(
95
+ self,
96
+ json_data: dict,
97
+ endpoint: str,
98
+ expect_stream: bool = False,
99
+ expect_json: bool = True
100
+ ) -> Union[dict, bytes, AsyncIterator[Union[dict, bytes]]]:
101
+ """
102
+ Post a request to the given endpoint and return the response.
103
+ If expect_stream is True, return an AsyncIterator of the response.
104
+ If expect_json is True, return the response as a JSON object.
105
+ Otherwise, return the response as bytes.
106
+ Args:
107
+ json_data: The JSON data to send in the request.
108
+ endpoint: The endpoint to post to.
109
+ expect_stream: Whether to expect a stream response.
110
+ expect_json: Whether to expect a JSON response.
111
+ Returns:
112
+ The response from the endpoint.
113
+ Raises:
114
+ Exception: If the response is not 200 or 201.
115
+ """
116
+ response = await self.client.post(endpoint, json=json_data, headers=self.headers)
117
+ if response.status_code not in [200, 201]:
118
+ error_response = await response.aread()
119
+ error_response = error_response.decode("utf-8")
120
+ raise Exception(f"Failed to post to {endpoint}: {error_response}")
121
+ if expect_stream:
122
+ async def stream_response() -> AsyncIterator[Union[dict, bytes]]:
123
+ async for chunk in response.aiter_lines():
124
+ if expect_json:
125
+ chunk = chunk.strip()
126
+ if not chunk:
127
+ continue
128
+ yield json.loads(chunk)
129
+ else:
130
+ yield chunk
131
+ return stream_response()
132
+ else:
133
+ if expect_json:
134
+ return response.json()
135
+ else:
136
+ return response.content
137
+
138
+
139
+ async def ping(self) -> SuccessResponse:
140
+ result = await self.get("ping")
141
+ return SuccessResponse(**result)
142
+
143
+ async def update_config(self, config: Settings) -> SuccessResponse:
144
+ result = await self.post(config.model_dump(), "providers/config", expect_json=True)
145
+ self.settings.append(config)
146
+ return SuccessResponse(**result)
147
+
148
+ async def update_configs(self, configs: List[Settings]) -> SuccessResponse:
149
+ for config in configs:
150
+ await self.update_config(config)
151
+ return SuccessResponse(success=True, message="Configs updated successfully")
152
+
153
+ async def get_configs(self) -> List[Settings]:
154
+ result = await self.get("providers/configs")
155
+ return [Settings(**config) for config in result]
156
+
157
+ async def delete_config(self, config_uid: str) -> SuccessResponse:
158
+ result = await self.delete(f"providers/config/{config_uid}")
159
+ return SuccessResponse(**result)
160
+
161
+ async def cleanup(self):
162
+ """
163
+ Delete all the created settings resources and close the client.
164
+ Should be called when you're done using the client.
165
+ """
166
+ for config in self.settings:
167
+ await self.delete_config(config.uid)
168
+ await self.client.aclose()
169
+
170
+ async def __aenter__(self):
171
+ """Async context manager entry."""
172
+ return self
173
+
174
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
175
+ """Async context manager exit."""
176
+ await self.cleanup()
177
+
178
+
179
+ async def agent_run(
180
+ self,
181
+ request: Union[AgentRequest, AgentFallbackRequest]
182
+ ) -> AgentResponse:
183
+ result = await self.post(request.model_dump(), "agent/run", expect_json=True)
184
+ return AgentResponse(**result)
185
+
186
+ async def agent_run_stream(
187
+ self,
188
+ request: Union[AgentRequest, AgentFallbackRequest]
189
+ ) -> AsyncIterator[AgentResponse]:
190
+ stream = await self.post(request.model_dump(), "agent/run_stream", expect_stream=True, expect_json=True)
191
+ async for chunk in stream:
192
+ yield AgentResponse(**chunk)
193
+
194
+ async def speak(
195
+ self,
196
+ request: Union[SpeakRequest, AudioFallbackRequest]
197
+ ) -> bytes:
198
+ return await self.post(request.model_dump(), "audio/speak", expect_json=False)
199
+
200
+ async def speak_stream(
201
+ self,
202
+ request: Union[SpeakRequest, AudioFallbackRequest]
203
+ ) -> AsyncIterator[bytes]:
204
+ return await self.post(request.model_dump(), "audio/speak_stream", expect_stream=True, expect_json=False)
205
+
206
+
207
+ async def transcribe(
208
+ self,
209
+ provider_uid: str,
210
+ file: File,
211
+ model: str,
212
+ language: Optional[str] = None,
213
+ gen_config: Optional[dict] = None
214
+ ) -> TranscribeResponse:
215
+ files = {
216
+ "file": file
217
+ }
218
+ data = {
219
+ "provider_uid": provider_uid,
220
+ "model": model,
221
+ "language": language,
222
+ "gen_config": json.dumps(gen_config) if gen_config else None
223
+ }
224
+ result = await self.post_multipart(files, data, "audio/transcribe")
225
+ return TranscribeResponse(**result)
226
+
227
+ async def transcribe_json(
228
+ self,
229
+ request: Union[TranscribeRequest, TranscribeFallbackRequest]
230
+ ) -> TranscribeResponse:
231
+ result = await self.post(request.model_dump(), "audio/transcribe_json", expect_json=True)
232
+ return TranscribeResponse(**result)
233
+
234
+
235
+
236
+
@@ -0,0 +1,41 @@
1
+ from .common import BaseRequest, ProviderKind, Settings, SuccessResponse
2
+ from .fallback import AgentFallbackRequest, AudioFallbackRequest, TranscribeFallbackRequest, FallbackStrategy
3
+ from .agent.agent import AgentRequest, AgentResponse, AgentResponseUsage
4
+ from .agent.chat import Message, MessageRole, TextMessage, BinaryMessage
5
+ from .agent.tools import Tool, ToolInput, ToolKind, WebSearchInput, MCPStreamableServerInput
6
+ from .audio.speak import SpeakMimeType, SpeakRequest, SpeakStreamResponse
7
+ from .audio.transcribe import TranscribeRequest, TranscribeResponse, File
8
+
9
+
10
+ __all__ = [
11
+ # Common
12
+ "BaseRequest",
13
+ "ProviderKind",
14
+ "Settings",
15
+ "SuccessResponse",
16
+ # Fallback
17
+ "AgentFallbackRequest",
18
+ "AudioFallbackRequest",
19
+ "TranscribeFallbackRequest",
20
+ "FallbackStrategy",
21
+ # Agent
22
+ "AgentRequest",
23
+ "AgentResponse",
24
+ "AgentResponseUsage",
25
+ "Message",
26
+ "MessageRole",
27
+ "TextMessage",
28
+ "BinaryMessage",
29
+ "Tool",
30
+ "ToolInput",
31
+ "ToolKind",
32
+ "WebSearchInput",
33
+ "MCPStreamableServerInput",
34
+ # Audio
35
+ "SpeakMimeType",
36
+ "SpeakRequest",
37
+ "SpeakStreamResponse",
38
+ "TranscribeRequest",
39
+ "TranscribeResponse",
40
+ "File",
41
+ ]
@@ -0,0 +1,19 @@
1
+ from .agent import AgentRequest, AgentResponse, AgentResponseUsage
2
+ from .chat import Message, MessageRole, TextMessage, BinaryMessage
3
+ from .tools import Tool, ToolInput, ToolKind, WebSearchInput, MCPStreamableServerInput
4
+
5
+
6
+ __all__ = [
7
+ "AgentRequest",
8
+ "AgentResponse",
9
+ "AgentResponseUsage",
10
+ "Message",
11
+ "MessageRole",
12
+ "TextMessage",
13
+ "BinaryMessage",
14
+ "Tool",
15
+ "ToolInput",
16
+ "ToolKind",
17
+ "WebSearchInput",
18
+ "MCPStreamableServerInput",
19
+ ]
@@ -0,0 +1,23 @@
1
+ # models for full run: AgentRequest, AgentResponse
2
+
3
+ from pydantic import BaseModel, Field
4
+ from typing import Optional, List, Union
5
+ from .chat import TextMessage, BinaryMessage
6
+ from .tools import WebSearchInput, MCPStreamableServerInput
7
+ from ..common import BaseRequest
8
+
9
+
10
+ class AgentRequest(BaseRequest):
11
+ model: str = Field(..., description="The model to use")
12
+ messages: List[Union[TextMessage, BinaryMessage]]
13
+ tools: List[Union[WebSearchInput, MCPStreamableServerInput]]
14
+ gen_config: Optional[dict] = Field(default=None, description="The configuration for the generation")
15
+
16
+
17
+ class AgentResponseUsage(BaseModel):
18
+ input_tokens: int = Field(..., description="The number of input tokens used")
19
+ output_tokens: int = Field(..., description="The number of output tokens used")
20
+
21
+ class AgentResponse(BaseModel):
22
+ output: str = Field(..., description="The output of the response")
23
+ usage: AgentResponseUsage = Field(..., description="The usage of the response")
@@ -0,0 +1,30 @@
1
+ # models for chat messages
2
+ from pydantic import BaseModel, Field, model_validator
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ class MessageRole(Enum):
7
+ USER = "user"
8
+ MODEL = "model"
9
+ SYSTEM = "system"
10
+
11
+
12
+ class Message(BaseModel):
13
+ role: MessageRole = Field(..., description="The role of the message")
14
+
15
+
16
+ class TextMessage(Message):
17
+ content: str = Field(..., description="The content of the message")
18
+
19
+ class BinaryMessage(Message):
20
+ """always from user"""
21
+ content: str = Field(..., description="The base64 encoded content of the message")
22
+ mime_type: str = Field(..., description="The MIME type of the content, only user can supply such")
23
+ caption: Optional[str] = Field(None, description="Caption for the binary message")
24
+
25
+ @model_validator(mode="after")
26
+ def validate_content(self) -> "BinaryMessage":
27
+ if self.role == MessageRole.MODEL:
28
+ raise ValueError("MIME type are meant for user messages only")
29
+ return self
30
+
@@ -0,0 +1,39 @@
1
+ # models for tools
2
+ from pydantic import BaseModel, Field, field_validator
3
+ from typing import Literal
4
+ from enum import Enum
5
+
6
+ class ToolKind(Enum):
7
+ WEB_SEARCH = "web_search"
8
+ MCP_STREAMABLE_SERVER = "mcp_streamable_server"
9
+
10
+ class Tool(BaseModel):
11
+ kind: ToolKind
12
+ input: BaseModel
13
+
14
+
15
+ class ToolInput(BaseModel):
16
+ kind: ToolKind
17
+ kwargs: dict = Field(default_factory=dict, description="Additional keyword arguments for the MCP server")
18
+
19
+ class WebSearchInput(ToolInput):
20
+ kind: ToolKind = Field(ToolKind.WEB_SEARCH, description="Web search kind of tool")
21
+ search_context_size: Literal['low', 'medium', 'high'] = Field('medium', description="The context size for the search")
22
+
23
+ @field_validator('kind')
24
+ def validate_kind(cls, v):
25
+ if v != ToolKind.WEB_SEARCH:
26
+ raise ValueError(f"Invalid kind: {v}")
27
+ return v
28
+
29
+ class MCPStreamableServerInput(ToolInput):
30
+ kind: ToolKind = Field(ToolKind.MCP_STREAMABLE_SERVER, description="Mcp kind of tool")
31
+ url: str = Field(..., description="The URL of the MCP server")
32
+ prefix: str = Field(..., description="The prefix of the MCP server")
33
+ timeout: int = Field(15, description="The timeout in seconds for the MCP server")
34
+
35
+ @field_validator('kind')
36
+ def validate_kind(cls, v):
37
+ if v != ToolKind.MCP_STREAMABLE_SERVER:
38
+ raise ValueError(f"Invalid kind: {v}")
39
+ return v
@@ -0,0 +1,12 @@
1
+ from .speak import SpeakMimeType, SpeakRequest, SpeakStreamResponse
2
+ from .transcribe import TranscribeRequest, TranscribeResponse, File
3
+
4
+
5
+ __all__ = [
6
+ "SpeakMimeType",
7
+ "SpeakRequest",
8
+ "SpeakStreamResponse",
9
+ "TranscribeRequest",
10
+ "TranscribeResponse",
11
+ "File",
12
+ ]
@@ -0,0 +1,23 @@
1
+ from pydantic import BaseModel, Field, field_validator
2
+ from typing import Optional, TypeAlias, Tuple, AsyncIterator
3
+ from enum import Enum
4
+ from ..common import BaseRequest
5
+
6
+ SpeakStreamResponse: TypeAlias = Tuple[AsyncIterator[bytes], str, int]
7
+
8
+
9
+ class SpeakMimeType(Enum):
10
+ PCM = "audio/pcm"
11
+ WAV = "audio/wav"
12
+ MP3 = "audio/mpeg"
13
+ ULAW = "audio/ulaw"
14
+ ALAW = "audio/alaw"
15
+
16
+ class SpeakRequest(BaseRequest):
17
+ model: str = Field(..., description="The model to use")
18
+ text: str = Field(..., description="The text to speak")
19
+ voice: str = Field(..., description="The voice to use")
20
+ mime_type: SpeakMimeType = Field(..., description="The MIME type of the output audio")
21
+ sample_rate: int = Field(..., description="The target sample rate of the output audio")
22
+ chunk_size: int = Field(default=20, description="Chunk size in milliseconds for streaming (default: 20ms)")
23
+ gen_config: Optional[dict] = Field(default=None, description="The configuration for the generation")
@@ -0,0 +1,48 @@
1
+ from pydantic import BaseModel, Field, field_validator
2
+ from typing import Tuple, TypeAlias, Optional, Union
3
+ from ..common import BaseRequest
4
+ import base64
5
+
6
+ File: TypeAlias = Tuple[str, Union[bytes, str], str] # (filename, file_content, content_type)
7
+
8
+ class TranscribeRequest(BaseRequest):
9
+ model: str = Field(..., description="The model to use")
10
+ file: File = Field(..., description="The file to transcribe")
11
+ language: Optional[str] = Field(default=None, description="The language to transcribe")
12
+ gen_config: Optional[dict] = Field(default=None, description="The configuration for the generation")
13
+
14
+ @field_validator('file', mode='before')
15
+ @classmethod
16
+ def decode_base64_file(cls, v) -> File:
17
+ """
18
+ Validates and processes the file field.
19
+
20
+ Accepts two formats:
21
+ 1. Tuple[str, bytes, str] - Direct bytes (from multipart/form-data)
22
+ 2. Tuple[str, str, str] - Base64 encoded string (from JSON)
23
+
24
+ Returns: Tuple[str, bytes, str] with decoded bytes
25
+ """
26
+ if not isinstance(v, (tuple, list)) or len(v) != 3:
27
+ raise ValueError("file must be a tuple/list of 3 elements: (filename, content, content_type)")
28
+
29
+ filename, content, content_type = v
30
+
31
+ # If content is already bytes, return as-is
32
+ if isinstance(content, bytes):
33
+ return (filename, content, content_type)
34
+
35
+ # If content is a string, assume it's base64 encoded
36
+ elif isinstance(content, str):
37
+ try:
38
+ decoded_content = base64.b64decode(content)
39
+ return (filename, decoded_content, content_type)
40
+ except Exception as e:
41
+ raise ValueError(f"Failed to decode base64 content: {str(e)}")
42
+ else:
43
+ raise ValueError(f"file content must be either bytes or base64 string, got {type(content)}")
44
+
45
+
46
+ class TranscribeResponse(BaseModel):
47
+ text: str = Field(..., description="The text of the transcription")
48
+ language: Optional[str] = Field(default=None, description="The language of the transcription")
@@ -0,0 +1,46 @@
1
+ """Common models shared across all services"""
2
+
3
+ from pydantic import BaseModel, Field, SecretStr, model_serializer
4
+ from enum import Enum
5
+ from typing import Optional, Any
6
+
7
+
8
+ class BaseRequest(BaseModel):
9
+ """Base request model that all service requests inherit from"""
10
+ provider_uid: str = Field(..., description="The unique identifier of the provider configuration to use")
11
+
12
+
13
+ class ProviderKind(Enum):
14
+ """Unified provider types for both agent and audio services"""
15
+ # Agent providers
16
+ OPENAI = "openai"
17
+ GOOGLE = "google"
18
+ ANTHROPIC = "anthropic"
19
+ GROQ = "groq"
20
+ # Audio providers
21
+ ELEVENLABS = "elevenlabs"
22
+
23
+
24
+ class Settings(BaseModel):
25
+ """Base settings for all service providers"""
26
+ uid: str = Field(..., description="The unique identifier of the provider configuration")
27
+ provider: ProviderKind = Field(..., description="The provider to use")
28
+ api_key: SecretStr = Field(..., description="API key for the provider")
29
+ base_url: Optional[str] = Field(None, description="Optional custom base URL for the provider")
30
+ blacklist_models: Optional[list[str]] = Field(None, description="models selection for blacklist")
31
+
32
+ @model_serializer
33
+ def serialize_model(self) -> dict[str, Any]:
34
+ """Custom serializer to handle SecretStr properly"""
35
+ return {
36
+ "uid": self.uid,
37
+ "provider": self.provider.value,
38
+ "api_key": self.api_key.get_secret_value(),
39
+ "base_url": self.base_url,
40
+ "blacklist_models": self.blacklist_models,
41
+ }
42
+
43
+
44
+ class SuccessResponse(BaseModel):
45
+ success: bool = Field(True, description="Whether the operation was successful")
46
+ message: str = Field("ok", description="The message of the operation")
@@ -0,0 +1,25 @@
1
+ from pydantic import BaseModel, Field
2
+ from typing import List
3
+ from .common import BaseRequest
4
+ from .audio.speak import SpeakRequest
5
+ from .audio.transcribe import TranscribeRequest
6
+ from .agent.agent import AgentRequest
7
+ from enum import Enum
8
+
9
+ class FallbackStrategy(Enum):
10
+ SEQUENTIAL = "sequential"
11
+ PARALLEL = "parallel"
12
+
13
+ class FallbackRequest(BaseModel):
14
+ requests: List[BaseRequest] = Field(..., description="List of requests to try as fallbacks")
15
+ strategy: FallbackStrategy = Field(FallbackStrategy.SEQUENTIAL, description="The strategy to use for fallback")
16
+ timeout_per_request: int = Field(default=360, description="The timeout to use for each request")
17
+
18
+ class AgentFallbackRequest(FallbackRequest):
19
+ requests: List[AgentRequest] = Field(..., description="List of agent requests to try as fallbacks")
20
+
21
+ class AudioFallbackRequest(FallbackRequest):
22
+ requests: List[SpeakRequest] = Field(..., description="List of audio requests to try as fallbacks")
23
+
24
+ class TranscribeFallbackRequest(FallbackRequest):
25
+ requests: List[TranscribeRequest] = Field(..., description="List of transcribe requests to try as fallbacks")
livellm/py.typed ADDED
File without changes
@@ -0,0 +1,573 @@
1
+ Metadata-Version: 2.4
2
+ Name: livellm
3
+ Version: 1.1.0
4
+ Summary: Python client for the LiveLLM Server
5
+ Project-URL: Homepage, https://github.com/qalby-tech/livellm-client-py
6
+ Project-URL: Repository, https://github.com/qalby-tech/livellm-client-py
7
+ Author: Kamil Saliamov
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.27.0
19
+ Requires-Dist: pydantic>=2.0.0
20
+ Provides-Extra: testing
21
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'testing'
22
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'testing'
23
+ Requires-Dist: pytest>=8.4.2; extra == 'testing'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # LiveLLM Python Client
27
+
28
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
30
+
31
+ Python client library for the LiveLLM Server - a unified proxy for AI agent, audio, and transcription services.
32
+
33
+ ## Features
34
+
35
+ - 🚀 **Async-first design** - Built on httpx for high-performance async operations
36
+ - 🔒 **Type-safe** - Full type hints and Pydantic validation
37
+ - 🎯 **Multi-provider support** - OpenAI, Google, Anthropic, Groq, ElevenLabs
38
+ - 🔄 **Streaming support** - Real-time streaming for agent and audio responses
39
+ - 🛠️ **Agent tools** - Web search and MCP server integration
40
+ - 🎙️ **Audio services** - Text-to-speech and transcription
41
+ - ⚡ **Fallback strategies** - Sequential and parallel fallback handling
42
+ - 📦 **Context manager support** - Automatic cleanup with async context managers
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ pip install livellm-client
48
+ ```
49
+
50
+ Or with development dependencies:
51
+
52
+ ```bash
53
+ pip install livellm-client[testing]
54
+ ```
55
+
56
+ ## Quick Start
57
+
58
+ ```python
59
+ import asyncio
60
+ from livellm import LivellmClient
61
+ from livellm.models import Settings, ProviderKind, AgentRequest, TextMessage, MessageRole
62
+ from pydantic import SecretStr
63
+
64
+ async def main():
65
+ # Initialize the client with context manager for automatic cleanup
66
+ async with LivellmClient(base_url="http://localhost:8000") as client:
67
+ # Configure a provider
68
+ config = Settings(
69
+ uid="my-openai-config",
70
+ provider=ProviderKind.OPENAI,
71
+ api_key=SecretStr("your-api-key")
72
+ )
73
+ await client.update_config(config)
74
+
75
+ # Run an agent query
76
+ request = AgentRequest(
77
+ provider_uid="my-openai-config",
78
+ model="gpt-4",
79
+ messages=[
80
+ TextMessage(role=MessageRole.USER, content="Hello, how are you?")
81
+ ],
82
+ tools=[]
83
+ )
84
+
85
+ response = await client.agent_run(request)
86
+ print(response.output)
87
+
88
+ asyncio.run(main())
89
+ ```
90
+
91
+ ## Configuration
92
+
93
+ ### Client Initialization
94
+
95
+ ```python
96
+ from livellm import LivellmClient
97
+
98
+ # Basic initialization
99
+ client = LivellmClient(base_url="http://localhost:8000")
100
+
101
+ # With timeout
102
+ client = LivellmClient(
103
+ base_url="http://localhost:8000",
104
+ timeout=30.0
105
+ )
106
+
107
+ # With pre-configured providers (sync operation)
108
+ from livellm.models import Settings, ProviderKind
109
+ from pydantic import SecretStr
110
+
111
+ configs = [
112
+ Settings(
113
+ uid="openai-config",
114
+ provider=ProviderKind.OPENAI,
115
+ api_key=SecretStr("sk-..."),
116
+ base_url="https://api.openai.com/v1" # Optional custom base URL
117
+ ),
118
+ Settings(
119
+ uid="anthropic-config",
120
+ provider=ProviderKind.ANTHROPIC,
121
+ api_key=SecretStr("sk-ant-..."),
122
+ blacklist_models=["claude-instant-1"] # Optional model blacklist
123
+ )
124
+ ]
125
+
126
+ client = LivellmClient(
127
+ base_url="http://localhost:8000",
128
+ configs=configs
129
+ )
130
+ ```
131
+
132
+ ### Provider Configuration
133
+
134
+ Supported providers:
135
+ - `OPENAI` - OpenAI GPT models
136
+ - `GOOGLE` - Google Gemini models
137
+ - `ANTHROPIC` - Anthropic Claude models
138
+ - `GROQ` - Groq models
139
+ - `ELEVENLABS` - ElevenLabs text-to-speech
140
+
141
+ ```python
142
+ # Add a provider configuration
143
+ config = Settings(
144
+ uid="unique-provider-id",
145
+ provider=ProviderKind.OPENAI,
146
+ api_key=SecretStr("your-api-key"),
147
+ base_url="https://custom-endpoint.com", # Optional
148
+ blacklist_models=["deprecated-model"] # Optional
149
+ )
150
+ await client.update_config(config)
151
+
152
+ # Get all configurations
153
+ configs = await client.get_configs()
154
+
155
+ # Delete a configuration
156
+ await client.delete_config("unique-provider-id")
157
+ ```
158
+
159
+ ## Usage Examples
160
+
161
+ ### Agent Services
162
+
163
+ #### Basic Agent Run
164
+
165
+ ```python
166
+ from livellm.models import AgentRequest, TextMessage, MessageRole
167
+
168
+ request = AgentRequest(
169
+ provider_uid="my-openai-config",
170
+ model="gpt-4",
171
+ messages=[
172
+ TextMessage(role=MessageRole.SYSTEM, content="You are a helpful assistant."),
173
+ TextMessage(role=MessageRole.USER, content="Explain quantum computing")
174
+ ],
175
+ tools=[],
176
+ gen_config={"temperature": 0.7, "max_tokens": 500}
177
+ )
178
+
179
+ response = await client.agent_run(request)
180
+ print(f"Output: {response.output}")
181
+ print(f"Tokens used - Input: {response.usage.input_tokens}, Output: {response.usage.output_tokens}")
182
+ ```
183
+
184
+ #### Streaming Agent Response
185
+
186
+ ```python
187
+ request = AgentRequest(
188
+ provider_uid="my-openai-config",
189
+ model="gpt-4",
190
+ messages=[
191
+ TextMessage(role=MessageRole.USER, content="Tell me a story")
192
+ ],
193
+ tools=[]
194
+ )
195
+
196
+ stream = await client.agent_run_stream(request)
197
+ async for chunk in stream:
198
+ print(chunk.output, end="", flush=True)
199
+ ```
200
+
201
+ #### Agent with Binary Messages
202
+
203
+ ```python
204
+ import base64
205
+
206
+ # Read and encode image
207
+ with open("image.jpg", "rb") as f:
208
+ image_data = base64.b64encode(f.read()).decode("utf-8")
209
+
210
+ from livellm.models import BinaryMessage
211
+
212
+ request = AgentRequest(
213
+ provider_uid="my-openai-config",
214
+ model="gpt-4-vision",
215
+ messages=[
216
+ BinaryMessage(
217
+ role=MessageRole.USER,
218
+ content=image_data,
219
+ mime_type="image/jpeg",
220
+ caption="What's in this image?"
221
+ )
222
+ ],
223
+ tools=[]
224
+ )
225
+
226
+ response = await client.agent_run(request)
227
+ ```
228
+
229
+ #### Agent with Web Search Tool
230
+
231
+ ```python
232
+ from livellm.models import WebSearchInput, ToolKind
233
+
234
+ request = AgentRequest(
235
+ provider_uid="my-openai-config",
236
+ model="gpt-4",
237
+ messages=[
238
+ TextMessage(role=MessageRole.USER, content="What's the latest news about AI?")
239
+ ],
240
+ tools=[
241
+ WebSearchInput(
242
+ kind=ToolKind.WEB_SEARCH,
243
+ search_context_size="high" # Options: "low", "medium", "high"
244
+ )
245
+ ]
246
+ )
247
+
248
+ response = await client.agent_run(request)
249
+ ```
250
+
251
+ #### Agent with MCP Server Tool
252
+
253
+ ```python
254
+ from livellm.models import MCPStreamableServerInput, ToolKind
255
+
256
+ request = AgentRequest(
257
+ provider_uid="my-openai-config",
258
+ model="gpt-4",
259
+ messages=[
260
+ TextMessage(role=MessageRole.USER, content="Execute tool")
261
+ ],
262
+ tools=[
263
+ MCPStreamableServerInput(
264
+ kind=ToolKind.MCP_STREAMABLE_SERVER,
265
+ url="http://mcp-server:8080",
266
+ prefix="mcp_",
267
+ timeout=15,
268
+ kwargs={"custom_param": "value"}
269
+ )
270
+ ]
271
+ )
272
+
273
+ response = await client.agent_run(request)
274
+ ```
275
+
276
+ ### Audio Services
277
+
278
+ #### Text-to-Speech
279
+
280
+ ```python
281
+ from livellm.models import SpeakRequest, SpeakMimeType
282
+
283
+ request = SpeakRequest(
284
+ provider_uid="elevenlabs-config",
285
+ model="eleven_turbo_v2",
286
+ text="Hello, this is a test of text to speech.",
287
+ voice="rachel",
288
+ mime_type=SpeakMimeType.MP3,
289
+ sample_rate=44100,
290
+ gen_config={"stability": 0.5, "similarity_boost": 0.75}
291
+ )
292
+
293
+ # Get audio as bytes
294
+ audio_bytes = await client.speak(request)
295
+ with open("output.mp3", "wb") as f:
296
+ f.write(audio_bytes)
297
+ ```
298
+
299
+ #### Streaming Text-to-Speech
300
+
301
+ ```python
302
+ request = SpeakRequest(
303
+ provider_uid="elevenlabs-config",
304
+ model="eleven_turbo_v2",
305
+ text="This is a longer text that will be streamed.",
306
+ voice="rachel",
307
+ mime_type=SpeakMimeType.MP3,
308
+ sample_rate=44100,
309
+ chunk_size=20 # Chunk size in milliseconds
310
+ )
311
+
312
+ # Stream audio chunks
313
+ stream = await client.speak_stream(request)
314
+ with open("output.mp3", "wb") as f:
315
+ async for chunk in stream:
316
+ f.write(chunk)
317
+ ```
318
+
319
+ #### Audio Transcription (Multipart)
320
+
321
+ ```python
322
+ # Using multipart upload
323
+ with open("audio.mp3", "rb") as f:
324
+ file_tuple = ("audio.mp3", f.read(), "audio/mpeg")
325
+
326
+ response = await client.transcribe(
327
+ provider_uid="openai-config",
328
+ file=file_tuple,
329
+ model="whisper-1",
330
+ language="en",
331
+ gen_config={"temperature": 0.2}
332
+ )
333
+
334
+ print(f"Transcription: {response.text}")
335
+ print(f"Detected language: {response.language}")
336
+ ```
337
+
338
+ #### Audio Transcription (JSON)
339
+
340
+ ```python
341
+ import base64
342
+ from livellm.models import TranscribeRequest
343
+
344
+ with open("audio.mp3", "rb") as f:
345
+ audio_data = base64.b64encode(f.read()).decode("utf-8")
346
+
347
+ request = TranscribeRequest(
348
+ provider_uid="openai-config",
349
+ model="whisper-1",
350
+ file=("audio.mp3", audio_data, "audio/mpeg"),
351
+ language="en"
352
+ )
353
+
354
+ response = await client.transcribe_json(request)
355
+ ```
356
+
357
+ ### Fallback Strategies
358
+
359
+ #### Sequential Fallback (Try each provider in order)
360
+
361
+ ```python
362
+ from livellm.models import AgentFallbackRequest, FallbackStrategy
363
+
364
+ fallback_request = AgentFallbackRequest(
365
+ requests=[
366
+ AgentRequest(
367
+ provider_uid="primary-provider",
368
+ model="gpt-4",
369
+ messages=[TextMessage(role=MessageRole.USER, content="Hello")],
370
+ tools=[]
371
+ ),
372
+ AgentRequest(
373
+ provider_uid="backup-provider",
374
+ model="claude-3",
375
+ messages=[TextMessage(role=MessageRole.USER, content="Hello")],
376
+ tools=[]
377
+ )
378
+ ],
379
+ strategy=FallbackStrategy.SEQUENTIAL,
380
+ timeout_per_request=30
381
+ )
382
+
383
+ response = await client.agent_run(fallback_request)
384
+ ```
385
+
386
+ #### Parallel Fallback (Try all providers simultaneously)
387
+
388
+ ```python
389
+ fallback_request = AgentFallbackRequest(
390
+ requests=[
391
+ AgentRequest(provider_uid="provider-1", model="gpt-4", messages=messages, tools=[]),
392
+ AgentRequest(provider_uid="provider-2", model="claude-3", messages=messages, tools=[]),
393
+ AgentRequest(provider_uid="provider-3", model="gemini-pro", messages=messages, tools=[])
394
+ ],
395
+ strategy=FallbackStrategy.PARALLEL,
396
+ timeout_per_request=10
397
+ )
398
+
399
+ response = await client.agent_run(fallback_request)
400
+ ```
401
+
402
+ #### Audio Fallback
403
+
404
+ ```python
405
+ from livellm.models import AudioFallbackRequest
406
+
407
+ fallback_request = AudioFallbackRequest(
408
+ requests=[
409
+ SpeakRequest(provider_uid="elevenlabs", model="model-1", text=text, voice="voice1",
410
+ mime_type=SpeakMimeType.MP3, sample_rate=44100),
411
+ SpeakRequest(provider_uid="openai", model="tts-1", text=text, voice="alloy",
412
+ mime_type=SpeakMimeType.MP3, sample_rate=44100)
413
+ ],
414
+ strategy=FallbackStrategy.SEQUENTIAL
415
+ )
416
+
417
+ audio = await client.speak(fallback_request)
418
+ ```
419
+
420
+ ## Context Manager Support
421
+
422
+ The client supports async context managers for automatic cleanup:
423
+
424
+ ```python
425
+ async with LivellmClient(base_url="http://localhost:8000") as client:
426
+ config = Settings(uid="temp-config", provider=ProviderKind.OPENAI,
427
+ api_key=SecretStr("key"))
428
+ await client.update_config(config)
429
+
430
+ # Use client...
431
+ response = await client.ping()
432
+
433
+ # Automatically cleans up configs and closes HTTP client
434
+ ```
435
+
436
+ Or manually:
437
+
438
+ ```python
439
+ client = LivellmClient(base_url="http://localhost:8000")
440
+ try:
441
+ # Use client...
442
+ pass
443
+ finally:
444
+ await client.cleanup()
445
+ ```
446
+
447
+ ## API Reference
448
+
449
+ ### Client Methods
450
+
451
+ #### Health Check
452
+ - `ping() -> SuccessResponse` - Check server health
453
+
454
+ #### Configuration Management
455
+ - `update_config(config: Settings) -> SuccessResponse` - Add/update a provider config
456
+ - `update_configs(configs: List[Settings]) -> SuccessResponse` - Add/update multiple configs
457
+ - `get_configs() -> List[Settings]` - Get all provider configurations
458
+ - `delete_config(config_uid: str) -> SuccessResponse` - Delete a provider config
459
+
460
+ #### Agent Services
461
+ - `agent_run(request: AgentRequest | AgentFallbackRequest) -> AgentResponse` - Run agent query
462
+ - `agent_run_stream(request: AgentRequest | AgentFallbackRequest) -> AsyncIterator[AgentResponse]` - Stream agent response
463
+
464
+ #### Audio Services
465
+ - `speak(request: SpeakRequest | AudioFallbackRequest) -> bytes` - Text-to-speech
466
+ - `speak_stream(request: SpeakRequest | AudioFallbackRequest) -> AsyncIterator[bytes]` - Streaming TTS
467
+ - `transcribe(provider_uid, file, model, language?, gen_config?) -> TranscribeResponse` - Multipart transcription
468
+ - `transcribe_json(request: TranscribeRequest | TranscribeFallbackRequest) -> TranscribeResponse` - JSON transcription
469
+
470
+ #### Cleanup
471
+ - `cleanup() -> None` - Clean up resources and close client
472
+ - `__aenter__() / __aexit__()` - Async context manager support
473
+
474
+ ### Models
475
+
476
+ #### Common Models
477
+ - `Settings` - Provider configuration
478
+ - `ProviderKind` - Enum of supported providers
479
+ - `SuccessResponse` - Generic success response
480
+ - `BaseRequest` - Base class for all requests
481
+
482
+ #### Agent Models
483
+ - `AgentRequest` - Agent query request
484
+ - `AgentResponse` - Agent query response
485
+ - `AgentResponseUsage` - Token usage information
486
+ - `TextMessage` - Text-based message
487
+ - `BinaryMessage` - Binary message (images, audio, etc.)
488
+ - `MessageRole` - Enum: USER, MODEL, SYSTEM
489
+
490
+ #### Tool Models
491
+ - `ToolKind` - Enum: WEB_SEARCH, MCP_STREAMABLE_SERVER
492
+ - `WebSearchInput` - Web search tool configuration
493
+ - `MCPStreamableServerInput` - MCP server tool configuration
494
+
495
+ #### Audio Models
496
+ - `SpeakRequest` - Text-to-speech request
497
+ - `SpeakMimeType` - Enum: PCM, WAV, MP3, ULAW, ALAW
498
+ - `TranscribeRequest` - Transcription request
499
+ - `TranscribeResponse` - Transcription response
500
+
501
+ #### Fallback Models
502
+ - `FallbackStrategy` - Enum: SEQUENTIAL, PARALLEL
503
+ - `AgentFallbackRequest` - Agent fallback configuration
504
+ - `AudioFallbackRequest` - Audio fallback configuration
505
+ - `TranscribeFallbackRequest` - Transcription fallback configuration
506
+
507
+ ## Error Handling
508
+
509
+ The client raises exceptions for HTTP errors:
510
+
511
+ ```python
512
+ try:
513
+ response = await client.agent_run(request)
514
+ except Exception as e:
515
+ print(f"Error: {e}")
516
+ ```
517
+
518
+ For more granular error handling:
519
+
520
+ ```python
521
+ import httpx
522
+
523
+ try:
524
+ response = await client.ping()
525
+ except httpx.HTTPStatusError as e:
526
+ print(f"HTTP error: {e.response.status_code}")
527
+ except httpx.RequestError as e:
528
+ print(f"Request error: {e}")
529
+ ```
530
+
531
+ ## Development
532
+
533
+ ### Running Tests
534
+
535
+ ```bash
536
+ # Install development dependencies
537
+ pip install -e ".[testing]"
538
+
539
+ # Run tests
540
+ pytest tests/
541
+ ```
542
+
543
+ ### Type Checking
544
+
545
+ The library is fully typed. Run type checking with:
546
+
547
+ ```bash
548
+ pip install mypy
549
+ mypy livellm
550
+ ```
551
+
552
+ ## Requirements
553
+
554
+ - Python 3.10+
555
+ - httpx >= 0.27.0
556
+ - pydantic >= 2.0.0
557
+
558
+ ## License
559
+
560
+ MIT License - see [LICENSE](LICENSE) file for details.
561
+
562
+ ## Contributing
563
+
564
+ Contributions are welcome! Please feel free to submit a Pull Request.
565
+
566
+ ## Links
567
+
568
+ - [GitHub Repository](https://github.com/qalby-tech/livellm-client-py)
569
+ - [Issue Tracker](https://github.com/qalby-tech/livellm-client-py/issues)
570
+
571
+ ## Changelog
572
+
573
+ See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
@@ -0,0 +1,17 @@
1
+ livellm/__init__.py,sha256=JG_0-UCfQI_3D0Y2PzobZLS5OhJwK76i8t81ye0KpfY,279
2
+ livellm/livellm.py,sha256=0C4LpQy3EOzxNQ6ltIZqStquYuV1WoKcJSFMVtkI4Sk,8635
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=AybOrNEP_5JwgForVTWGlK39PWvvapjj4UP7sx3e5qU,1144
7
+ livellm/models/agent/__init__.py,sha256=KVm6AgQoWEaoq47QAG4Ou4NimoXOTkjXC-0-gnMRLZ8,476
8
+ livellm/models/agent/agent.py,sha256=2qCh-SsHPhrL7-phv0HpojNgixXg42FhVW8tOv8K7z8,980
9
+ livellm/models/agent/chat.py,sha256=whDuFo8ddR6dPwKo0mMZS7LzCBkL3sVb4lr7tVXjg-M,983
10
+ livellm/models/agent/tools.py,sha256=gHyVUjK6HzXB6Sd64TIM6pLjmTKSGe-fOEH9ELs5dgg,1398
11
+ livellm/models/audio/__init__.py,sha256=sz2NxCOfFGVvp-XQUsdgOR_TYBO1Wb-8LLXaZDEiAZk,282
12
+ livellm/models/audio/speak.py,sha256=4cJhXonImeohL2Fltc2ub_aCGeTAoew6Hnz-myrrR8k,1001
13
+ livellm/models/audio/transcribe.py,sha256=0XtK_f5cYPO4VMD0lh6tYYQQPFaj4g3N2eK7nzuEjKY,2111
14
+ livellm-1.1.0.dist-info/METADATA,sha256=RuCnzRRlAF5VhAmpyalSqhx2aWYrJlSdPWAmt92GFIo,15207
15
+ livellm-1.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ livellm-1.1.0.dist-info/licenses/LICENSE,sha256=yapGO2C_00ymEx6TADdbU8Oyc1bWOrZY-fjP-agmFL4,1071
17
+ livellm-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Kamil Saliamov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.