ligbox-ops-platform/projects/ops-desk/api/app/agents/llm_client.py
Ligbox Spec Hub 6c4b063f76 Implement Spec 027 access matrix preview and agent bindings enforcement.
Adds RBAC matrix API/UI behind feature flags, agent×role toggles with audit,
and extends Agentic Ops with streaming chat, Kimi LLM support, and UI updates.
2026-06-20 22:02:03 +00:00

450 lines
15 KiB
Python

"""LLM providers — Spec 029 T1 (Ollama) + 034 KIMI + Groq free tier."""
from __future__ import annotations
import json
import os
import re
from collections.abc import Iterator
import httpx
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
AGENTIC_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct")
AGENTIC_EMBED_MODEL = os.getenv("AGENTIC_EMBED_MODEL", "nomic-embed-text")
AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes")
KIMI_API_KEY = os.getenv("KIMI_API_KEY", "").strip()
KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1").strip().rstrip("/")
KIMI_MODEL = os.getenv("KIMI_MODEL", "kimi-k2.5").strip()
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").strip().rstrip("/")
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant").strip()
AGENTIC_LLM_PROVIDER = os.getenv("AGENTIC_LLM_PROVIDER", "auto").lower().strip()
AGENTIC_LLM_FALLBACK = os.getenv("AGENTIC_LLM_FALLBACK", "ollama").lower().strip()
def _clean_env(value: str) -> str:
return re.split(r"\s+#", value or "", maxsplit=1)[0].strip()
def resolve_provider() -> str:
if not AGENTIC_LLM_ENABLED:
return "t0"
provider = _clean_env(AGENTIC_LLM_PROVIDER).lower() or "auto"
if provider == "auto":
if GROQ_API_KEY:
return "groq"
if KIMI_API_KEY:
return "kimi"
return "ollama"
return provider
def active_model() -> str:
p = resolve_provider()
if p == "groq":
return _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
if p == "kimi":
return _clean_env(KIMI_MODEL) or "kimi-k2.5"
if p == "ollama":
return AGENTIC_LLM_MODEL
return "t0"
def llm_status() -> dict:
provider = resolve_provider()
return {
"tier": "t1" if AGENTIC_LLM_ENABLED else "t0",
"provider": provider,
"model": active_model(),
"groq_configured": bool(GROQ_API_KEY),
"groq_base_url": _clean_env(GROQ_BASE_URL),
"kimi_configured": bool(KIMI_API_KEY),
"kimi_base_url": _clean_env(KIMI_BASE_URL),
"ollama": ollama_available(),
"ollama_url": OLLAMA_BASE_URL,
"ollama_model": AGENTIC_LLM_MODEL,
"embed_model": AGENTIC_EMBED_MODEL,
"fallback": AGENTIC_LLM_FALLBACK,
}
def ollama_available() -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{OLLAMA_BASE_URL}/api/tags").status_code == 200
except Exception:
return False
def _openai_compatible_chat(
*,
base_url: str,
api_key: str,
model: str,
messages: list[dict],
max_tokens: int,
timeout: float,
prefix: str,
) -> tuple[str, str]:
try:
with httpx.Client(timeout=timeout) as c:
r = c.post(
f"{base_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": messages,
"temperature": 0.3,
"max_tokens": max_tokens,
},
)
if r.status_code == 200:
data = r.json()
choice = (data.get("choices") or [{}])[0]
msg = choice.get("message") or {}
txt = (msg.get("content") or "").strip()
if txt:
return txt, model
if r.status_code == 429:
return ("", f"{prefix}-quota")
return ("", f"{prefix}-error-{r.status_code}")
except Exception:
return ("", f"{prefix}-error")
def _groq_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]:
if not GROQ_API_KEY:
return ("", "groq-unconfigured")
model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1"
return _openai_compatible_chat(
base_url=base,
api_key=GROQ_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=30.0,
prefix="groq",
)
def _kimi_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]:
if not KIMI_API_KEY:
return ("", "kimi-unconfigured")
model = _clean_env(KIMI_MODEL) or "kimi-k2.5"
base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1"
return _openai_compatible_chat(
base_url=base,
api_key=KIMI_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=45.0,
prefix="kimi",
)
def _ollama_chat(messages: list[dict], *, max_tokens: int = 800) -> tuple[str, str]:
if not ollama_available():
return ("", "ollama-offline")
try:
with httpx.Client(timeout=120.0) as c:
r = c.post(
f"{OLLAMA_BASE_URL}/api/chat",
json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": False},
)
if r.status_code == 200:
txt = (r.json().get("message") or {}).get("content", "").strip()
if txt:
return txt, AGENTIC_LLM_MODEL
except Exception:
pass
return ("", "ollama-error")
def _try_fallback(messages: list[dict], *, max_tokens: int, skip: str) -> tuple[str, str]:
order = []
if AGENTIC_LLM_FALLBACK == "ollama" and skip != "ollama":
order.append("ollama")
if skip != "groq" and GROQ_API_KEY:
order.append("groq")
if skip != "kimi" and KIMI_API_KEY:
order.append("kimi")
for fb in order:
if fb == "ollama":
txt, model = _ollama_chat(messages, max_tokens=max_tokens)
elif fb == "groq":
txt, model = _groq_chat(messages, max_tokens=max_tokens)
else:
txt, model = _kimi_chat(messages, max_tokens=max_tokens)
if txt:
return txt, f"{fb}:{model}"
return ("", "t0-fallback")
def _llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[str, str]:
if not AGENTIC_LLM_ENABLED:
return ("", "t0")
provider = resolve_provider()
dispatch = {
"groq": _groq_chat,
"kimi": _kimi_chat,
"ollama": _ollama_chat,
}
fn = dispatch.get(provider)
if not fn:
return ("", "t0")
txt, model = fn(messages, max_tokens=max_tokens)
if txt:
return txt, f"{provider}:{model.split(':')[-1] if ':' in model else model}"
if model.endswith("-quota"):
return ("", model)
return _try_fallback(messages, max_tokens=max_tokens, skip=provider)
def _openai_compatible_stream(
*,
base_url: str,
api_key: str,
model: str,
messages: list[dict],
max_tokens: int,
timeout: float,
) -> Iterator[str]:
try:
with httpx.Client(timeout=timeout) as c:
with c.stream(
"POST",
f"{base_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": messages,
"temperature": 0.5,
"max_tokens": max_tokens,
"stream": True,
},
) as r:
if r.status_code != 200:
return
for line in r.iter_lines():
if not line or not line.startswith("data: "):
continue
payload = line[6:].strip()
if payload == "[DONE]":
break
try:
data = json.loads(payload)
except json.JSONDecodeError:
continue
delta = (data.get("choices") or [{}])[0].get("delta") or {}
txt = delta.get("content") or ""
if txt:
yield txt
except Exception:
return
def _groq_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]:
if not GROQ_API_KEY:
return
model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant"
base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1"
yield from _openai_compatible_stream(
base_url=base,
api_key=GROQ_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=60.0,
)
def _kimi_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]:
if not KIMI_API_KEY:
return
model = _clean_env(KIMI_MODEL) or "kimi-k2.5"
base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1"
yield from _openai_compatible_stream(
base_url=base,
api_key=KIMI_API_KEY,
model=model,
messages=messages,
max_tokens=max_tokens,
timeout=60.0,
)
def _ollama_stream(messages: list[dict], *, max_tokens: int = 800) -> Iterator[str]:
if not ollama_available():
return
try:
with httpx.Client(timeout=120.0) as c:
with c.stream(
"POST",
f"{OLLAMA_BASE_URL}/api/chat",
json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": True},
) as r:
for line in r.iter_lines():
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
chunk = (data.get("message") or {}).get("content") or ""
if chunk:
yield chunk
if data.get("done"):
break
except Exception:
return
def stream_llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[Iterator[str], str]:
"""Returns (token iterator, provider label). Falls back to burst if stream empty."""
if not AGENTIC_LLM_ENABLED:
return iter(()), "t0"
provider = resolve_provider()
dispatch = {"groq": _groq_stream, "kimi": _kimi_stream, "ollama": _ollama_stream}
fn = dispatch.get(provider)
if not fn:
return iter(()), "t0"
def _gen():
got = False
for tok in fn(messages, max_tokens=max_tokens):
got = True
yield tok
if not got:
txt, model = _llm_chat(messages, max_tokens=max_tokens)
if txt:
yield txt
label = f"{provider}:{active_model()}"
return _gen(), label
def build_chat_messages(
*,
question: str,
kb_snippets: list[str] | None = None,
findings_summary: str | None = None,
ops_context: str | None = None,
user_role: str = "technician",
target_agent: str = "A6",
agent_name: str = "Copiloto",
agent_role: str = "Assistência tickets e janela humana",
history_messages: list[dict] | None = None,
) -> list[dict]:
from app.agents.chat_context_builder import RESPONSE_FORMAT_INSTRUCTION
ctx_block = ops_context or ""
if not ctx_block:
ctx_parts = [f"Operador: {user_role} (Ligbox Desk)"]
if findings_summary:
ctx_parts.append(f"Findings abertos:\n{findings_summary[:2000]}")
if kb_snippets:
ctx_parts.append("Base de conhecimento relevante:\n" + "\n---\n".join(kb_snippets[:6])[:4000])
ctx_block = "\n\n".join(ctx_parts)
system = (
f"Você é **{agent_name}** ({target_agent}) — {agent_role}.\n"
"Actua como **investigador DevOps sénior** no ecossistema Ligbox, não como FAQ genérico.\n\n"
"## Missão\n"
"Analisar o contexto operacional real, formular hipóteses fundamentadas e propor "
"**acções concretas** que o operador possa executar agora.\n\n"
"## Dados operacionais injectados\n"
f"{ctx_block}\n\n"
f"{RESPONSE_FORMAT_INSTRUCTION}"
)
messages: list[dict] = [{"role": "system", "content": system}]
for hm in history_messages or []:
role = hm.get("role")
content = (hm.get("content") or "").strip()
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({
"role": "user",
"content": (
f"Pergunta do operador ({user_role}):\n{question}\n\n"
"Responda no formato investigativo obrigatório. "
"Use os incidentes/findings/cenários do contexto quando relevantes."
),
})
return messages
def advise_human_action(
*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None
) -> tuple[str, str]:
kb = "---".join(kb_snippets or [])[:2500] or "N/A"
messages = [
{
"role": "system",
"content": "Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?",
},
{
"role": "user",
"content": f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {kb}",
},
]
txt, model = _llm_chat(messages, max_tokens=400)
if txt:
return txt, model
return (f"Investigar manualmente: {finding_title}", "t0")
def chat_context(
*,
question: str,
kb_snippets: list[str] | None = None,
findings_summary: str | None = None,
ops_context: str | None = None,
user_role: str = "technician",
target_agent: str = "A6",
agent_name: str = "Copiloto",
agent_role: str = "Assistência tickets e janela humana",
history: str | None = None,
history_messages: list[dict] | None = None,
) -> tuple[str, str]:
# Legacy string history → single user turn if no structured history
hist_msgs = list(history_messages or [])
if not hist_msgs and history:
hist_msgs = [{"role": "user", "content": f"[Histórico resumido]\n{history[:3000]}"}]
messages = build_chat_messages(
question=question,
kb_snippets=kb_snippets,
findings_summary=findings_summary,
ops_context=ops_context,
user_role=user_role,
target_agent=target_agent,
agent_name=agent_name,
agent_role=agent_role,
history_messages=hist_msgs,
)
txt, model = _llm_chat(messages, max_tokens=1200)
if txt:
return txt, model
if model == "groq-quota":
return (
"Quota Groq esgotada (free tier). Tente amanhã ou use fallback Ollama local.",
"groq-quota",
)
if model == "kimi-quota":
return (
"Conta KIMI sem saldo. Groq/Ollama disponíveis como alternativa.",
"kimi-quota",
)
return (
"Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.",
"t0",
)