"""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", )