TIDE / src /llm.py
hiasgnpsadgd's picture
Wire OpenAI Responses API with GPT-5.4 mini
00746d1 verified
Raw
History Blame Contribute Delete
9.52 kB
"""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)