api_client.py file-level

at sha256:a · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 fix(ISR): default require_independent_second_reviewer to require Opera… · aaronrene · Sep 2, 2026
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