Files
leocrm/app/ai/llm_client.py
T

874 lines
32 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.
Generic functions:
- ``llm_complete()`` — generic chat completion with retry, cost tracking
- ``llm_embed()`` — generic text embedding
- ``get_api_credentials()`` — centralised credential/provider lookup
- ``build_model()`` — centralised LiteLLM model string builder
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
2026-07-23 08:42:26 +02:00
import litellm
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────────────────────────
MAX_INPUT_CHARS = 8000
# OpenRouter for embeddings (Ollama Cloud has no embedding endpoint)
OPENROUTER_API_KEY = os.environ.get("API_KEY_OPENROUTER", "")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_EMBEDDING_MODEL = os.environ.get(
"SEARCH_EMBEDDING_MODEL", "openai/text-embedding-3-small"
)
EMBEDDING_DIMENSIONS = 768 # Must match DB column vector(768)
# Default retry settings
DEFAULT_TIMEOUT = 30
DEFAULT_MAX_RETRIES = 2
BASE_BACKOFF_SECONDS = 1.0
# Transient error keywords for retry classification
_TRANSIENT_KEYWORDS = frozenset(
{
"timeout",
"timed out",
"rate limit",
"rate_limit",
"429",
"503",
"502",
"504",
"service unavailable",
"overloaded",
"connection reset",
"connection aborted",
"temporary",
}
)
# Permanent error keywords — fail immediately, no retry
_PERMANENT_KEYWORDS = frozenset(
{
"authentication",
"auth",
"401",
"403",
"unauthorized",
"forbidden",
"invalid api key",
"invalid_api_key",
"validation",
"invalid_request",
"400",
"bad request",
"model_not_found",
"not found",
}
)
# ──────────────────────────────────────────────────────────────────────────
# Centralised helper functions
# ──────────────────────────────────────────────────────────────────────────
# ── Cost Overrun Protection (B.17) ───────────────────────────────────────────
def _get_cost_key(tenant_id: uuid.UUID | str) -> str:
"""Build the Redis cost-tracking key for the current month."""
now = datetime.now(UTC)
month_str = now.strftime("%Y-%m")
return f"cost:tenant:{tenant_id}:month:{month_str}"
async def get_tenant_monthly_cost(tenant_id: uuid.UUID | str) -> float:
"""Get the accumulated LLM cost for a tenant in the current month.
Reads from Redis key ``cost:tenant:{tenant_id}:month:{YYYY-MM}``.
Returns 0.0 if Redis is unavailable or the key doesn't exist.
"""
try:
from app.core.auth import get_redis
r = get_redis()
key = _get_cost_key(tenant_id)
val = await r.get(key)
return float(val) if val else 0.0
except Exception:
logger.debug("Failed to get tenant monthly cost from Redis")
return 0.0
async def _check_tenant_budget(
tenant_id: uuid.UUID | str | None,
db: AsyncSession | None = None,
) -> None:
"""Check if tenant has exceeded their LLM budget.
Raises ``ValueError("Tenant LLM budget exceeded")`` if the tenant's
accumulated monthly cost exceeds the configured budget and
``llm_hard_cutoff`` is enabled.
"""
if tenant_id is None:
return
from app.config import get_settings
settings = get_settings()
if settings.llm_monthly_budget_usd <= 0:
return # No budget limit configured
current_cost = await get_tenant_monthly_cost(tenant_id)
if current_cost >= settings.llm_monthly_budget_usd:
if settings.llm_hard_cutoff:
logger.warning(
"Tenant %s LLM budget exceeded: $%.2f >= $%.2f (hard cutoff)",
tenant_id, current_cost, settings.llm_monthly_budget_usd,
)
raise ValueError("Tenant LLM budget exceeded")
else:
logger.warning(
"Tenant %s LLM budget exceeded: $%.2f >= $%.2f (soft limit, no cutoff)",
tenant_id, current_cost, settings.llm_monthly_budget_usd,
)
async def _track_tenant_cost(
tenant_id: uuid.UUID | str | None,
cost_usd: float,
db: AsyncSession | None = None,
) -> float:
"""Track LLM cost in Redis and check for alert thresholds.
Increments ``cost:tenant:{tenant_id}:month:{YYYY-MM}`` by ``cost_usd``.
Returns the new total cost for the month.
"""
if tenant_id is None or cost_usd <= 0:
return 0.0
try:
from app.core.auth import get_redis
r = get_redis()
key = _get_cost_key(tenant_id)
new_total = await r.incrbyfloat(key, cost_usd)
# Set TTL to 35 days so old months auto-expire
await r.expire(key, 35 * 24 * 3600)
# Check cost alert thresholds
await _check_cost_alerts(tenant_id, new_total, db)
return float(new_total)
except Exception:
logger.debug("Failed to track tenant cost in Redis")
return 0.0
async def _check_cost_alerts(
tenant_id: uuid.UUID | str,
current_cost: float,
db: AsyncSession | None = None,
) -> None:
"""Send cost alerts at 50%, 80%, and 100% of the tenant budget.
Each threshold alert is sent only once per month (tracked via Redis flag
``cost:alerted:{tenant_id}:{threshold}``).
"""
from app.config import get_settings
settings = get_settings()
budget = settings.llm_monthly_budget_usd
if budget <= 0:
return
thresholds = [0.50, 0.80, 1.00]
now = datetime.now(UTC)
month_str = now.strftime("%Y-%m")
try:
from app.core.auth import get_redis
r = get_redis()
for threshold in thresholds:
threshold_cost = budget * threshold
if current_cost >= threshold_cost:
alert_key = f"cost:alerted:{tenant_id}:{threshold}:{month_str}"
already_alerted = await r.set(alert_key, "1", nx=True, ex=35 * 24 * 3600)
if already_alerted:
# This is a new alert — send notification
pct = int(threshold * 100)
logger.info(
"Cost alert: tenant %s reached %d%% of budget ($%.2f / $%.2f)",
tenant_id, pct, current_cost, budget,
)
# Best-effort system notification
if db is not None:
try:
# Need a user_id — try to find an admin for this tenant
from sqlalchemy import select as sa_select
from app.core.notifications import post_system_message
from app.models.user import User, UserTenant
async with db.begin_nested() if db.in_transaction() else _NoopCtx():
result = await db.execute(
sa_select(User.id).join(UserTenant, UserTenant.user_id == User.id)
.where(UserTenant.tenant_id == tenant_id)
.where(User.is_active == True) # noqa: E712
.limit(1)
)
admin_row = result.first()
if admin_row:
await post_system_message(
db=db,
tenant_id=tenant_id if isinstance(tenant_id, uuid.UUID) else uuid.UUID(str(tenant_id)),
user_id=admin_row[0],
message_type="cost_alert",
title=f"LLM Cost Alert: {pct}% of budget reached",
body=f"Current monthly LLM cost: ${current_cost:.2f} / ${budget:.2f} ({pct}%).",
severity="warning" if pct < 100 else "error",
)
except Exception:
logger.debug("Failed to send cost alert notification")
except Exception:
logger.debug("Failed to check cost alerts")
class _NoopCtx:
"""No-op async context manager for optional transaction nesting."""
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def get_api_credentials(
db: AsyncSession | None,
tenant_id: uuid.UUID | None,
) -> tuple[str | None, str | None, str | None]:
"""Get API key, base_url and provider_type for LLM/embedding calls.
Priority:
1. OpenRouter env var (API_KEY_OPENROUTER) dedicated embedding provider
2. Default AI provider from DB (fallback)
3. API_KEY_OLLAMA_CLOUD env var (last resort)
Args:
db: Optional async DB session for provider lookup.
tenant_id: Optional tenant ID for provider lookup.
Returns:
Tuple of (api_key, api_base, provider_type) — any may be ``None``.
"""
# OpenRouter is the primary embedding provider
if OPENROUTER_API_KEY:
return OPENROUTER_API_KEY, OPENROUTER_BASE_URL, "openai"
# Fallback to DB provider
if db and tenant_id:
try:
from app.plugins.builtins.contracts import get_contract
ai_contract = get_contract("ai_assistant")
if ai_contract is not None:
provider = await ai_contract.get_default_provider(db, tenant_id)
if provider and provider.api_key:
return provider.api_key, provider.base_url, provider.provider_type
except Exception:
logger.debug("Failed to get provider from DB, falling back to env")
env_key = os.environ.get("API_KEY_OLLAMA_CLOUD", "")
return (env_key if env_key else None), None, None
async def get_provider_compliance(
db: AsyncSession | None,
tenant_id: uuid.UUID | None,
) -> dict[str, Any] | None:
"""Get compliance metadata for the active AI provider.
Returns a dict with keys: ``region``, ``hosting_type``, ``dpa_status``,
``retention_policy``, ``training_on_customer_data``, ``transfer_notice``,
``allowed_data_classes``.
Returns ``None`` if no DB provider is configured (env-based fallback).
"""
if not (db and tenant_id):
return None
try:
from app.plugins.builtins.contracts import get_contract
ai_contract = get_contract("ai_assistant")
if ai_contract is None:
return None
provider = await ai_contract.get_default_provider(db, tenant_id)
if provider is None:
return None
return {
"region": getattr(provider, "region", "unknown"),
"hosting_type": getattr(provider, "hosting_type", "cloud"),
"dpa_status": getattr(provider, "dpa_status", "none"),
"retention_policy": getattr(provider, "retention_policy", ""),
"training_on_customer_data": getattr(provider, "training_on_customer_data", False),
"transfer_notice": getattr(provider, "transfer_notice", ""),
"allowed_data_classes": getattr(provider, "allowed_data_classes", []),
}
except Exception:
logger.debug("Failed to get provider compliance metadata")
return None
def check_data_class_allowed(
compliance: dict[str, Any] | None,
data_class: str,
) -> bool:
"""Check whether the configured provider may process *data_class*.
Uses :func:`app.core.sensitive_data.check_provider_compliance`.
Returns ``True`` if compliance metadata is unavailable (fail-open for
backward compatibility and mock mode).
"""
from app.core.sensitive_data import check_provider_compliance
if compliance is None:
return True
return check_provider_compliance(
compliance.get("allowed_data_classes"),
data_class,
)
def build_model(model: str, provider_type: str | None) -> str:
"""Build LiteLLM model string with provider prefix.
If ``provider_type`` is given, strips any existing prefix from ``model``
and prepends ``provider_type``.
Args:
model: Model name, optionally already prefixed (e.g. ``openai/gpt-4o``).
provider_type: Provider prefix to apply (e.g. ``openai``, ``anthropic``).
Returns:
LiteLLM-compatible model string (e.g. ``openai/gpt-4o``).
"""
if provider_type:
model_parts = model.split("/", 1)
return f"{provider_type}/{model_parts[-1]}"
return model
def _classify_error(exc: Exception) -> str:
"""Classify an exception as ``transient`` or ``permanent``.
Uses string matching on the exception message/type name against known
patterns. Falls back to ``transient`` for unknown errors (safer to retry).
Args:
exc: The exception to classify.
Returns:
``"transient"`` or ``"permanent"``.
"""
msg = str(exc).lower()
exc_type_name = type(exc).__name__.lower()
# Check permanent first — auth errors should never be retried
if any(kw in msg or kw in exc_type_name for kw in _PERMANENT_KEYWORDS):
return "permanent"
if any(kw in msg or kw in exc_type_name for kw in _TRANSIENT_KEYWORDS):
return "transient"
# asyncio.TimeoutError is always transient
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
return "transient"
# Default: treat as transient (safe to retry)
return "transient"
def _extract_cost_usd(response: Any, model: str) -> float:
"""Extract cost in USD from a LiteLLM response.
Uses ``litellm.completion_cost`` when available, otherwise returns 0.0.
Args:
response: LiteLLM response object.
model: Model string used for the call.
Returns:
Estimated cost in USD, or 0.0 if unavailable.
"""
try:
cost = litellm.completion_cost(response)
if cost is not None:
return float(cost)
except Exception:
logger.debug("litellm.completion_cost failed, using fallback")
return 0.0
def _extract_usage(response: Any) -> dict[str, int]:
"""Extract token usage from a LiteLLM response.
Args:
response: LiteLLM response object.
Returns:
Dict with ``prompt_tokens``, ``completion_tokens``, ``total_tokens``.
"""
usage = getattr(response, "usage", None)
if usage is None:
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
total_tokens = getattr(usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
# ──────────────────────────────────────────────────────────────────────────
# Generic LLM functions
# ──────────────────────────────────────────────────────────────────────────
async def llm_complete( # noqa: ASYNC109
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
temperature: float = 0.3,
max_tokens: int = 1000,
api_key: str | None = None,
api_base: str | None = None,
provider: str | None = None,
response_format: dict[str, Any] | None = None,
timeout: int = DEFAULT_TIMEOUT, # noqa: ASYNC109
max_retries: int = DEFAULT_MAX_RETRIES,
trace_id: str | None = None,
tenant_id: uuid.UUID | str | None = None,
db: AsyncSession | None = None,
) -> dict[str, Any]:
"""Generic LLM chat completion via LiteLLM with retry and cost tracking.
Supports 100+ providers through LiteLLM's unified interface.
Transient errors (timeout, rate-limit) are retried with exponential
backoff. Permanent errors (auth, validation) fail immediately.
Args:
model: Model name (e.g. ``gpt-4o``, ``openai/gpt-4o``).
messages: Chat messages list (``[{"role": ..., "content": ...}]``).
tools: Optional list of tool/function definitions.
temperature: Sampling temperature (default 0.3).
max_tokens: Maximum tokens to generate (default 1000).
api_key: Override API key. If ``None``, uses env/DB lookup.
api_base: Override API base URL.
provider: Provider prefix (e.g. ``openai``, ``anthropic``).
response_format: Optional response format spec (e.g. JSON mode).
timeout: Request timeout in seconds (default 30).
max_retries: Max retry attempts for transient errors (default 2).
trace_id: Optional trace ID for request correlation/logging.
tenant_id: Optional tenant ID for cost tracking and budget enforcement.
db: Optional DB session for cost alert notifications.
Returns:
Dict with keys: ``content``, ``usage``, ``cost_usd``, ``model``,
``raw_response`` (the LiteLLM response object for advanced use).
Raises:
ValueError: If tenant LLM budget is exceeded (hard cutoff).
Exception: Permanent errors or after exhausting retries.
"""
# Check tenant budget before making the call
await _check_tenant_budget(tenant_id, db)
# Build LiteLLM model string
litellm_model = build_model(model, provider)
# Log trace_id correlation if provided
if trace_id:
logger.debug("llm_complete trace_id=%s model=%s", trace_id, litellm_model)
# Build kwargs
kwargs: dict[str, Any] = {
"model": litellm_model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"timeout": timeout,
}
if api_key:
kwargs["api_key"] = api_key
if api_base:
kwargs["api_base"] = api_base
if tools:
kwargs["tools"] = tools
if response_format:
kwargs["response_format"] = response_format
last_exc: Exception | None = None
for attempt in range(max_retries + 1):
try:
response = await litellm.acompletion(**kwargs)
content = response.choices[0].message.content or ""
usage = _extract_usage(response)
cost_usd = _extract_cost_usd(response, litellm_model)
logger.debug(
"llm_complete success: model=%s tokens=%d cost=$%.6f attempt=%d trace_id=%s",
litellm_model,
usage["total_tokens"],
cost_usd,
attempt + 1,
trace_id or "-",
)
# Track cost in Redis for tenant budget enforcement
if tenant_id is not None and cost_usd > 0:
await _track_tenant_cost(tenant_id, cost_usd, db)
return {
"content": content,
"usage": usage,
"cost_usd": cost_usd,
"model": litellm_model,
"raw_response": response,
}
except Exception as exc:
last_exc = exc
error_class = _classify_error(exc)
if error_class == "permanent" or attempt >= max_retries:
logger.error(
"llm_complete failed (permanent/exhausted): model=%s attempt=%d error=%s",
litellm_model,
attempt + 1,
exc,
)
raise
# Transient error — retry with exponential backoff
backoff = BASE_BACKOFF_SECONDS * (2**attempt)
logger.warning(
"llm_complete transient error (attempt %d/%d), retrying in %.1fs: %s",
attempt + 1,
max_retries + 1,
backoff,
exc,
)
await asyncio.sleep(backoff)
# Should not reach here, but satisfy type checker
assert last_exc is not None
raise last_exc
async def llm_embed( # noqa: ASYNC109
texts: str | list[str],
model: str | None = None,
db: AsyncSession | None = None,
tenant_id: uuid.UUID | None = None,
api_key: str | None = None,
api_base: str | None = None,
provider: str | None = None,
dimensions: int | None = None,
timeout: int = DEFAULT_TIMEOUT, # noqa: ASYNC109
trace_id: str | None = None,
) -> list[list[float]]:
"""Generic text embedding via LiteLLM.
Handles both single-text and batch embedding. Uses centralised
credential lookup when ``api_key`` is not provided.
Args:
texts: Single text string or list of texts to embed.
model: Embedding model name (default: ``openai/text-embedding-3-small``).
db: Optional DB session for API key lookup and cost alert notifications.
tenant_id: Optional tenant ID for cost tracking and budget enforcement.
api_key: Override API key. If ``None``, uses env/DB lookup.
api_base: Override API base URL.
provider: Provider prefix override.
dimensions: Override embedding dimensions.
timeout: Request timeout in seconds (default 30).
trace_id: Optional trace ID for request correlation/logging.
Returns:
List of embedding vectors (each a list of floats). For a single
text input, returns a one-element list.
"""
# Check tenant budget before making the call
await _check_tenant_budget(tenant_id, db)
if trace_id:
logger.debug("llm_embed trace_id=%s", trace_id)
# Normalise to list input
single_input = isinstance(texts, str)
text_list = [texts] if single_input else texts
if not text_list:
return []
# Truncate inputs
truncated = [t[:MAX_INPUT_CHARS] for t in text_list]
# Resolve model
embedding_model = model or OPENROUTER_EMBEDDING_MODEL
# Resolve credentials
if not api_key:
resolved_key, resolved_base, resolved_provider = await get_api_credentials(
db, tenant_id
)
api_key = resolved_key
if not api_base:
api_base = resolved_base
if not provider:
provider = resolved_provider
# Build LiteLLM model string
litellm_model = build_model(embedding_model, provider)
# Build kwargs
litellm_kwargs: dict[str, Any] = {
"model": litellm_model,
"input": truncated[0] if single_input else truncated,
"timeout": timeout,
}
if api_key:
litellm_kwargs["api_key"] = api_key
if api_base:
litellm_kwargs["api_base"] = api_base
# Request specific dimensions for text-embedding-3 models
effective_dims = dimensions or EMBEDDING_DIMENSIONS
if "text-embedding-3" in litellm_model:
litellm_kwargs["dimensions"] = effective_dims
try:
response = await litellm.aembedding(**litellm_kwargs)
embeddings = [d["embedding"] for d in response.data]
# Track embedding cost for tenant budget enforcement
if tenant_id is not None:
try:
emb_cost = _extract_cost_usd(response, litellm_model)
if emb_cost > 0:
await _track_tenant_cost(tenant_id, emb_cost, db)
except Exception:
pass
logger.debug(
"llm_embed success: model=%s count=%d dims=%d trace_id=%s",
litellm_model,
len(embeddings),
len(embeddings[0]) if embeddings else 0,
trace_id or "-",
)
return embeddings
except Exception:
logger.warning("llm_embed failed: model=%s", litellm_model, exc_info=True)
return [[] for _ in text_list]
# ──────────────────────────────────────────────────────────────────────────
# LLMClient class (AI Copilot — backward compatible)
# ──────────────────────────────────────────────────────────────────────────
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:
"""Call LLM via ``llm_complete()`` (delegates to LiteLLM).
2026-07-23 08:42:26 +02:00
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."
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
2026-07-23 08:42:26 +02:00
try:
result = await llm_complete(
model=self.model,
messages=messages,
temperature=0.3,
max_tokens=1000,
api_key=self.api_key or None,
api_base=self.api_base or None,
provider=self.provider,
)
return self._parse_llm_response(result["content"])
2026-07-23 08:42:26 +02:00
except Exception as e:
logger.error("LLM API call failed: %s", e)
2026-07-23 08:42:26 +02:00
# 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