"""LLM access layer (provider-agnostic). Dispatches to a configured provider — Gemini or Anthropic — via plain urllib (no SDKs). When no key is configured it falls back to a deterministic mock reasoner so the full loop runs offline. All paths return parsed JSON dicts. """ from __future__ import annotations import json import re import urllib.request import urllib.error from . import config class LLMError(RuntimeError): pass def _extract_json(text: str) -> dict: """Pull the first JSON object out of a model response.""" text = text.strip() # Strip ```json fences if present. fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) if fence: text = fence.group(1) start = text.find("{") end = text.rfind("}") if start == -1 or end == -1: raise LLMError(f"No JSON object found in LLM output: {text[:200]}") return json.loads(text[start : end + 1]) def _post(url: str, payload: dict, headers: dict, provider: str) -> dict: req = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={"content-type": "application/json", **headers}, method="POST", ) try: with urllib.request.urlopen(req, timeout=60) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: detail = e.read().decode("utf-8")[:400] if hasattr(e, "read") else "" raise LLMError(f"{provider} API error {e.code}: {detail}") except urllib.error.URLError as e: raise LLMError(f"Network error calling {provider} API: {e}") def _call_gemini(system: str, user: str, max_tokens: int) -> dict: url = ( "https://generativelanguage.googleapis.com/v1beta/models/" f"{config.GEMINI_MODEL}:generateContent" ) payload = { "system_instruction": {"parts": [{"text": system}]}, "contents": [{"role": "user", "parts": [{"text": user}]}], "generationConfig": { "maxOutputTokens": max_tokens, "temperature": 0.2, "responseMimeType": "application/json", }, } body = _post(url, payload, {"x-goog-api-key": config.GEMINI_API_KEY}, "Gemini") candidates = body.get("candidates", []) if not candidates: raise LLMError(f"Gemini returned no candidates: {json.dumps(body)[:300]}") parts = candidates[0].get("content", {}).get("parts", []) text = "".join(p.get("text", "") for p in parts) return _extract_json(text) def _call_anthropic(system: str, user: str, max_tokens: int) -> dict: payload = { "model": config.LLM_MODEL, "max_tokens": max_tokens, "system": system, "messages": [{"role": "user", "content": user}], } headers = { "x-api-key": config.ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", } body = _post("https://api.anthropic.com/v1/messages", payload, headers, "Anthropic") text = "".join(b.get("text", "") for b in body.get("content", [])) return _extract_json(text) def call_json(system: str, user: str, max_tokens: int = 1500) -> dict: """Single-turn LLM call that returns parsed JSON.""" if config.USE_MOCK_LLM: raise _MockSignal() # callers catch this and run their mock path if config.LLM_PROVIDER == "gemini": return _call_gemini(system, user, max_tokens) return _call_anthropic(system, user, max_tokens) class _MockSignal(Exception): """Internal sentinel: tells the caller to use its deterministic mock branch.""" def is_mock() -> bool: return config.USE_MOCK_LLM