"""LLM provider interface — provider-agnostic by design. The default provider is ``null`` (no network calls, no LLM). Components must degrade gracefully when the provider is null: rule-based fallbacks kick in. Adding a provider = implementing ``LLMProvider.complete`` and registering it in ``get_provider``. Provider and model are selected exclusively via env vars (``CAYCE_OPS_LLM_PROVIDER``, ``CAYCE_OPS_LLM_MODEL``). """ from __future__ import annotations import json import urllib.request from typing import Protocol class LLMProvider(Protocol): name: str def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str: """Return the model's completion for the prompt. Raises on failure.""" ... # pragma: no cover class NullProvider: """No-op provider: never touches the network. Signals 'no LLM available'.""" name = "null" def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str: raise RuntimeError("LLM provider is 'null': no LLM configured") class OpenAICompatProvider: """Minimal OpenAI-compatible /chat/completions client (stdlib urllib only). Works with OpenAI, OpenRouter, local llama.cpp/vLLM servers, etc. """ name = "openai-compat" def __init__(self, base_url: str, api_key: str, model: str) -> None: self.base_url = base_url.rstrip("/") self.api_key = api_key self.model = model def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str: payload = { "model": self.model, "messages": ( ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}] ), "temperature": 0.0, } req = urllib.request.Request( f"{self.base_url}/chat/completions", data=json.dumps(payload).encode(), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", }, method="POST", ) with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (base_url is operator-configured) body = json.loads(resp.read().decode()) return body["choices"][0]["message"]["content"] def get_provider(cfg=None) -> LLMProvider: """Build the provider named by CAYCE_OPS_LLM_PROVIDER (default: null).""" from cayceops.config import load cfg = cfg or load() if cfg.llm_provider == "null": return NullProvider() if cfg.llm_provider == "openai-compat": return OpenAICompatProvider(cfg.llm_base_url, cfg.llm_api_key, cfg.llm_model) raise ValueError(f"unknown LLM provider: {cfg.llm_provider!r}")