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
« 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).
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).
11Design (docs/proposals/api-token-delegate.md):
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"""
28from __future__ import annotations
30import http.client
31import json
32import os
33import urllib.error
34import urllib.request
35from dataclasses import dataclass
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
42_ANTHROPIC_VERSION = "2023-06-01"
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"
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}
64#: Characters a model id may contain when it is interpolated into a URL path.
65_MODEL_PATH_OK = frozenset(
66 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"
67)
70def _unsafe_model_reason(model: str) -> str | None:
71 """Reject a model id that cannot safely be interpolated into a URL path.
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
90@dataclass(frozen=True)
91class ApiResult:
92 """Outcome of one hosted-API generation call (fail-soft, never raised)."""
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
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
109def has_api_token(vendor: str, *, _env=os.environ) -> bool:
110 """Dispatch probe: is the *selected* vendor's key present and non-blank?
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())
119def present_key_names(*, _env=os.environ) -> tuple[str, ...]:
120 """Env-var *names* (never values) of the vendor keys currently set.
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())
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
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
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.
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")
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
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
228def _status_error(status: int, body: str) -> ApiResult:
229 """Map an HTTP error status onto the fail-soft vocabulary.
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]}")
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.
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)
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))
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)