Files
leocrm/app/ai/llm_client.py
T

215 lines
8.1 KiB
Python
Raw Normal View History

2026-07-23 08:42:26 +02:00
"""Configurable LLM client — supports LiteLLM (100+ providers) or mock/stub mode.
2026-07-23 08:42:26 +02:00
Reads AI_MODEL, AI_API_KEY, AI_PROVIDER from environment. If not set, uses mock mode
which returns predefined actions based on keyword matching. This allows
tests to run without external API dependencies.
2026-07-23 08:42:26 +02:00
LiteLLM provides a unified interface to OpenAI, Anthropic, Google, Azure,
AWS Bedrock, Ollama, and many more providers.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any
2026-07-23 08:42:26 +02:00
import litellm
logger = logging.getLogger(__name__)
class LLMResponse:
"""Structured LLM response containing proposed actions."""
def __init__(
self, message: str, proposed_actions: list[dict[str, Any]], confidence: float = 0.8
):
self.message = message
self.proposed_actions = proposed_actions
self.confidence = confidence
def to_dict(self) -> dict[str, Any]:
return {
"message": self.message,
"proposed_actions": self.proposed_actions,
"confidence": self.confidence,
}
class LLMClient:
"""LLM client that translates natural language to proposed API actions.
Modes:
2026-07-23 08:42:26 +02:00
- If AI_MODEL and AI_API_KEY are set: calls LiteLLM chat completions API
- Otherwise: mock/stub mode with keyword-based action mapping
2026-07-23 08:42:26 +02:00
LiteLLM model format: "provider/model_name" (e.g. "openai/gpt-4o", "anthropic/claude-3-sonnet", "ollama/llama3")
"""
def __init__(
2026-07-23 08:42:26 +02:00
self, model: str | None = None, api_key: str | None = None, api_base: str | None = None,
provider: str | None = None,
) -> None:
self.model = model or os.environ.get("AI_MODEL", "")
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
2026-07-23 08:42:26 +02:00
self.api_base = api_base or os.environ.get("AI_API_BASE", "")
self.provider = provider or os.environ.get("AI_PROVIDER", "openai")
self.is_mock = not bool(self.model and self.api_key)
async def generate(self, user_query: str, context: dict[str, Any] | None = None) -> LLMResponse:
"""Generate proposed actions from natural language query.
Args:
user_query: Natural language input from user
context: Optional context (e.g. current page, selected entity)
Returns:
LLMResponse with message and proposed_actions list
"""
if self.is_mock:
return await self._mock_generate(user_query, context or {})
return await self._api_generate(user_query, context or {})
async def _mock_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
"""Mock/stub mode — keyword-based action mapping for tests."""
from app.ai.action_mapper import map_query_to_actions
actions = map_query_to_actions(query, context)
if actions:
return LLMResponse(
message=f"I found {len(actions)} possible action(s) based on your request.",
proposed_actions=actions,
confidence=0.85,
)
return LLMResponse(
message="I couldn't determine a specific action from your request. Could you be more specific?",
proposed_actions=[],
confidence=0.3,
)
async def _api_generate(self, query: str, context: dict[str, Any]) -> LLMResponse:
2026-07-23 08:42:26 +02:00
"""Call LLM via LiteLLM unified interface.
Supports 100+ providers through a single API:
- OpenAI: "openai/gpt-4o"
- Anthropic: "anthropic/claude-3-sonnet"
- Google: "gemini/gemini-pro"
- Azure: "azure/<deployment-name>"
- Ollama: "ollama/llama3"
- And many more.
"""
system_prompt = self._build_system_prompt(context)
user_prompt = f"User request: {query}\n\nRespond with proposed actions as JSON."
2026-07-23 08:42:26 +02:00
# Build LiteLLM model string: "provider/model" or just "model" for OpenAI compat
if self.provider and self.provider != "openai":
litellm_model = f"{self.provider}/{self.model}"
else:
litellm_model = self.model
# Build kwargs for litellm.acompletion
kwargs: dict[str, Any] = {
"model": litellm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
"max_tokens": 1000,
}
2026-07-23 08:42:26 +02:00
# Add API key if set
if self.api_key:
kwargs["api_key"] = self.api_key
# Add API base if set (for self-hosted or custom endpoints)
if self.api_base:
kwargs["api_base"] = self.api_base
try:
response = await litellm.acompletion(**kwargs)
content = response.choices[0].message.content
return self._parse_llm_response(content)
except Exception as e:
logger.error("LiteLLM API call failed: %s", e)
# Fall back to mock mode on API error
return LLMResponse(
message=f"LLM API call failed: {e}. Falling back to keyword matching.",
proposed_actions=[],
confidence=0.1,
)
def _build_system_prompt(self, context: dict[str, Any]) -> str:
"""Build system prompt describing available API actions."""
available_apis = [
2026-07-23 08:42:26 +02:00
{"method": "GET", "path": "/api/v1/contacts", "description": "List contacts (persons and companies)"},
{"method": "POST", "path": "/api/v1/contacts", "description": "Create a contact (person or company)"},
{
"method": "GET",
2026-07-23 08:42:26 +02:00
"path": "/api/v1/contacts/{id}",
"description": "Get contact details",
},
{
"method": "PATCH",
2026-07-23 08:42:26 +02:00
"path": "/api/v1/contacts/{id}",
"description": "Update a contact",
},
{
"method": "DELETE",
2026-07-23 08:42:26 +02:00
"path": "/api/v1/contacts/{id}",
"description": "Delete a contact",
},
{"method": "GET", "path": "/api/v1/workflows", "description": "List workflows"},
{"method": "POST", "path": "/api/v1/workflows", "description": "Create a workflow"},
2026-07-23 08:42:26 +02:00
{"method": "GET", "path": "/api/v1/calendar/entries", "description": "List calendar entries"},
{"method": "POST", "path": "/api/v1/calendar/entries", "description": "Create a calendar entry"},
{"method": "GET", "path": "/api/v1/dms/files", "description": "List DMS files"},
]
context_str = json.dumps(context) if context else "{}"
return (
"You are an AI copilot for LeoCRM. Based on the user's natural language request, "
"propose one or more API actions. Always respond with a JSON object containing: "
'"message": a human-readable summary, '
'"proposed_actions": an array of {method, path, body, description, confidence}. '
f"Available API endpoints: {json.dumps(available_apis)}. "
f"Current context: {context_str}. "
"Never execute actions directly — only propose them for user confirmation."
)
def _parse_llm_response(self, content: str) -> LLMResponse:
"""Parse LLM JSON response into LLMResponse."""
try:
parsed = json.loads(content)
return LLMResponse(
message=parsed.get("message", "Here are the proposed actions."),
proposed_actions=parsed.get("proposed_actions", []),
confidence=parsed.get("confidence", 0.8),
)
except (json.JSONDecodeError, KeyError):
logger.warning("Failed to parse LLM response as JSON: %s", content[:200])
return LLMResponse(
message=content,
proposed_actions=[],
confidence=0.3,
)
# Global client instance
_client: LLMClient | None = None
def get_llm_client() -> LLMClient:
"""Get or create the global LLM client instance."""
global _client
if _client is None:
_client = LLMClient()
return _client
def reset_llm_client() -> None:
"""Reset the global client (for testing)."""
global _client
_client = None