Spaces:
Sleeping
Sleeping
| """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 | |
| # --------------------------------------------------------------------------- # | |
| 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 | |
| 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) | |