Skip to content

Commit 518dee6

Browse files
committed
fixed streaming errors and improved formatting
1 parent 1c7ae13 commit 518dee6

File tree

11 files changed

+208
-44
lines changed

11 files changed

+208
-44
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to LocalLab will be documented in this file.
44

5+
## [0.4.45] - 2024-03-14
6+
7+
### Fixed
8+
- Fixed Python client initialization error "'str' object has no attribute 'headers'"
9+
- Updated client package to handle string URLs in constructor
10+
- Bumped client package version to 1.0.2
11+
- Updated documentation with correct client initialization examples
12+
513
## [0.4.31] - 2024-03-14
614

715
### Fixed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import asyncio
2+
from locallab.client import LocalLabClient
3+
4+
async def main():
5+
# Initialize client with URL string
6+
client = LocalLabClient("http://localhost:8000")
7+
8+
try:
9+
# Test health check
10+
healthy = await client.health_check()
11+
print(f"Server health: {healthy}\n")
12+
13+
# Test basic generation
14+
response = await client.generate("I want to see your work! What are you doing?")
15+
print("Generation response:")
16+
print(response.text)
17+
print()
18+
19+
# Test streaming with proper spacing
20+
print("Streaming response:")
21+
async for token in client.stream_generate("What are you working on? Tell me about your current project."):
22+
print(token, end="", flush=True)
23+
print("\n")
24+
25+
# Test chat with context
26+
messages = [
27+
{"role": "system", "content": "You are a helpful assistant"},
28+
{"role": "user", "content": "Tell me about Paris"},
29+
{"role": "assistant", "content": "Paris is the capital of France."},
30+
{"role": "user", "content": "What's the most famous landmark there?"}
31+
]
32+
chat_response = await client.chat(messages)
33+
print("\nChat response:")
34+
print(chat_response.choices[0].message.content)
35+
36+
except Exception as e:
37+
print(f"Error: {str(e)}")
38+
finally:
39+
await client.close()
40+
41+
if __name__ == "__main__":
42+
asyncio.run(main())

client/python_client/locallab/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
RateLimitError,
2020
)
2121

22-
__version__ = "1.0.1"
22+
__version__ = "1.0.2"
2323
__author__ = "Utkarsh"
2424
__email__ = "utkarshweb2023@gmail.com"
2525

@@ -36,4 +36,4 @@
3636
"LocalLabError",
3737
"ValidationError",
3838
"RateLimitError",
39-
]
39+
]
Binary file not shown.
Binary file not shown.

client/python_client/locallab/client.py

Lines changed: 141 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import aiohttp
55
import websockets
66
from pydantic import BaseModel, Field
7+
import logging
8+
9+
logger = logging.getLogger(__name__)
710

811
class LocalLabConfig(BaseModel):
912
base_url: str
@@ -29,22 +32,23 @@ class Usage(BaseModel):
2932
total_tokens: int
3033

3134
class GenerateResponse(BaseModel):
32-
response: str
33-
model_id: str
34-
usage: Usage
35+
"""Response model for text generation"""
36+
text: str # Changed from 'response' to 'text' to match server
37+
model: str # Changed from 'model_id' to 'model' to match server
38+
usage: Optional[Usage] = None # Made usage optional since server might not always send it
3539

3640
class ChatChoice(BaseModel):
3741
message: ChatMessage
38-
finish_reason: str
42+
finish_reason: Optional[str] = None # Made optional
3943

4044
class ChatResponse(BaseModel):
4145
choices: List[ChatChoice]
42-
usage: Usage
46+
usage: Optional[Usage] = None # Made usage optional
4347

4448
class BatchResponse(BaseModel):
4549
responses: List[str]
46-
model_id: str
47-
usage: Usage
50+
model: str # Changed from 'model_id' to 'model'
51+
usage: Optional[Usage] = None # Made usage optional
4852

4953
class ModelInfo(BaseModel):
5054
name: str
@@ -89,8 +93,12 @@ def __init__(self, message: str, retry_after: int):
8993
self.retry_after = retry_after
9094

9195
class LocalLabClient:
92-
def __init__(self, config: Union[LocalLabConfig, Dict[str, Any]]):
93-
if isinstance(config, dict):
96+
def __init__(self, config: Union[str, LocalLabConfig, Dict[str, Any]]):
97+
"""Initialize the client with either a URL string or config object"""
98+
if isinstance(config, str):
99+
# If just a URL string is provided, create a config object
100+
config = LocalLabConfig(base_url=config)
101+
elif isinstance(config, dict):
94102
config = LocalLabConfig(**config)
95103
self.config = config
96104
self.session: Optional[aiohttp.ClientSession] = None
@@ -157,32 +165,137 @@ async def _request(self, method: str, path: str, **kwargs) -> Any:
157165
raise LocalLabError(str(e), "CONNECTION_ERROR")
158166
await asyncio.sleep(2 ** attempt)
159167

160-
async def generate(self, prompt: str, options: Optional[Union[GenerateOptions, Dict]] = None) -> GenerateResponse:
161-
"""Generate text from prompt"""
162-
if isinstance(options, dict):
163-
options = GenerateOptions(**options)
164-
data = {"prompt": prompt, **(options.model_dump() if options else {})}
165-
response = await self._request("POST", "/generate", json=data)
166-
return GenerateResponse(**response)
167-
168168
async def stream_generate(self, prompt: str, options: Optional[Union[GenerateOptions, Dict]] = None) -> AsyncGenerator[str, None]:
169169
"""Stream generated text"""
170170
if isinstance(options, dict):
171171
options = GenerateOptions(**options)
172-
if options:
173-
options.stream = True
174-
else:
175-
options = GenerateOptions(stream=True)
172+
if options is None:
173+
options = GenerateOptions()
176174

177-
data = {"prompt": prompt, **options.model_dump()}
178-
async with self.session.post("/generate/stream", json=data) as response:
175+
# Ensure stream is True and format data correctly
176+
data = {
177+
"prompt": prompt,
178+
"stream": True,
179+
"max_tokens": options.max_length,
180+
"temperature": options.temperature,
181+
"top_p": options.top_p,
182+
"model": options.model_id
183+
}
184+
# Remove None values
185+
data = {k: v for k, v in data.items() if v is not None}
186+
187+
async with self.session.post("/generate", json=data) as response:
188+
if response.status != 200:
189+
try:
190+
error_data = await response.json()
191+
error_msg = error_data.get("detail", "Streaming failed")
192+
logger.error(f"Streaming error: {error_msg}")
193+
yield f"\nError: {error_msg}"
194+
return
195+
except:
196+
yield "\nError: Streaming failed"
197+
return
198+
199+
buffer = ""
200+
current_sentence = ""
201+
last_token_was_space = False
202+
179203
async for line in response.content:
180204
if line:
181205
try:
182-
data = json.loads(line)
183-
yield data["response"]
184-
except json.JSONDecodeError:
185-
yield line.decode().strip()
206+
line = line.decode('utf-8').strip()
207+
# Skip empty lines
208+
if not line:
209+
continue
210+
211+
# Handle SSE format
212+
if line.startswith("data: "):
213+
line = line[6:] # Remove "data: " prefix
214+
215+
# Skip control messages
216+
if line in ["[DONE]", "[ERROR]"]:
217+
continue
218+
219+
try:
220+
# Try to parse as JSON
221+
data = json.loads(line)
222+
text = data.get("text", data.get("response", ""))
223+
except json.JSONDecodeError:
224+
# If not JSON, use the line as is
225+
text = line
226+
227+
if text:
228+
# Clean up any special tokens
229+
text = text.replace("<|", "").replace("|>", "")
230+
text = text.replace("<", "").replace(">", "")
231+
text = text.replace("[", "").replace("]", "")
232+
text = text.replace("{", "").replace("}", "")
233+
text = text.replace("data:", "")
234+
text = text.replace("��", "")
235+
text = text.replace("\\n", "\n")
236+
text = text.replace("|user|", "")
237+
text = text.replace("|The", "The")
238+
text = text.replace("/|assistant|", "").replace("/|user|", "")
239+
240+
# Add space between words if needed
241+
if (not text.startswith(" ") and
242+
not text.startswith("\n") and
243+
not last_token_was_space and
244+
buffer and
245+
not buffer.endswith(" ") and
246+
not buffer.endswith("\n")):
247+
text = " " + text
248+
249+
# Update tracking variables
250+
buffer += text
251+
current_sentence += text
252+
last_token_was_space = text.endswith(" ") or text.endswith("\n")
253+
254+
# Check for sentence completion
255+
if any(current_sentence.endswith(p) for p in [".", "!", "?", "\n"]):
256+
current_sentence = ""
257+
258+
yield text
259+
260+
except Exception as e:
261+
logger.error(f"Error processing stream chunk: {str(e)}")
262+
yield f"\nError: {str(e)}"
263+
return
264+
265+
async def generate(self, prompt: str, options: Optional[Union[GenerateOptions, Dict]] = None) -> GenerateResponse:
266+
"""Generate text from prompt"""
267+
if isinstance(options, dict):
268+
options = GenerateOptions(**options)
269+
if options is None:
270+
options = GenerateOptions()
271+
272+
# Format data consistently with stream_generate
273+
data = {
274+
"prompt": prompt,
275+
"max_tokens": options.max_length,
276+
"temperature": options.temperature,
277+
"top_p": options.top_p,
278+
"model": options.model_id,
279+
"stream": False
280+
}
281+
# Remove None values
282+
data = {k: v for k, v in data.items() if v is not None}
283+
284+
response = await self._request("POST", "/generate", json=data)
285+
text = response.get("text", response.get("response", ""))
286+
if isinstance(text, str):
287+
# Clean up any special tokens
288+
text = text.replace("<|", "").replace("|>", "")
289+
text = text.replace("<", "").replace(">", "")
290+
text = text.replace("[", "").replace("]", "")
291+
text = text.replace("{", "").replace("}", "")
292+
text = text.strip()
293+
294+
return GenerateResponse(
295+
text=text,
296+
model=response.get("model", response.get("model_id", "")),
297+
usage=response.get("usage")
298+
)
186299

187300
async def chat(self, messages: List[Union[ChatMessage, Dict]], options: Optional[Union[GenerateOptions, Dict]] = None) -> ChatResponse:
188301
"""Chat completion"""
@@ -281,4 +394,4 @@ async def on_message(self, callback: callable) -> None:
281394
data = json.loads(message)
282395
await callback(data)
283396
except json.JSONDecodeError:
284-
await callback(message)
397+
await callback(message)

client/python_client/locallab_client.egg-info/PKG-INFO

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
Metadata-Version: 2.2
1+
Metadata-Version: 2.4
22
Name: locallab-client
33
Version: 1.0.1
44
Summary: Python client for connecting to LocalLab servers - Interact with AI models running on LocalLab
5-
Home-page: https://github.com/yourusername/locallab-client
6-
Author: Your Name
5+
Home-page: https://github.com/UtkarshTheDev/LocalLab
6+
Author: Utkarsh Tiwari
77
Author-email: Utkarsh <utkarshweb2023@gmail.com>
88
License: MIT
9-
Project-URL: Homepage, https://github.com/Developer-Utkarsh/LocalLab
10-
Project-URL: Documentation, https://github.com/Developer-Utkarsh/LocalLab#readme
11-
Project-URL: Repository, https://github.com/Developer-Utkarsh/LocalLab.git
12-
Project-URL: Issues, https://github.com/Developer-Utkarsh/LocalLab/issues
9+
Project-URL: Homepage, https://github.com/UtkarshTheDev/LocalLab
10+
Project-URL: Documentation, https://github.com/UtkarshTheDev/LocalLab#readme
11+
Project-URL: Repository, https://github.com/UtkarshTheDev/LocalLab.git
12+
Project-URL: Issues, https://github.com/UtkarshTheDev/LocalLab/issues
1313
Keywords: llm,ai,client,api,inference
1414
Classifier: Development Status :: 4 - Beta
1515
Classifier: Intended Audience :: Developers

client/python_client/setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name="locallab-client",
8-
version="1.0.0",
8+
version="1.0.2",
99
author="Utkarsh Tiwari",
1010
author_email="utkarshweb2023@gmail.com",
1111
description="Official Python client for LocalLab - A local LLM server",
@@ -32,6 +32,7 @@
3232
"aiohttp>=3.8.0",
3333
"typing-extensions>=4.0.0",
3434
"pydantic>=2.0.0",
35+
"websockets>=10.0",
3536
],
3637
extras_require={
3738
"dev": [

locallab/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
LocalLab - A lightweight AI inference server for running LLMs locally
33
"""
44

5-
__version__ = "0.4.44"
5+
__version__ = "0.4.45"
66

77
# Only import what's necessary initially, lazy-load the rest
88
from .logger import get_logger

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name="locallab",
8-
version="0.4.44",
8+
version="0.4.45",
99
packages=find_packages(include=["locallab", "locallab.*"]),
1010
install_requires=[
1111
"fastapi>=0.95.0,<1.0.0",

tests/test_endpoints.zsh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ test_endpoint "/generate/batch" "POST" '{
7979
}'
8080

8181
# Test Loading Model
82-
test_endpoint "/models/load" "POST" '{
83-
"model_id": "microsoft/phi-2"
82+
# test_endpoint "/models/load" "POST" '{
83+
# "model_id": "microsoft/phi-2"
8484
}'
8585
8686
# Test System Instructions
@@ -89,6 +89,6 @@ test_endpoint "/system/instructions" "POST" '{
8989
}'
9090
9191
# Test Unloading Model
92-
test_endpoint "/models/unload" "POST"
93-
92+
# test_endpoint "/models/unload" "POST"
93+
#
9494
print "\n${GREEN}All tests completed!${RESET}"

0 commit comments

Comments
 (0)