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

220 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +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 functools 

31import http.client 

32import ipaddress 

33import json 

34import os 

35import socket 

36import urllib.error 

37import urllib.request 

38from dataclasses import dataclass 

39 

40from . import config 

41 

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

43# instead of the hardcoded table below (#666). It moved to the leaf :mod:`keel.vocab` 

44# with the rest of the shared vendor vocabulary (#1050) because 

45# :data:`keel.vocab.EFFORT_VENDORS` names it; re-exported here, which is where the 

46# package reads it. 

47from .vocab import OPENAI_COMPATIBLE as OPENAI_COMPATIBLE 

48 

49#: The module's public surface, in definition order (#1070). It is declared because the 

50#: ``X as X`` re-exports above are read from *other* modules — a use CodeQL's 

51#: ``py/unused-import`` cannot see, since it counts same-module uses only. A name listed 

52#: in ``__all__`` is used by definition, so the declaration answers the scanner with the 

53#: language's own statement of intent rather than with a dismissal. Being a real 

54#: declaration it has to be the *whole* surface, not the re-exports alone; 

55#: ``tests/test_reexport_surface.py`` holds it to that in both directions. 

56__all__ = [ 

57 "OPENAI_COMPATIBLE", 

58 "DEFAULT_MAX_TOKENS", 

59 "DEFAULT_TIMEOUT", 

60 "ApiResult", 

61 "env_key_name", 

62 "has_api_token", 

63 "present_key_names", 

64 "merge_payload", 

65 "parse_usage", 

66 "GuardedAddressError", 

67 "build_http_only_opener", 

68 "generate", 

69] 

70 

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

72DEFAULT_MAX_TOKENS = 16384 

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

74DEFAULT_TIMEOUT = 300 

75 

76_ANTHROPIC_VERSION = "2023-06-01" 

77 

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

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

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

81#: 

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

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

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

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

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

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

88 "google-api": ( 

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

90 "GEMINI_API_KEY", 

91 ), 

92} 

93 

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

95_MODEL_PATH_OK = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") 

96 

97 

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

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

100 

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

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

103 the model arrives per run from ``--delegate google-api:MODEL`` or ``keel delegate run 

104 --model``, so a value containing ``/``, ``..``, ``?`` or ``#`` could retarget 

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

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

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

108 """ 

109 if not model: 

110 return "model is empty" 

111 if not _MODEL_PATH_OK.issuperset(model): 

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

113 if ".." in model: 

114 return "model contains a path traversal sequence" 

115 return None 

116 

117 

118@dataclass(frozen=True) 

119class ApiResult: 

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

121 

122 ok: bool 

123 text: str = "" 

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

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

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

127 error_code: str | None = None 

128 error: str | None = None 

129 #: The token counts the vendor reported for this call (#1373), read by 

130 #: :func:`parse_usage`. Both set or both ``None``: ``None`` means the response 

131 #: carried no usable counts, never that the call was free. 

132 prompt_tokens: int | None = None 

133 completion_tokens: int | None = None 

134 

135 @property 

136 def usage(self) -> dict[str, int] | None: 

137 """``{"prompt_tokens": …, "completion_tokens": …}``, or ``None`` when not reported.""" 

138 if self.prompt_tokens is None or self.completion_tokens is None: 

139 return None 

140 return {"prompt_tokens": self.prompt_tokens, "completion_tokens": self.completion_tokens} 

141 

142 

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

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

145 entry = _VENDORS.get(vendor) 

146 return entry[1] if entry else None 

147 

148 

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

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

151 

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

153 ``ANTHROPIC_API_KEY`` set reports absent. 

154 """ 

155 name = env_key_name(vendor) 

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

157 

158 

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

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

161 

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

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

164 :func:`has_api_token`. 

165 """ 

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

167 

168 

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

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

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

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

173 return "API key contains control characters" 

174 if not key.isascii(): 

175 return "API key contains non-ASCII characters" 

176 return None 

177 

178 

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

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

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

182 

183 

184def merge_payload(payload: dict, extra: dict | None) -> dict: 

185 """Merge a vendor payload fragment into a request body, one nesting level at a time. 

186 

187 A shallow ``update`` is wrong for exactly one vendor and that vendor matters: Gemini 

188 spells reasoning effort as ``generationConfig.thinkingConfig.thinkingBudget``, and a 

189 shallow merge of that fragment would drop the ``maxOutputTokens`` already sitting in 

190 ``generationConfig`` — the request would still succeed, with an unbounded answer. 

191 Recursive on dicts, replace on everything else; the input is never mutated. 

192 """ 

193 merged = dict(payload) 

194 for name, value in (extra or {}).items(): 

195 current = merged.get(name) 

196 if isinstance(current, dict) and isinstance(value, dict): 

197 merged[name] = merge_payload(current, value) 

198 else: 

199 merged[name] = value 

200 return merged 

201 

202 

203def _build_request( 

204 vendor: str, 

205 model: str, 

206 prompt: str, 

207 key: str, 

208 max_tokens: int, 

209 base: str | None = None, 

210 extra: dict | None = None, 

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

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

213 

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

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

216 

217 ``extra`` is the caller's payload fragment — the per-vendor reasoning-effort spelling 

218 :func:`keel.delegate.plan_run` resolves (``thinking`` / ``reasoning_effort`` / 

219 ``generationConfig.thinkingConfig``). It is merged **into** the body rather than 

220 layered over it; see :func:`merge_payload`. 

221 """ 

222 payload: dict 

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

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

225 if vendor == OPENAI_COMPATIBLE: 

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

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

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

229 payload = { 

230 "model": model, 

231 "max_tokens": max_tokens, 

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

233 } 

234 elif vendor == "google-api": 

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

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

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

238 payload = { 

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

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

241 } 

242 elif vendor == "anthropic-api": 

243 headers = { 

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

245 "x-api-key": key, 

246 "anthropic-version": _ANTHROPIC_VERSION, 

247 } 

248 payload = { 

249 "model": model, 

250 "max_tokens": max_tokens, 

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

252 } 

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

254 headers = { 

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

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

257 } 

258 payload = { 

259 "model": model, 

260 "max_completion_tokens": max_tokens, 

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

262 } 

263 return url, headers, json.dumps(merge_payload(payload, extra)).encode("utf-8") 

264 

265 

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

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

268 try: 

269 if vendor == OPENAI_COMPATIBLE: 

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

271 elif vendor == "google-api": 

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

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

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

275 elif vendor == "anthropic-api": 

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

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

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

279 else: 

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

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

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

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

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

285 return None 

286 

287 

288def _count(value: object) -> int | None: 

289 """A token count as a vendor reported it: a non-negative ``int``, else ``None``. 

290 

291 ``bool`` is refused although it is an ``int`` subclass, and so is a float such as 

292 ``12.0``: a count that arrives in any other shape is a gateway's invention, and 

293 guessing what it meant is exactly what #1373 must not do. 

294 """ 

295 if isinstance(value, bool) or not isinstance(value, int) or value < 0: 

296 return None 

297 return value 

298 

299 

300def _optional(usage: dict, name: str) -> object: 

301 """An optional count field: absent or ``null`` is zero, anything else is checked.""" 

302 value = usage.get(name) 

303 return 0 if value is None else value 

304 

305 

306def parse_usage(vendor: str, data: object) -> tuple[int, int] | None: 

307 """``(prompt_tokens, completion_tokens)`` from a decoded response, or ``None``. 

308 

309 Only the shapes each vendor documents are read (#1373): 

310 

311 - ``anthropic-api`` — ``usage.input_tokens`` / ``usage.output_tokens``. The prompt 

312 side adds ``cache_creation_input_tokens`` and ``cache_read_input_tokens`` (either 

313 may be absent or ``null``), because "Total input tokens in a request is the 

314 summation" of all three: 

315 https://docs.anthropic.com/en/api/messages (the ``usage`` object). 

316 - ``openai-api`` — ``usage.prompt_tokens`` / ``usage.completion_tokens`` 

317 (``CompletionUsage`` in https://github.com/openai/openai-openapi; reasoning 

318 tokens are a breakdown *of* ``completion_tokens``, not an addition). 

319 - ``openai-compatible`` — the same OpenAI shape, which is what "compatible" means. 

320 Confirmed for DeepSeek (https://api-docs.deepseek.com/api/create-chat-completion) 

321 and OpenRouter (https://openrouter.ai/docs/use-cases/usage-accounting); any other 

322 server that omits ``usage`` simply records nothing. 

323 - ``google-api`` — ``usageMetadata.promptTokenCount`` for the prompt, and 

324 ``candidatesTokenCount`` plus ``thoughtsTokenCount`` (absent for a model that does 

325 not think) for the completion, since ``totalTokenCount`` is "prompt + thoughts + 

326 response candidates": https://ai.google.dev/api/generate-content#UsageMetadata. 

327 

328 ``None`` whenever a required field is missing or any read field is not a 

329 non-negative integer — one malformed field voids the whole reading rather than 

330 being skipped, because a partial sum is a guess. A reading with a zero on either side 

331 is ``None`` too: keel never sends an empty prompt, and a gateway that does not count 

332 answers zeros, which recorded as-is would report a billed call as free. Dropping a 

333 reading under-records; it never invents one. 

334 """ 

335 key = "usageMetadata" if vendor == "google-api" else "usage" 

336 usage = data.get(key) if isinstance(data, dict) else None 

337 if not isinstance(usage, dict): 

338 return None 

339 prompt_parts: tuple[object, ...] 

340 completion_parts: tuple[object, ...] 

341 if vendor == "google-api": 

342 prompt_parts = (usage.get("promptTokenCount"),) 

343 completion_parts = ( 

344 usage.get("candidatesTokenCount"), 

345 _optional(usage, "thoughtsTokenCount"), 

346 ) 

347 elif vendor == "anthropic-api": 

348 prompt_parts = ( 

349 usage.get("input_tokens"), 

350 _optional(usage, "cache_creation_input_tokens"), 

351 _optional(usage, "cache_read_input_tokens"), 

352 ) 

353 completion_parts = (usage.get("output_tokens"),) 

354 else: # openai-api and openai-compatible share the OpenAI shape 

355 prompt_parts = (usage.get("prompt_tokens"),) 

356 completion_parts = (usage.get("completion_tokens"),) 

357 counts = [_count(value) for value in (*prompt_parts, *completion_parts)] 

358 if None in counts: 

359 return None 

360 prompt = sum(counts[: len(prompt_parts)]) # type: ignore[arg-type] 

361 completion = sum(counts[len(prompt_parts) :]) # type: ignore[arg-type] 

362 if not prompt or not completion: 

363 return None 

364 return prompt, completion 

365 

366 

367class GuardedAddressError(OSError): 

368 """A connection was refused because of where the host name resolved.""" 

369 

370 

371def _permitted_addresses(host, port, *, env, resolve): 

372 """Every address ``host`` may be connected to. Raises if none may be. 

373 

374 Resolution happens **once** and the caller connects to what was checked. 

375 Re-resolving after the check is the whole vulnerability: a name that answers 

376 a public address to the check and a private one to ``connect`` passes both. 

377 """ 

378 try: 

379 candidates = resolve(host, port, type=socket.SOCK_STREAM) 

380 except OSError as exc: 

381 # Not a policy refusal. Reporting an unresolvable name as a security 

382 # block would send an operator hunting for a threat that is a typo. 

383 raise GuardedAddressError(f"cannot resolve {host!r}: {exc}") from None 

384 permitted, refusals = [], [] 

385 for candidate in candidates: 

386 try: 

387 ip = ipaddress.ip_address(candidate[4][0]) 

388 except ValueError: # pragma: no cover - getaddrinfo yields literals 

389 continue 

390 refusal = config.resolved_address_refusal(host, ip, env=env) 

391 if refusal is None: 

392 permitted.append(candidate) 

393 else: 

394 refusals.append(refusal) 

395 if not permitted: 

396 detail = refusals[0] if refusals else "resolved to no usable address" 

397 raise GuardedAddressError(f"endpoint host {host!r} {detail}") 

398 return permitted 

399 

400 

401def _guarded_create_connection(*, env, resolve): 

402 """A ``socket.create_connection`` that only reaches checked addresses. 

403 

404 Substituted for ``HTTPConnection._create_connection`` rather than overriding 

405 ``connect``: ``HTTPSConnection.connect`` calls ``super().connect()`` and then 

406 wraps the socket in TLS with ``server_hostname``. Replacing this one seam 

407 leaves that intact, so the guard cannot accidentally change how the 

408 certificate is checked — which reimplementing ``connect`` for HTTPS would 

409 have risked. 

410 """ 

411 

412 def create_connection(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None): 

413 host, port = address[0], address[1] 

414 permitted = _permitted_addresses(host, port, env=env, resolve=resolve) 

415 # Hand stdlib the **checked address**, not the name. Resolving a literal 

416 # is a no-op, so this pins the connection to what was approved while 

417 # reusing socket.create_connection's timeout, source-address and 

418 # address-family handling rather than reimplementing them here. 

419 return socket.create_connection((permitted[0][4][0], port), timeout, source_address) 

420 

421 return create_connection 

422 

423 

424def _guarded_handlers(*, env, resolve): 

425 """HTTP and HTTPS handlers whose connections check where the name went.""" 

426 create = _guarded_create_connection(env=env, resolve=resolve) 

427 

428 # Assigned *after* `super().__init__`, not as a class attribute: 

429 # `HTTPConnection.__init__` does `self._create_connection = 

430 # socket.create_connection`, so an instance attribute shadows anything set 

431 # on the subclass. A class-level override silently does nothing — which is 

432 # how the first cut of this guard let a rebinding resolver straight through 

433 # while every unit test still passed. 

434 def _init(self, *args, _base, **kwargs): 

435 _base.__init__(self, *args, **kwargs) 

436 self._create_connection = create 

437 

438 http_conn = type( 

439 "GuardedHTTPConnection", 

440 (http.client.HTTPConnection,), 

441 {"__init__": functools.partialmethod(_init, _base=http.client.HTTPConnection)}, 

442 ) 

443 https_conn = type( 

444 "GuardedHTTPSConnection", 

445 (http.client.HTTPSConnection,), 

446 {"__init__": functools.partialmethod(_init, _base=http.client.HTTPSConnection)}, 

447 ) 

448 

449 class GuardedHTTPHandler(urllib.request.HTTPHandler): 

450 def http_open(self, req): 

451 return self.do_open(http_conn, req) 

452 

453 class GuardedHTTPSHandler(urllib.request.HTTPSHandler): 

454 def https_open(self, req): 

455 return self.do_open(https_conn, req) 

456 

457 return GuardedHTTPHandler(), GuardedHTTPSHandler() 

458 

459 

460def build_http_only_opener( 

461 *, _env=None, _resolve=socket.getaddrinfo 

462) -> urllib.request.OpenerDirector: 

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

464 

465 Public and shared rather than private to this module, because keel makes 

466 outbound HTTP from two places — this delegate and ``keel doctor``'s PyPI 

467 version check — and each having its own hand-rolled opener is how the handler 

468 sets drift apart. #811 hardened the second one; #810 squashed a stale base 

469 over it and restored plain ``urlopen`` for six days without anything noticing 

470 (#934). One opener, one owner, one place to audit the handler list. 

471 

472 The handlers also guard **where a name resolves** (#969). `config`'s 

473 endpoint check classifies the host as written, so a hostname carries no 

474 address and reaches what its literal spelling could not — `10.0.0.5` is 

475 refused while a name resolving to it is not. The address is checked and the 

476 connection pinned to what was checked, because re-resolving afterwards is 

477 the vulnerability rather than a detail of it. 

478 

479 `_env` and `_resolve` are injectable so a rebinding resolver can be tested 

480 offline; both callers use the defaults. 

481 """ 

482 http_handler, https_handler = _guarded_handlers( 

483 env=os.environ if _env is None else _env, resolve=_resolve 

484 ) 

485 opener = urllib.request.OpenerDirector() 

486 opener.add_handler(http_handler) 

487 opener.add_handler(https_handler) 

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

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

490 return opener 

491 

492 

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

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

495 

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

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

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

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

500 anywhere but at the key. 

501 """ 

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

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

504 if status in (401, 403): 

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

506 if status == 429: 

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

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

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

510 

511 

512def generate( 

513 vendor: str, 

514 model: str, 

515 prompt: str, 

516 *, 

517 endpoint: str | None = None, 

518 api_key_env: str | None = None, 

519 max_tokens: int = DEFAULT_MAX_TOKENS, 

520 timeout: int = DEFAULT_TIMEOUT, 

521 extra_payload: dict | None = None, 

522 _env=os.environ, 

523 _opener=None, 

524) -> ApiResult: 

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

526 

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

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

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

530 

531 ``extra_payload`` is an optional vendor-shaped fragment merged into the request body 

532 — how ``keel delegate run --effort`` reaches a hosted vendor (#1012). Keyword-only 

533 with a ``None`` default, so every existing caller builds the same body it did before. 

534 """ 

535 if vendor == OPENAI_COMPATIBLE: 

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

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

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

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

540 if not endpoint or not api_key_env: 

541 return ApiResult( 

542 False, 

543 error_code="unknown-vendor", 

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

545 ) 

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

547 else: 

548 entry = _VENDORS.get(vendor) 

549 if entry is None: 

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

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

552 if not key: 

553 return ApiResult( 

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

555 ) 

556 reason = _invalid_key_reason(key) 

557 if reason is not None: 

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

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

560 unsafe = _unsafe_model_reason(model) 

561 if unsafe is not None: 

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

563 

564 url, headers, body = _build_request( 

565 vendor, model, prompt, key, max_tokens, entry[0], extra_payload 

566 ) 

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

568 # or model/prompt content. 

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

570 opener = _opener if _opener is not None else build_http_only_opener() 

571 try: 

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

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

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

575 except urllib.error.HTTPError as exc: 

576 detail = "" 

577 try: 

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

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

580 detail = str(exc) 

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

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

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

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

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

586 

587 if not 200 <= status < 300: 

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

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

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

591 try: 

592 data = json.loads(raw) 

593 except ValueError: 

594 return ApiResult(False, error_code="bad-response", error="response is not valid JSON") 

595 # Read before the text is judged: a response with no usable completion was still 

596 # a call the vendor counted, and its counts are as real as a success's (#1373). 

597 usage = parse_usage(vendor, data) 

598 counts = {} if usage is None else {"prompt_tokens": usage[0], "completion_tokens": usage[1]} 

599 text = _parse_content(vendor, data) 

600 if not text: 

601 return ApiResult( 

602 False, 

603 error_code="bad-response", 

604 error="response carried no completion text", 

605 **counts, 

606 ) 

607 return ApiResult(True, text=text, **counts)