Files
aituner/src/aituner/http_client.py
Gahow Wang 2fcaf80450 Wrap socket/timeout errors in HTTP client as HttpClientError
stream_chat_completion (and the LLM stream/chat paths) only caught HTTPError, so a
request exceeding request_timeout_s raised a raw TimeoutError mid-stream that escaped
_run_one_request (which only catches HttpClientError), propagated through the probe,
and crashed the whole trial ("failed: timed out"). A timed-out request is a failed
request (SLO miss), not a trial crash. Catch OSError (covers TimeoutError, URLError,
ConnectionError) after HTTPError and wrap it. Exposed by lowering request_timeout_s
to 180s on the 27B run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:58:28 +08:00

336 lines
12 KiB
Python

from __future__ import annotations
import json
import os
import time
import tomllib
import urllib.error
import urllib.request
from dataclasses import dataclass
from ipaddress import ip_address
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlparse
class HttpClientError(RuntimeError):
"""Raised for HTTP client failures."""
def _should_bypass_proxy(url: str) -> bool:
host = (urlparse(url).hostname or "").strip()
if not host:
return False
if host == "localhost":
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
def _urlopen(request: urllib.request.Request, *, timeout: float):
if _should_bypass_proxy(request.full_url):
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
return opener.open(request, timeout=timeout)
return urllib.request.urlopen(request, timeout=timeout)
def _find_dotenv(start: Path | None = None) -> Path | None:
current = (start or Path.cwd()).resolve()
for candidate_dir in (current, *current.parents):
candidate = candidate_dir / ".env"
if candidate.is_file():
return candidate
return None
def _load_dotenv() -> None:
path = _find_dotenv()
if path is None:
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export ") :].strip()
key, separator, value = line.partition("=")
if not separator:
continue
key = key.strip()
if not key:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
os.environ.setdefault(key, value)
def _load_codex_network_env() -> None:
config_path = Path.home() / ".codex" / "config.toml"
if not config_path.is_file():
return
try:
payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError:
return
network = payload.get("network")
if not isinstance(network, dict):
return
for key, value in network.items():
if not isinstance(value, str) or not value.strip():
continue
normalized = value.strip()
os.environ.setdefault(str(key), normalized)
if str(key).endswith("_proxy"):
os.environ.setdefault(str(key).upper(), normalized)
def _resolve_api_key(api_key_env: str | None, *, provider: str) -> str | None:
_load_dotenv()
if provider == "codex":
_load_codex_network_env()
if api_key_env:
api_key = os.environ.get(api_key_env)
if api_key:
return api_key
if provider != "codex" and api_key_env != "OPENAI_API_KEY":
return None
auth_path = Path.home() / ".codex" / "auth.json"
if not auth_path.is_file():
return None
try:
payload = json.loads(auth_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
api_key = payload.get("OPENAI_API_KEY")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
return None
def _auth_headers(api_key_env: str | None, provider: str = "custom") -> dict[str, str]:
headers = {"Content-Type": "application/json"}
api_key = _resolve_api_key(api_key_env, provider=provider)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def _openai_url(base_url: str, path: str) -> str:
root = base_url.rstrip("/")
normalized_path = "/" + path.lstrip("/")
if root.endswith("/v1") and normalized_path.startswith("/v1/"):
normalized_path = normalized_path[len("/v1") :]
return root + normalized_path
def wait_for_server(base_url: str, path: str, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
url = f"{base_url.rstrip('/')}{path}"
last_error = "server_not_ready"
while time.monotonic() < deadline:
try:
request = urllib.request.Request(url=url, headers=_auth_headers(None), method="GET")
with _urlopen(request, timeout=5) as response:
if 200 <= response.status < 500:
return
except Exception as exc: # noqa: BLE001
last_error = str(exc)
time.sleep(1.0)
raise HttpClientError(f"Timed out waiting for {url}: {last_error}")
def chat_completion(
*,
base_url: str,
api_key_env: str | None,
provider: str = "custom",
wire_api: str = "chat.completions",
model: str,
messages: list[dict[str, Any]],
timeout_s: float,
system_prompt: str = "",
reasoning_effort: str | None = None,
) -> dict[str, Any]:
if wire_api == "responses":
payload = {"model": model, "input": messages}
if system_prompt:
payload["instructions"] = system_prompt
path = "/v1/responses"
else:
payload = {"model": model, "messages": messages}
if system_prompt:
payload["messages"] = [{"role": "system", "content": system_prompt}, *messages]
if reasoning_effort:
payload["reasoning_effort"] = reasoning_effort
path = "/v1/chat/completions"
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url=_openai_url(base_url, path),
headers=_auth_headers(api_key_env, provider),
data=data,
method="POST",
)
try:
with _urlopen(request, timeout=timeout_s) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HttpClientError(f"llm_completion failed: {exc.code} {detail}") from exc
except OSError as exc:
# TimeoutError (socket.timeout), URLError, ConnectionError all subclass OSError.
raise HttpClientError(f"llm_completion failed: {exc}") from exc
def stream_text_completion(
*,
base_url: str,
api_key_env: str | None,
provider: str = "custom",
wire_api: str = "chat.completions",
model: str,
messages: list[dict[str, Any]],
timeout_s: float,
system_prompt: str = "",
reasoning_effort: str | None = None,
) -> str:
if wire_api != "chat.completions":
raise HttpClientError("stream_text_completion currently supports only chat.completions")
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"stream": True,
}
if system_prompt:
payload["messages"] = [{"role": "system", "content": system_prompt}, *messages]
if reasoning_effort:
payload["reasoning_effort"] = reasoning_effort
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url=_openai_url(base_url, "/v1/chat/completions"),
headers=_auth_headers(api_key_env, provider),
data=data,
method="POST",
)
parts: list[str] = []
try:
with _urlopen(request, timeout=timeout_s) as response:
for raw in _iter_sse_lines(response):
if raw == "[DONE]":
break
payload = json.loads(raw)
if not isinstance(payload, dict):
continue
choices = payload.get("choices")
if not isinstance(choices, list) or not choices:
continue
delta = choices[0].get("delta", {})
if not isinstance(delta, dict):
continue
content = delta.get("content")
if isinstance(content, str):
parts.append(content)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HttpClientError(f"stream_text_completion failed: {exc.code} {detail}") from exc
except OSError as exc:
raise HttpClientError(f"stream_text_completion failed: {exc}") from exc
return "".join(parts)
@dataclass(frozen=True)
class StreamMetrics:
ttft_ms: float | None
tpot_ms: float | None
completion_tokens: int | None
completion_tokens_source: str = "usage"
streamed_chunk_count: int = 0
def stream_chat_completion(
*,
base_url: str,
body: dict[str, Any],
timeout_s: float,
) -> StreamMetrics:
data = json.dumps(body).encode("utf-8")
request = urllib.request.Request(
url=_openai_url(base_url, "/v1/chat/completions"),
headers=_auth_headers(None),
data=data,
method="POST",
)
start = time.monotonic()
first_token_at: float | None = None
last_token_at: float | None = None
chunk_token_count = 0
completion_tokens: int | None = None
completion_tokens_source = "none"
try:
with _urlopen(request, timeout=timeout_s) as response:
for raw in _iter_sse_lines(response):
if raw == "[DONE]":
break
payload = json.loads(raw)
if not isinstance(payload, dict):
continue
usage = payload.get("usage")
if isinstance(usage, dict):
comp = usage.get("completion_tokens")
if isinstance(comp, int) and comp >= 0:
completion_tokens = comp
completion_tokens_source = "usage"
choices = payload.get("choices")
if not isinstance(choices, list) or not choices:
continue
delta = choices[0].get("delta", {})
if not isinstance(delta, dict):
continue
content = delta.get("content")
if isinstance(content, str) and content:
now = time.monotonic()
if first_token_at is None:
first_token_at = now
last_token_at = now
chunk_token_count += 1
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HttpClientError(f"stream_chat_completion failed: {exc.code} {detail}") from exc
except OSError as exc:
# A request that exceeds request_timeout_s raises TimeoutError mid-stream;
# treat it as a failed request (SLO miss), not a crashed trial.
raise HttpClientError(f"stream_chat_completion failed: {exc}") from exc
ttft_ms = None if first_token_at is None else (first_token_at - start) * 1000.0
if completion_tokens is None and chunk_token_count > 0:
completion_tokens = chunk_token_count
completion_tokens_source = "stream_chunks"
used_tokens = completion_tokens
if (
first_token_at is None
or last_token_at is None
or used_tokens is None
or used_tokens <= 1
):
tpot_ms = None
else:
tpot_ms = ((last_token_at - first_token_at) / max(used_tokens - 1, 1)) * 1000.0
return StreamMetrics(
ttft_ms=ttft_ms,
tpot_ms=tpot_ms,
completion_tokens=used_tokens if used_tokens is not None and used_tokens > 0 else None,
completion_tokens_source=completion_tokens_source,
streamed_chunk_count=chunk_token_count,
)
def _iter_sse_lines(response: Any) -> Iterable[str]:
for raw in response:
line = raw.decode("utf-8", errors="replace").strip()
if not line.startswith("data:"):
continue
payload = line[len("data:") :].strip()
if payload:
yield payload