api_client.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """HTTP transport and headless review API client (§K5.8 / K11).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import os |
| 7 | import urllib.error |
| 8 | import urllib.request |
| 9 | from dataclasses import dataclass, field |
| 10 | from pathlib import Path |
| 11 | from typing import Protocol |
| 12 | from urllib.parse import urljoin |
| 13 | |
| 14 | from tools.freeze_reviewer.providers.api_prompt import ( |
| 15 | build_review_request_body, |
| 16 | serialize_review_request, |
| 17 | ) |
| 18 | from tools.freeze_reviewer.providers.api_response import ( |
| 19 | ProviderReviewError, |
| 20 | parse_review_response, |
| 21 | ) |
| 22 | from tools.freeze_reviewer.providers.model_hint import resolve_model_hint |
| 23 | from tools.freeze_reviewer.types import ChecklistItem, Finding, ReviewerSettings |
| 24 | |
| 25 | DEFAULT_API_KEY_VAR = "OVERSEER_REVIEW_API_KEY" |
| 26 | DEFAULT_API_URL_VAR = "OVERSEER_REVIEW_API_URL" |
| 27 | HEALTH_PATH = "/health" |
| 28 | REVIEW_PATH = "/review" |
| 29 | DEFAULT_TIMEOUT_SECONDS = 30.0 |
| 30 | |
| 31 | |
| 32 | class ProviderTransportError(Exception): |
| 33 | """Raised when the HTTP transport cannot complete a request.""" |
| 34 | |
| 35 | |
| 36 | class HttpTransport(Protocol): |
| 37 | """Injectable HTTP transport for tests (no network in CI).""" |
| 38 | |
| 39 | def request( |
| 40 | self, |
| 41 | *, |
| 42 | method: str, |
| 43 | url: str, |
| 44 | headers: dict[str, str], |
| 45 | body: bytes | None = None, |
| 46 | timeout: float = DEFAULT_TIMEOUT_SECONDS, |
| 47 | ) -> tuple[int, bytes]: |
| 48 | """Return HTTP status code and response body bytes.""" |
| 49 | |
| 50 | |
| 51 | @dataclass |
| 52 | class UrllibTransport: |
| 53 | """Production HTTP transport using stdlib urllib.""" |
| 54 | |
| 55 | def request( |
| 56 | self, |
| 57 | *, |
| 58 | method: str, |
| 59 | url: str, |
| 60 | headers: dict[str, str], |
| 61 | body: bytes | None = None, |
| 62 | timeout: float = DEFAULT_TIMEOUT_SECONDS, |
| 63 | ) -> tuple[int, bytes]: |
| 64 | request = urllib.request.Request(url, data=body, headers=headers, method=method) |
| 65 | try: |
| 66 | with urllib.request.urlopen(request, timeout=timeout) as response: |
| 67 | return response.status, response.read() |
| 68 | except urllib.error.HTTPError as exc: |
| 69 | return exc.code, exc.read() |
| 70 | except urllib.error.URLError as exc: |
| 71 | reason = getattr(exc, "reason", exc) |
| 72 | raise ProviderTransportError(str(reason)) from exc |
| 73 | |
| 74 | |
| 75 | @dataclass |
| 76 | class ReviewApiConfig: |
| 77 | """Resolved API credentials and endpoint (never logged).""" |
| 78 | |
| 79 | api_key: str |
| 80 | base_url: str |
| 81 | |
| 82 | |
| 83 | @dataclass |
| 84 | class ReviewApiClient: |
| 85 | """Headless freeze-review HTTP client.""" |
| 86 | |
| 87 | kit_root: Path | None = None |
| 88 | transport: HttpTransport = field(default_factory=UrllibTransport) |
| 89 | api_key_var: str = DEFAULT_API_KEY_VAR |
| 90 | url_var: str = DEFAULT_API_URL_VAR |
| 91 | timeout: float = DEFAULT_TIMEOUT_SECONDS |
| 92 | last_request_url: str | None = field(default=None, init=False) |
| 93 | last_request_body: bytes | None = field(default=None, init=False) |
| 94 | |
| 95 | def resolve_config(self) -> ReviewApiConfig | None: |
| 96 | """Load API config from environment; return None when incomplete.""" |
| 97 | api_key = os.environ.get(self.api_key_var, "").strip() |
| 98 | if not api_key: |
| 99 | return None |
| 100 | base_url = os.environ.get(self.url_var, "").strip().rstrip("/") |
| 101 | if not base_url: |
| 102 | return None |
| 103 | return ReviewApiConfig(api_key=api_key, base_url=base_url) |
| 104 | |
| 105 | def reachable(self) -> tuple[bool, str | None]: |
| 106 | """Probe API health without sending artifact content (§K5.8).""" |
| 107 | config = self.resolve_config() |
| 108 | if config is None: |
| 109 | if not os.environ.get(self.api_key_var, "").strip(): |
| 110 | return False, "missing API credentials" |
| 111 | return False, "missing API base URL" |
| 112 | url = urljoin(config.base_url + "/", HEALTH_PATH.lstrip("/")) |
| 113 | headers = {"Authorization": f"Bearer {config.api_key}", "Accept": "application/json"} |
| 114 | try: |
| 115 | status, _body = self.transport.request( |
| 116 | method="GET", |
| 117 | url=url, |
| 118 | headers=headers, |
| 119 | timeout=self.timeout, |
| 120 | ) |
| 121 | except ProviderTransportError as exc: |
| 122 | return False, f"API transport error: {exc}" |
| 123 | if 200 <= status < 300: |
| 124 | return True, None |
| 125 | return False, f"API health check failed with status {status}" |
| 126 | |
| 127 | def review( |
| 128 | self, |
| 129 | *, |
| 130 | artifact_text: str, |
| 131 | artifact_path: str, |
| 132 | checklist: list[ChecklistItem], |
| 133 | reviewer: ReviewerSettings, |
| 134 | ) -> list[Finding]: |
| 135 | """POST artifact to the review API and parse findings.""" |
| 136 | config = self.resolve_config() |
| 137 | if config is None: |
| 138 | raise ProviderReviewError("API credentials or base URL not configured") |
| 139 | if not reviewer.model: |
| 140 | raise ProviderReviewError("reviewer model label is required for API review") |
| 141 | |
| 142 | model_hint = resolve_model_hint(reviewer.model, kit_root=self.kit_root) |
| 143 | body = build_review_request_body( |
| 144 | artifact_text=artifact_text, |
| 145 | artifact_path=artifact_path, |
| 146 | checklist=checklist, |
| 147 | model_label=reviewer.model, |
| 148 | model_hint=model_hint, |
| 149 | ) |
| 150 | payload = serialize_review_request(body) |
| 151 | url = urljoin(config.base_url + "/", REVIEW_PATH.lstrip("/")) |
| 152 | headers = { |
| 153 | "Authorization": f"Bearer {config.api_key}", |
| 154 | "Content-Type": "application/json", |
| 155 | "Accept": "application/json", |
| 156 | } |
| 157 | self.last_request_url = url |
| 158 | self.last_request_body = payload |
| 159 | try: |
| 160 | status, response_body = self.transport.request( |
| 161 | method="POST", |
| 162 | url=url, |
| 163 | headers=headers, |
| 164 | body=payload, |
| 165 | timeout=self.timeout, |
| 166 | ) |
| 167 | except ProviderTransportError as exc: |
| 168 | raise ProviderReviewError(f"API transport error: {exc}") from exc |
| 169 | if status < 200 or status >= 300: |
| 170 | snippet = _safe_error_snippet(response_body) |
| 171 | raise ProviderReviewError(f"review API failed with status {status}: {snippet}") |
| 172 | return parse_review_response(response_body, default_path=artifact_path) |
| 173 | |
| 174 | |
| 175 | def _safe_error_snippet(body: bytes, limit: int = 120) -> str: |
| 176 | """Return a short non-secret error snippet for provider_cause.""" |
| 177 | try: |
| 178 | text = body.decode("utf-8", errors="replace").strip() |
| 179 | except Exception: |
| 180 | return "unreadable response body" |
| 181 | if not text: |
| 182 | return "empty response body" |
| 183 | try: |
| 184 | parsed = json.loads(text) |
| 185 | if isinstance(parsed, dict): |
| 186 | message = parsed.get("error") or parsed.get("message") |
| 187 | if isinstance(message, str) and message.strip(): |
| 188 | text = message.strip() |
| 189 | except json.JSONDecodeError: |
| 190 | pass |
| 191 | if len(text) > limit: |
| 192 | return text[: limit - 3] + "..." |
| 193 | return text |