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
« 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).
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 functools
31import http.client
32import ipaddress
33import json
34import os
35import socket
36import urllib.error
37import urllib.request
38from dataclasses import dataclass
40from . import config
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
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]
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
76_ANTHROPIC_VERSION = "2023-06-01"
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}
94#: Characters a model id may contain when it is interpolated into a URL path.
95_MODEL_PATH_OK = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_")
98def _unsafe_model_reason(model: str) -> str | None:
99 """Reject a model id that cannot safely be interpolated into a URL path.
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
118@dataclass(frozen=True)
119class ApiResult:
120 """Outcome of one hosted-API generation call (fail-soft, never raised)."""
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
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}
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
149def has_api_token(vendor: str, *, _env=os.environ) -> bool:
150 """Dispatch probe: is the *selected* vendor's key present and non-blank?
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())
159def present_key_names(*, _env=os.environ) -> tuple[str, ...]:
160 """Env-var *names* (never values) of the vendor keys currently set.
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())
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
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
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.
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
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.
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.
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")
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
288def _count(value: object) -> int | None:
289 """A token count as a vendor reported it: a non-negative ``int``, else ``None``.
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
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
306def parse_usage(vendor: str, data: object) -> tuple[int, int] | None:
307 """``(prompt_tokens, completion_tokens)`` from a decoded response, or ``None``.
309 Only the shapes each vendor documents are read (#1373):
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.
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
367class GuardedAddressError(OSError):
368 """A connection was refused because of where the host name resolved."""
371def _permitted_addresses(host, port, *, env, resolve):
372 """Every address ``host`` may be connected to. Raises if none may be.
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
401def _guarded_create_connection(*, env, resolve):
402 """A ``socket.create_connection`` that only reaches checked addresses.
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 """
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)
421 return create_connection
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)
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
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 )
449 class GuardedHTTPHandler(urllib.request.HTTPHandler):
450 def http_open(self, req):
451 return self.do_open(http_conn, req)
453 class GuardedHTTPSHandler(urllib.request.HTTPSHandler):
454 def https_open(self, req):
455 return self.do_open(https_conn, req)
457 return GuardedHTTPHandler(), GuardedHTTPSHandler()
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.
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.
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.
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
493def _status_error(status: int, body: str) -> ApiResult:
494 """Map an HTTP error status onto the fail-soft vocabulary.
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]}")
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.
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.
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)
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))
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)