"""Minimal OpenAI Responses API client for TIDE's structured narrative. The client uses the standard OpenAI REST endpoint directly, with no SDK dependency. ``OPENAI_API_KEY`` is the only required setting. The model and API base can be overridden with ``OPENAI_MODEL`` and ``OPENAI_BASE_URL``. """ from __future__ import annotations import json import os import shlex import time from dataclasses import dataclass from pathlib import Path from typing import Any from urllib import error, request DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" DEFAULT_OPENAI_MODEL = "gpt-5.4-mini" DEFAULT_REASONING_EFFORT = "low" NARRATIVE_OUTPUT_SCHEMA = { "type": "object", "properties": { "takeaway": {"type": "string", "minLength": 1}, "summary": { "type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "maxItems": 4, }, "actions": { "type": "array", "items": { "type": "object", "properties": { "severity": { "type": "string", "enum": ["review", "completeness"], }, "text": {"type": "string", "minLength": 1}, }, "required": ["severity", "text"], "additionalProperties": False, }, }, }, "required": ["takeaway", "summary", "actions"], "additionalProperties": False, } class LlmError(RuntimeError): """Raised when the LLM cannot return a usable JSON object.""" # --------------------------------------------------------------------------- # # .env loading # --------------------------------------------------------------------------- # def load_dotenv(dotenv_path: str | Path = ".env", *, override: bool = False) -> None: path = Path(dotenv_path).expanduser() if not path.exists(): return for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line[len("export ") :].strip() if "=" not in line: continue key, value = line.split("=", 1) key = key.strip() if not key or (not override and key in os.environ): continue value = value.strip() try: parts = shlex.split(value, posix=True) value = parts[0] if len(parts) == 1 else value except ValueError: pass os.environ[key] = value # --------------------------------------------------------------------------- # # Provider # --------------------------------------------------------------------------- # @dataclass(frozen=True) class OpenAISettings: api_key: str model: str = DEFAULT_OPENAI_MODEL base_url: str = DEFAULT_OPENAI_BASE_URL reasoning_effort: str = DEFAULT_REASONING_EFFORT timeout_seconds: float = 45.0 max_retries: int = 2 retry_backoff_seconds: float = 2.0 max_output_tokens: int = 1500 @property def url(self) -> str: return f"{self.base_url.rstrip('/')}/responses" class OpenAIResponsesClient: def __init__(self, settings: OpenAISettings) -> None: self.settings = settings def complete_json(self, *, system_prompt: str, user_prompt: str) -> dict[str, Any]: payload: dict[str, Any] = { "model": self.settings.model, "instructions": system_prompt, "input": user_prompt, "reasoning": {"effort": self.settings.reasoning_effort}, "max_output_tokens": self.settings.max_output_tokens, "store": False, "text": { "verbosity": "low", "format": { "type": "json_schema", "name": "tide_narrative", "strict": True, "schema": NARRATIVE_OUTPUT_SCHEMA, }, }, } body = self._post_with_retries(payload) if body.get("error"): raise LlmError(f"OpenAI response error: {body['error']!r}") status = body.get("status") if status not in {None, "completed"}: raise LlmError(f"OpenAI response did not complete (status={status!r}).") return _parse_json_object(_extract_output_text(body)) def _post_with_retries(self, payload: dict[str, Any]) -> dict[str, Any]: attempts = max(int(self.settings.max_retries), 0) + 1 last: Exception | None = None for attempt in range(1, attempts + 1): try: return self._post_once(payload) except LlmError as exc: last = exc if not _retryable(str(exc)) or attempt >= attempts: raise _sleep(attempt, self.settings.retry_backoff_seconds) except (TimeoutError, OSError) as exc: last = exc if attempt >= attempts: break _sleep(attempt, self.settings.retry_backoff_seconds) raise LlmError(f"OpenAI request failed after {attempts} attempt(s): {last}") def _post_once(self, payload: dict[str, Any]) -> dict[str, Any]: data = json.dumps(payload).encode("utf-8") req = request.Request( self.settings.url, data=data, headers={ "Authorization": f"Bearer {self.settings.api_key}", "Content-Type": "application/json", }, method="POST", ) try: with request.urlopen(req, timeout=self.settings.timeout_seconds) as response: raw = response.read().decode("utf-8") except error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace").strip()[:800] raise LlmError(f"{exc.code} response from OpenAI; body: {detail}") from exc except error.URLError as exc: raise OSError(f"OpenAI request failed: {exc.reason}") from exc try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise LlmError(f"OpenAI returned non-JSON: {raw[:300]}") from exc if not isinstance(parsed, dict): raise LlmError("OpenAI response body was not a JSON object.") return parsed def build_client(dotenv_path: str | Path | None = None) -> OpenAIResponsesClient | None: """Return a client if credentials are present, else None (LLM is optional).""" status = llm_status(dotenv_path) if not status["configured"]: return None return OpenAIResponsesClient( OpenAISettings( api_key=os.environ["OPENAI_API_KEY"].strip(), model=str(status["model"]), base_url=_env_value("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL), reasoning_effort=_env_value( "OPENAI_REASONING_EFFORT", DEFAULT_REASONING_EFFORT, ), ) ) def llm_status(dotenv_path: str | Path | None = None) -> dict[str, Any]: """Return safe configuration metadata without exposing credential values.""" if dotenv_path: load_dotenv(dotenv_path) disabled = _truthy(os.getenv("TIDE_DISABLE_LLM")) return { "configured": bool((os.getenv("OPENAI_API_KEY") or "").strip()) and not disabled, "provider": "openai", "model": _env_value("OPENAI_MODEL", DEFAULT_OPENAI_MODEL), "disabled": disabled, } def _extract_output_text(body: dict[str, Any]) -> str: text_parts: list[str] = [] for item in body.get("output", []): if not isinstance(item, dict) or item.get("type") != "message": continue for part in item.get("content", []): if not isinstance(part, dict): continue if part.get("type") == "refusal": detail = str(part.get("refusal", "Model refused the request.")) raise LlmError(f"OpenAI refused the narrative request: {detail[:300]}") if part.get("type") == "output_text" and part.get("text"): text_parts.append(str(part["text"])) if not text_parts: raise LlmError(f"OpenAI response contained no output text: {body!r}") return "".join(text_parts) def _truthy(value: str | None) -> bool: return (value or "").strip().casefold() in {"1", "true", "yes", "on"} def _env_value(name: str, default: str) -> str: return (os.getenv(name) or "").strip() or default def _parse_json_object(content: Any) -> dict[str, Any]: text = content if isinstance(content, str) else str(content or "") text = text.strip() try: parsed = json.loads(text) except json.JSONDecodeError: start, end = text.find("{"), text.rfind("}") if start >= 0 and end >= start: parsed = json.loads(text[start : end + 1]) else: raise LlmError(f"Model did not return JSON: {text[:200]}") if not isinstance(parsed, dict): raise LlmError("Model returned JSON but not an object.") return parsed def _retryable(message: str) -> bool: return message.startswith("429 ") or any(message.startswith(f"{c} ") for c in range(500, 600)) def _sleep(attempt: int, backoff: float) -> None: delay = max(float(backoff), 0.0) * (2 ** max(attempt - 1, 0)) if delay > 0: time.sleep(delay)