Pharm_GPT / src /llm_client.py
swaraj
test
829d818
Raw
History Blame Contribute Delete
6.57 kB
"""
src/llm_client.py — Ollama / OpenAI-compatible text chat client.
Uses only text chat completions; no image generation, no g4f, no GPT-4 models.
Model and endpoint are loaded from environment variables via src/config.py.
"""
import json
import logging
from typing import Dict, Iterator, List, Optional
import requests
from src.config import (
CHAT_COMPLETION_URL,
MAX_TOKENS,
MODEL_NAME,
OLLAMA_API_KEY,
REQUEST_TIMEOUT,
TEMPERATURE,
)
logger = logging.getLogger(__name__)
class LLMError(Exception):
"""Raised when the LLM call fails for any reason."""
class OllamaChatClient:
"""
Thin wrapper around any OpenAI-compatible /chat/completions endpoint.
Tested with Ollama's built-in OpenAI-compatibility layer.
"""
def __init__(self) -> None:
self.url = CHAT_COMPLETION_URL
self.model = MODEL_NAME
self.api_key = OLLAMA_API_KEY
if not self.url:
raise LLMError(
"MODEL_BASE_URL is not configured. "
"Set it in .env or as a Hugging Face Space secret."
)
if not self.model:
raise LLMError(
"MODEL_NAME is not configured. "
"Set it in .env or as a Hugging Face Space secret."
)
def _headers(self) -> Dict[str, str]:
headers: Dict[str, str] = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
def chat(
self,
messages: List[Dict[str, str]],
system_prompt: Optional[str] = None,
) -> str:
"""
Send a chat completion request and return the assistant's reply as a string.
Args:
messages: Ordered list of {"role": ..., "content": ...} dicts.
system_prompt: Optional system instruction prepended before messages.
Returns:
The assistant's text response.
Raises:
LLMError: On any connectivity, HTTP, or parsing failure.
"""
all_messages: List[Dict[str, str]] = []
if system_prompt:
all_messages.append({"role": "system", "content": system_prompt})
all_messages.extend(messages)
payload = {
"model": self.model,
"messages": all_messages,
"max_tokens": MAX_TOKENS,
"temperature": TEMPERATURE,
"stream": False,
}
logger.debug(
"LLM request | model=%s | url=%s | messages=%d",
self.model,
self.url,
len(all_messages),
)
try:
resp = requests.post(
self.url,
headers=self._headers(),
json=payload,
timeout=REQUEST_TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"].strip()
except requests.exceptions.ConnectionError:
raise LLMError(
f"Cannot connect to the Ollama endpoint: {self.url}\n"
"Check that MODEL_BASE_URL is correct and the service is running."
)
except requests.exceptions.Timeout:
raise LLMError(
f"Request timed out after {REQUEST_TIMEOUT}s. "
"Try increasing REQUEST_TIMEOUT or check model availability."
)
except requests.exceptions.HTTPError as exc:
body = exc.response.text[:300] if exc.response is not None else ""
raise LLMError(
f"Ollama API returned HTTP {exc.response.status_code}: {body}"
)
except (KeyError, IndexError, json.JSONDecodeError) as exc:
raise LLMError(
f"Unexpected response format from Ollama endpoint: {exc}"
)
def stream_chat(
self,
messages: List[Dict[str, str]],
system_prompt: Optional[str] = None,
) -> Iterator[str]:
"""
Stream a chat completion and yield text chunks as they arrive.
Uses the Ollama/OpenAI SSE streaming format (stream=True).
Raises LLMError on connectivity, HTTP, or parsing failure.
"""
all_messages: List[Dict[str, str]] = []
if system_prompt:
all_messages.append({"role": "system", "content": system_prompt})
all_messages.extend(messages)
payload = {
"model": self.model,
"messages": all_messages,
"max_tokens": MAX_TOKENS,
"temperature": TEMPERATURE,
"stream": True,
}
logger.debug(
"LLM stream request | model=%s | messages=%d",
self.model,
len(all_messages),
)
try:
with requests.post(
self.url,
headers=self._headers(),
json=payload,
timeout=REQUEST_TIMEOUT,
stream=True,
) as resp:
resp.raise_for_status()
for raw_line in resp.iter_lines():
if not raw_line:
continue
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
obj = json.loads(data)
chunk = obj["choices"][0]["delta"].get("content", "")
if chunk:
yield chunk
except (json.JSONDecodeError, KeyError, IndexError):
continue
except requests.exceptions.ConnectionError:
raise LLMError(
f"Cannot connect to the Ollama endpoint: {self.url}\n"
"Check that MODEL_BASE_URL is correct and the service is running."
)
except requests.exceptions.Timeout:
raise LLMError(
f"Request timed out after {REQUEST_TIMEOUT}s. "
"Try increasing REQUEST_TIMEOUT or check model availability."
)
except requests.exceptions.HTTPError as exc:
body = exc.response.text[:300] if exc.response is not None else ""
raise LLMError(
f"Ollama API returned HTTP {exc.response.status_code}: {body}"
)