carrotai-chat / shared /llm_client.py
Jaco9921's picture
Initial deploy: CarrotAI chat service
8f936fd verified
Raw
History Blame Contribute Delete
8.17 kB
"""
LLM client for text and JSON completions.
Provides a unified interface for LLM calls using litellm, which supports
multiple providers (OpenAI, Anthropic, etc.).
Configuration via environment variables:
- LLM_MODEL: Model to use (default: gpt-4o-mini)
- LLM_PROVIDER: Provider prefix for litellm (default: openai)
- OPENAI_API_KEY: Required for OpenAI models
- ANTHROPIC_API_KEY: Required for Anthropic models
"""
import os
import json
import asyncio
from typing import Any, Dict, List, Optional
from litellm import completion, acompletion
# Default model and provider from environment
# Supports both formats:
# - DEFAULT_LLM_MODEL=provider/model (e.g., gemini/gemini-3-flash-preview)
# - LLM_PROVIDER + LLM_MODEL separately
def _parse_default_model():
default_llm = os.getenv("DEFAULT_LLM_MODEL", "")
if "/" in default_llm:
parts = default_llm.split("/", 1)
return parts[1], parts[0]
return os.getenv("LLM_MODEL", "gpt-4o-mini"), os.getenv("LLM_PROVIDER", "openai")
DEFAULT_MODEL, DEFAULT_PROVIDER = _parse_default_model()
class LLMError(Exception):
"""Generic error from LLM provider."""
pass
class LLMParseError(Exception):
"""Error parsing JSON response from LLM."""
pass
def _validate_api_key(provider: str) -> None:
"""
Validate that the required API key is set for the given provider.
Args:
provider: The LLM provider (openai, anthropic, etc.)
Raises:
EnvironmentError: If the required API key is not found
"""
key_mapping = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"azure": "AZURE_API_KEY",
"xai": "XAI_API_KEY",
}
env_var = key_mapping.get(provider)
if env_var and not os.getenv(env_var):
raise EnvironmentError(f"{env_var} not found in environment variables.")
def _get_full_model_name(model: str, provider: str) -> str:
"""
Get the full model name with provider prefix for litellm.
Args:
model: Base model name (e.g., 'gpt-4o-mini', 'claude-3-haiku')
provider: Provider name (e.g., 'openai', 'anthropic')
Returns:
Full model name for litellm (e.g., 'openai/gpt-4o-mini')
"""
# If model already has provider prefix, return as-is
if "/" in model:
return model
# Add provider prefix for non-OpenAI providers
if provider != "openai":
return f"{provider}/{model}"
return model
def llm_complete(
prompt: str,
system_prompt: str = "You are a helpful financial assistant.",
model: str | None = None,
provider: str | None = None,
temperature: float = 1.0
) -> str:
"""
Get a simple text completion from the LLM.
Args:
prompt: The user prompt to send
system_prompt: System instructions for the LLM
model: Model name (defaults to LLM_MODEL env var or gpt-4o-mini)
provider: Provider name (defaults to LLM_PROVIDER env var or openai)
temperature: Sampling temperature (0.0 = deterministic)
Returns:
The LLM's text response
Raises:
LLMError: If the completion fails
EnvironmentError: If required API key is missing
"""
model = model or DEFAULT_MODEL
provider = provider or DEFAULT_PROVIDER
_validate_api_key(provider)
try:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
full_model = _get_full_model_name(model, provider)
response = completion(
model=full_model,
messages=messages,
temperature=temperature
)
return response.choices[0].message.content.strip()
except Exception as e:
raise LLMError(f"LLM completion failed: {str(e)}")
async def llm_complete_async(
prompt: str = "",
system_prompt: str = "You are a helpful financial assistant.",
model: str | None = None,
provider: str | None = None,
temperature: float = 0.2,
messages: List[Dict[str, str]] | None = None,
max_tokens: int | None = None,
max_retries: int = 3,
) -> str:
"""
Async version of llm_complete with retry logic. Supports multi-turn conversations.
Args:
prompt: The user prompt (ignored if messages is provided)
system_prompt: System instructions (ignored if messages is provided)
model: Model name (defaults to LLM_MODEL env var)
provider: Provider name (defaults to LLM_PROVIDER env var)
temperature: Sampling temperature
messages: Optional pre-built messages list for multi-turn conversations
max_tokens: Optional max tokens in response
max_retries: Number of retry attempts on failure
Returns:
The LLM's text response
Raises:
LLMError: If all retries fail
"""
model = model or DEFAULT_MODEL
provider = provider or DEFAULT_PROVIDER
_validate_api_key(provider)
if messages is None:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
full_model = _get_full_model_name(model, provider)
completion_kwargs: Dict[str, Any] = {
"model": full_model,
"messages": messages,
"temperature": temperature,
}
if max_tokens is not None:
completion_kwargs["max_tokens"] = max_tokens
last_error = None
for attempt in range(max_retries):
try:
response = await acompletion(**completion_kwargs)
return response.choices[0].message.content.strip()
except Exception as e:
last_error = LLMError(f"LLM completion failed: {str(e)}")
await asyncio.sleep(2 ** attempt) # Exponential backoff
raise last_error or LLMError("LLM request failed after retries")
def llm_complete_json(
prompt: str,
system_prompt: str = "You are a data extraction assistant. Output valid JSON only.",
model: str | None = None,
provider: str | None = None,
temperature: float = 1.0,
response_model: type | None = None,
) -> Dict[str, Any]:
"""
Get a JSON response from the LLM.
Ensures the output is parsed into a Python dictionary. Optionally validates
against a Pydantic model.
Args:
prompt: The user prompt to send
system_prompt: System instructions for the LLM
model: Model name (defaults to LLM_MODEL env var or gpt-4o-mini)
provider: Provider name (defaults to LLM_PROVIDER env var or openai)
temperature: Sampling temperature (0.0 = deterministic)
response_model: Optional Pydantic model class to validate/parse response
Returns:
Parsed JSON as dictionary, or Pydantic model instance if response_model provided
Raises:
LLMError: If the completion fails
LLMParseError: If JSON parsing fails
EnvironmentError: If required API key is missing
"""
model = model or DEFAULT_MODEL
provider = provider or DEFAULT_PROVIDER
_validate_api_key(provider)
try:
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
full_model = _get_full_model_name(model, provider)
response = completion(
model=full_model,
messages=messages,
temperature=temperature,
response_format={"type": "json_object"}
)
content = response.choices[0].message.content.strip()
try:
data = json.loads(content)
except json.JSONDecodeError:
raise LLMParseError(f"Failed to parse JSON response: {content}")
# If a response model is provided, validate and return instance
if response_model is not None:
try:
return response_model(**data)
except Exception as e:
raise LLMParseError(f"Failed to validate response against {response_model.__name__}: {e}")
return data
except LLMParseError:
raise
except Exception as e:
raise LLMError(f"LLM JSON completion failed: {str(e)}")