Coverage for src/keel/api_delegate.py: 100%

131 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 12:05 +0000

1"""Thin I/O: hosted-API code-generation delegate (issue #548). 

2 

3Lets the s4 implement / s7 review steps run with **only an API token in the 

4environment** (``ANTHROPIC_API_KEY`` / ``OPENAI_API_KEY`` / ``GEMINI_API_KEY``, 

5or a configured OpenAI-compatible key) and no agent CLI installed. The delegate 

6follows the same no-tools contract as ``ollama:MODEL`` in ship.md s4: the 

7orchestrator owns every git/PR step and calls this module exactly once per attempt 

8to turn a prompt into text (a unified diff for the implementer, a structured 

9verdict for the reviewer). 

10 

11Design (docs/proposals/api-token-delegate.md): 

12 

13- **Stdlib only** — plain ``urllib`` over an opener that registers only 

14 HTTP/HTTPS handlers and follows no redirects (the SSRF-safe pattern from 

15 ai-jury's hosted adapters). No vendor SDK, no new runtime dependency. 

16- **Fail-soft** — like ``runner``/``git``/``github``, every failure becomes an 

17 :class:`ApiResult` with an ``error_code``; nothing here raises. HTTP 429 maps 

18 to ``rate-limit`` so the caller can honour the no-retry-on-quota rule. 

19- **Secrets** — the key is read from the environment only, validated against 

20 header injection, and scrubbed out of any error text before it is surfaced. 

21 The ``secrets`` consent-scope requirement is part of the adapter contract 

22 (ship.md s4/s7: resolve to ``HOST_AGENT`` before any key is read when the 

23 scope is absent) — the same prose-level enforcement model as every other 

24 delegate rule; this module itself performs no consent check, so any new 

25 code surface that calls :func:`generate` must gate it the same way. 

26""" 

27 

28from __future__ import annotations 

29 

30import http.client 

31import json 

32import os 

33import urllib.error 

34import urllib.request 

35from dataclasses import dataclass 

36 

37#: Response cap for a single unattended completion; overridable per call. 

38DEFAULT_MAX_TOKENS = 16384 

39#: Per-request timeout in seconds; overridable per call. 

40DEFAULT_TIMEOUT = 300 

41 

42_ANTHROPIC_VERSION = "2023-06-01" 

43 

44#: The one vendor whose endpoint and key-env name come from ``knobs.delegate_profiles`` 

45#: instead of the hardcoded table below (#666). 

46OPENAI_COMPATIBLE = "openai-compatible" 

47 

48#: vendor -> (endpoint, env var carrying the key), matching ``agents.API_VENDORS``. 

49#: Every URL here is a **hardcoded constant** — that is what keeps the SSRF story 

50#: trivial, and why a config-supplied endpoint is a separate decision (#666). 

51#: 

52#: ``google-api``'s URL carries the model in its *path* rather than the body, so it 

53#: is a template. See :func:`_unsafe_model_reason`: a model that reaches a URL path 

54#: is untrusted input in a way the other two vendors' models are not. 

55_VENDORS: dict[str, tuple[str, str]] = { 

56 "anthropic-api": ("https://api.anthropic.com/v1/messages", "ANTHROPIC_API_KEY"), 

57 "openai-api": ("https://api.openai.com/v1/chat/completions", "OPENAI_API_KEY"), 

58 "google-api": ( 

59 "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", 

60 "GEMINI_API_KEY", 

61 ), 

62} 

63 

64#: Characters a model id may contain when it is interpolated into a URL path. 

65_MODEL_PATH_OK = frozenset( 

66 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_" 

67) 

68 

69 

70def _unsafe_model_reason(model: str) -> str | None: 

71 """Reject a model id that cannot safely be interpolated into a URL path. 

72 

73 Only ``google-api`` puts the model in the URL; ``anthropic-api``/``openai-api`` 

74 carry it in the JSON body, where a stray ``/`` or ``?`` is inert. Here it is not: 

75 the model arrives from ``--delegate google-api:MODEL`` or a ``delegate-model:`` 

76 issue label, so a value containing ``/``, ``..``, ``?`` or ``#`` could retarget 

77 the request to a different path or smuggle query parameters onto a URL that also 

78 carries an API key header. Rejected rather than escaped — no real Gemini model id 

79 needs anything outside ``[A-Za-z0-9._-]``. 

80 """ 

81 if not model: 

82 return "model is empty" 

83 if not _MODEL_PATH_OK.issuperset(model): 

84 return "model contains characters that are not URL-path safe" 

85 if ".." in model: 

86 return "model contains a path traversal sequence" 

87 return None 

88 

89 

90@dataclass(frozen=True) 

91class ApiResult: 

92 """Outcome of one hosted-API generation call (fail-soft, never raised).""" 

93 

94 ok: bool 

95 text: str = "" 

96 #: machine-readable failure class: ``unknown-vendor`` | ``no-key`` | 

97 #: ``bad-key`` | ``bad-model`` | ``auth`` | ``rate-limit`` | ``http`` | 

98 #: ``network`` | ``bad-response``; ``None`` on success. 

99 error_code: str | None = None 

100 error: str | None = None 

101 

102 

103def env_key_name(vendor: str) -> str | None: 

104 """The env var a vendor's key is read from, or ``None`` for unknown vendors.""" 

105 entry = _VENDORS.get(vendor) 

106 return entry[1] if entry else None 

107 

108 

109def has_api_token(vendor: str, *, _env=os.environ) -> bool: 

110 """Dispatch probe: is the *selected* vendor's key present and non-blank? 

111 

112 Contextual by design: running with ``openai-api:`` and only 

113 ``ANTHROPIC_API_KEY`` set reports absent. 

114 """ 

115 name = env_key_name(vendor) 

116 return bool(name and _env.get(name, "").strip()) 

117 

118 

119def present_key_names(*, _env=os.environ) -> tuple[str, ...]: 

120 """Env-var *names* (never values) of the vendor keys currently set. 

121 

122 Backs the ``api-token`` runtime capability: the capability reports whether 

123 any supported vendor key is present; the per-vendor dispatch check is 

124 :func:`has_api_token`. 

125 """ 

126 return tuple(name for _, name in _VENDORS.values() if _env.get(name, "").strip()) 

127 

128 

129def _invalid_key_reason(key: str) -> str | None: 

130 """Reject keys that cannot safely travel in an HTTP header (pre-flight).""" 

131 # ⚡ Bolt Optimization: Use chained 'in' and 'or' to avoid any() generator overhead 

132 if "\r" in key or "\n" in key or "\0" in key: 

133 return "API key contains control characters" 

134 if not key.isascii(): 

135 return "API key contains non-ASCII characters" 

136 return None 

137 

138 

139def _scrub(text: str, key: str) -> str: 

140 """Remove the raw key from any surfaced text (error bodies echo headers).""" 

141 return text.replace(key, "[REDACTED:api-key]") if key else text 

142 

143 

144def _build_request( 

145 vendor: str, model: str, prompt: str, key: str, max_tokens: int, base: str | None = None 

146) -> tuple[str, dict[str, str], bytes]: 

147 """Build ``(url, headers, body)`` for one single-shot generation call. 

148 

149 ``base`` is the endpoint. It is a hardcoded ``_VENDORS`` constant for every vendor 

150 except ``openai-compatible``, where it comes from the validated profile. 

151 """ 

152 payload: dict 

153 template = base if base is not None else _VENDORS[vendor][0] 

154 url = template.format(model=model) if "{model}" in template else template 

155 if vendor == OPENAI_COMPATIBLE: 

156 # OpenAI-shaped by definition — that is what "compatible" means, and why one 

157 # profile reaches OpenRouter, Groq, DeepSeek, Together, LiteLLM and vLLM. 

158 headers = {"content-type": "application/json", "authorization": f"Bearer {key}"} 

159 payload = { 

160 "model": model, 

161 "max_tokens": max_tokens, 

162 "messages": [{"role": "user", "content": prompt}], 

163 } 

164 elif vendor == "google-api": 

165 # Key travels as a header, never as ?key= — a URL carries into logs, 

166 # referrers and error text in a way a header does not. 

167 headers = {"content-type": "application/json", "x-goog-api-key": key} 

168 payload = { 

169 "contents": [{"parts": [{"text": prompt}]}], 

170 "generationConfig": {"maxOutputTokens": max_tokens}, 

171 } 

172 elif vendor == "anthropic-api": 

173 headers = { 

174 "content-type": "application/json", 

175 "x-api-key": key, 

176 "anthropic-version": _ANTHROPIC_VERSION, 

177 } 

178 payload = { 

179 "model": model, 

180 "max_tokens": max_tokens, 

181 "messages": [{"role": "user", "content": prompt}], 

182 } 

183 else: # openai-api — the only other key in _VENDORS 

184 headers = { 

185 "content-type": "application/json", 

186 "authorization": f"Bearer {key}", 

187 } 

188 payload = { 

189 "model": model, 

190 "max_completion_tokens": max_tokens, 

191 "messages": [{"role": "user", "content": prompt}], 

192 } 

193 return url, headers, json.dumps(payload).encode("utf-8") 

194 

195 

196def _parse_content(vendor: str, data: object) -> str | None: 

197 """Extract the completion text from a decoded response, ``None`` if malformed.""" 

198 try: 

199 if vendor == OPENAI_COMPATIBLE: 

200 text = data["choices"][0]["message"]["content"] # type: ignore[index] 

201 elif vendor == "google-api": 

202 parts = data["candidates"][0]["content"]["parts"] # type: ignore[index] 

203 chunks = [p["text"] for p in parts if isinstance(p, dict) and "text" in p] 

204 text = "".join(chunks) if chunks else None 

205 elif vendor == "anthropic-api": 

206 blocks = data["content"] # type: ignore[index] 

207 parts = [b["text"] for b in blocks if isinstance(b, dict) and b.get("type") == "text"] 

208 text = "".join(parts) if parts else None 

209 else: 

210 text = data["choices"][0]["message"]["content"] # type: ignore[index] 

211 # A gateway/proxy or format drift can return valid JSON with a non-str 

212 # payload; report bad-response instead of leaking a dict/list to callers. 

213 return text if isinstance(text, str) and text else None 

214 except (KeyError, IndexError, TypeError, AttributeError): 

215 return None 

216 

217 

218def _build_opener() -> urllib.request.OpenerDirector: 

219 """HTTP/HTTPS-only opener: no redirect handler, no file/ftp/proxy handlers.""" 

220 opener = urllib.request.OpenerDirector() 

221 opener.add_handler(urllib.request.HTTPHandler()) 

222 opener.add_handler(urllib.request.HTTPSHandler()) 

223 opener.add_handler(urllib.request.HTTPErrorProcessor()) 

224 opener.add_handler(urllib.request.HTTPDefaultErrorHandler()) 

225 return opener 

226 

227 

228def _status_error(status: int, body: str) -> ApiResult: 

229 """Map an HTTP error status onto the fail-soft vocabulary. 

230 

231 ``400`` is read as an auth failure when the body says so, because Google answers 

232 an invalid ``GEMINI_API_KEY`` with ``400 INVALID_ARGUMENT: API key not valid`` 

233 rather than 401 (verified against the live endpoint). Classifying that as a 

234 generic ``http`` error would tell an operator with a mistyped key to look 

235 anywhere but at the key. 

236 """ 

237 if status == 400 and "api key not valid" in body.lower(): 

238 return ApiResult(False, error_code="auth", error=f"HTTP {status}: {body[:200]}") 

239 if status in (401, 403): 

240 return ApiResult(False, error_code="auth", error=f"HTTP {status}: {body[:200]}") 

241 if status == 429: 

242 # Quota/rate-limit: the s4 rule says do not retry — fail soft and fall back. 

243 return ApiResult(False, error_code="rate-limit", error=f"HTTP {status}: {body[:200]}") 

244 return ApiResult(False, error_code="http", error=f"HTTP {status}: {body[:200]}") 

245 

246 

247def generate( 

248 vendor: str, 

249 model: str, 

250 prompt: str, 

251 *, 

252 endpoint: str | None = None, 

253 api_key_env: str | None = None, 

254 max_tokens: int = DEFAULT_MAX_TOKENS, 

255 timeout: int = DEFAULT_TIMEOUT, 

256 _env=os.environ, 

257 _opener=None, 

258) -> ApiResult: 

259 """One single-shot generation call against a hosted vendor API. 

260 

261 Pure request/response — no retries (the s4 contract owns the 2-retry loop on 

262 a bad diff and the no-retry-on-429 rule), no streaming, no tools. ``_env`` 

263 and ``_opener`` are injectable so the wrapper is fully unit-testable offline. 

264 """ 

265 if vendor == OPENAI_COMPATIBLE: 

266 # The only vendor whose URL and key-env come from config rather than from 

267 # the hardcoded table. keel.config.endpoint_issues has already refused a 

268 # non-http(s) scheme and a non-loopback host without the env opt-in, at 

269 # `keel validate` time; this is the dispatch-time contract check. 

270 if not endpoint or not api_key_env: 

271 return ApiResult( 

272 False, error_code="unknown-vendor", 

273 error=f"{OPENAI_COMPATIBLE} requires both endpoint and api_key_env", 

274 ) 

275 entry: tuple[str, str] | None = (endpoint, api_key_env) 

276 else: 

277 entry = _VENDORS.get(vendor) 

278 if entry is None: 

279 return ApiResult(False, error_code="unknown-vendor", error=f"unknown API vendor: {vendor}") 

280 key = _env.get(entry[1], "").strip() 

281 if not key: 

282 return ApiResult( 

283 False, error_code="no-key", error=f"{entry[1]} is not set in the environment" 

284 ) 

285 reason = _invalid_key_reason(key) 

286 if reason is not None: 

287 return ApiResult(False, error_code="bad-key", error=reason) 

288 if "{model}" in entry[0]: 

289 unsafe = _unsafe_model_reason(model) 

290 if unsafe is not None: 

291 return ApiResult(False, error_code="bad-model", error=unsafe) 

292 

293 url, headers, body = _build_request(vendor, model, prompt, key, max_tokens, entry[0]) 

294 # URL comes only from the hardcoded _VENDORS constants — never config, env, 

295 # or model/prompt content. 

296 request = urllib.request.Request(url, data=body, headers=headers, method="POST") # nosec B310 

297 opener = _opener if _opener is not None else _build_opener() 

298 try: 

299 with opener.open(request, timeout=timeout) as resp: 

300 raw = resp.read(50 * 1024 * 1024).decode("utf-8", errors="replace") 

301 status = getattr(resp, "status", 200) 

302 except urllib.error.HTTPError as exc: 

303 detail = "" 

304 try: 

305 detail = exc.read(50 * 1024 * 1024).decode("utf-8", errors="replace") 

306 except OSError: # pragma: no cover - defensive; HTTPError bodies rarely fail to read 

307 detail = str(exc) 

308 return _status_error(exc.code, _scrub(detail, key)) 

309 except (urllib.error.URLError, http.client.HTTPException, OSError, TimeoutError) as exc: 

310 # http.client exceptions (IncompleteRead, BadStatusLine, ...) are NOT 

311 # OSError subclasses and would otherwise escape the fail-soft contract. 

312 return ApiResult(False, error_code="network", error=_scrub(str(exc), key)) 

313 

314 if not 200 <= status < 300: 

315 # Non-2xx (incl. a 3xx from a redirecting intermediary — this opener 

316 # never follows redirects) must never be parsed as a completion. 

317 return _status_error(status, _scrub(raw, key)) 

318 try: 

319 data = json.loads(raw) 

320 except ValueError: 

321 return ApiResult( 

322 False, error_code="bad-response", error="response is not valid JSON" 

323 ) 

324 text = _parse_content(vendor, data) 

325 if not text: 

326 return ApiResult( 

327 False, error_code="bad-response", error="response carried no completion text" 

328 ) 

329 return ApiResult(True, text=text)