llm-connect/llm_connect/_http.py
tegwick ed7c632155
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Implement LLM-WP-0008: provider-scoped account balance CLI
Add pluggable balance registry, OpenRouter credits/key limit client,
and llm-connect balance with one-shot --provider that does not change
library defaults.
2026-08-03 23:49:01 +02:00

130 lines
3.8 KiB
Python

"""
Thin synchronous HTTP helper built on :mod:`urllib.request`.
Translates HTTP errors into typed :mod:`markitect.llm.exceptions`.
"""
import json
import urllib.error
import urllib.request
from typing import Any, Dict, Optional
from llm_connect._diagnostics import record_provider_request, record_provider_response
from llm_connect.exceptions import (
LLMAPIError,
LLMRateLimitError,
LLMTimeoutError,
)
def post_json(
url: str,
payload: Dict[str, Any],
headers: Optional[Dict[str, str]] = None,
timeout: int = 300,
) -> Dict[str, Any]:
"""POST *payload* as JSON and return the parsed response body.
Raises:
LLMRateLimitError: on HTTP 429
LLMAPIError: on other non-2xx responses
LLMTimeoutError: on socket / read timeout
"""
record_provider_request(url=url, payload=payload, headers=headers or {})
data = json.dumps(payload).encode()
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
return _read_json_response(url, req, timeout=timeout)
def get_json(
url: str,
headers: Optional[Dict[str, str]] = None,
timeout: int = 60,
) -> Dict[str, Any]:
"""GET *url* and return the parsed JSON response body.
Raises:
LLMRateLimitError: on HTTP 429
LLMAPIError: on other non-2xx responses
LLMTimeoutError: on socket / read timeout
"""
record_provider_request(url=url, payload=None, headers=headers or {})
req = urllib.request.Request(
url,
headers={**(headers or {})},
method="GET",
)
return _read_json_response(url, req, timeout=timeout)
def _read_json_response(
url: str,
req: urllib.request.Request,
*,
timeout: int,
) -> Dict[str, Any]:
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode()
try:
parsed = json.loads(body)
record_provider_response(status=resp.status, body=parsed)
return parsed
except json.JSONDecodeError as exc:
record_provider_response(status=resp.status, body=body)
preview = body[:300].replace("\n", "\\n")
raise LLMAPIError(
f"Invalid JSON response from {url}: {exc} - body preview: {preview!r}",
cause=exc,
) from exc
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode()
except Exception:
pass
record_provider_response(status=exc.code, body=_json_or_text(body))
if exc.code == 429:
raise LLMRateLimitError(
f"Rate limited (429) from {url}",
status_code=429,
response_body=body,
cause=exc,
) from exc
raise LLMAPIError(
f"HTTP {exc.code} from {url}",
status_code=exc.code,
response_body=body,
cause=exc,
) from exc
except urllib.error.URLError as exc:
record_provider_response(body={"error": str(exc.reason)})
if "timed out" in str(exc.reason):
raise LLMTimeoutError(
f"Request to {url} timed out after {timeout}s",
cause=exc,
) from exc
raise LLMAPIError(
f"URL error for {url}: {exc.reason}",
cause=exc,
) from exc
except TimeoutError as exc:
record_provider_response(body={"error": "timeout"})
raise LLMTimeoutError(
f"Request to {url} timed out after {timeout}s",
cause=exc,
) from exc
def _json_or_text(body: str) -> Any:
try:
return json.loads(body)
except (TypeError, ValueError):
return body