Files
hermes-agent/agent/model_metadata_http.py
Teknium 8469e34161 fix(model-metadata): cap every metadata probe's whole exchange too
The same per-op-only timeout sits on the shared model-metadata transport
(agent/model_metadata_http.py), and it is on the same request path: a new
session resolves its context window through fetch_model_metadata (the
OpenRouter catalog) and fetch_endpoint_model_metadata (a custom endpoint's
/models). With DNS for openrouter.ai stalled, the first session.create
blocked for the whole stall even after the models.dev cap.

stream() now sends and, for a 2xx, reads the body under
run_bounded_sync with a 15 s cap. A rejection still returns on its
headers without reading the body, so the auth-failure early close keeps
working; get() rides on stream(). On expiry it raises DeadlineExpired,
which every caller already treats as a failed probe.
2026-10-28 07:23:10 -07:00

73 lines
2.9 KiB
Python

"""Model probes share chat's TLS policy, with scoped redirect credentials."""
from __future__ import annotations
from contextlib import contextmanager
# Wall-clock cap on one probe (headers + whole body), on top of the per-op httpx timeouts.
TOTAL_DEADLINE_S = 15.0
def resolve_verify(base_url: str = ""):
from agent.ssl_verify import resolve_httpx_verify
settings = {}
if base_url:
try:
from hermes_cli.config import get_custom_provider_tls_settings
settings = get_custom_provider_tls_settings(base_url)
except Exception:
pass # Metadata remains optional when config discovery fails.
return resolve_httpx_verify(
ca_bundle=settings.get("ssl_ca_cert"),
ssl_verify=settings.get("ssl_verify"),
base_url=base_url,
)
@contextmanager
def stream(url: str, *, headers=None, params=None, timeout=10.0, verify=None):
import httpx
from agent.deadline import DeadlineExpired, run_bounded_sync
from hermes_cli.urllib_security import url_origin
origin = url_origin(url)
private_headers = {name.lower() for name in headers or {} if name.lower() not in {"accept", "user-agent"}}
private_headers.update({"authorization", "cookie", "proxy-authorization"})
def scope_credentials(request):
if url_origin(str(request.url)) != origin:
for name in private_headers:
request.headers.pop(name, None)
if isinstance(timeout, tuple):
connect, read = timeout
timeout = httpx.Timeout(read, connect=connect)
with httpx.Client(
verify=resolve_verify() if verify is None else verify,
timeout=timeout,
follow_redirects=True,
event_hooks={"request": [scope_credentials]},
) as client:
def fetch():
response = client.send(client.build_request("GET", url, headers=headers, params=params), stream=True)
if response.is_success: # a rejection closes on its headers, never waits for a body
response.read()
return response
# Probes run inside requests (session.create resolves the context window through them), and
# httpx timeouts bound each socket op, not the exchange: a body trickling in under the read
# timeout, or a hung DNS lookup, would hold the caller for as long as it lasts. Leaving the
# client block on expiry closes the pool under the abandoned worker.
bounded = run_bounded_sync(fetch, TOTAL_DEADLINE_S, label=f"model metadata {origin}")
if bounded.timed_out:
raise DeadlineExpired(f"model metadata {origin}", TOTAL_DEADLINE_S)
try:
yield bounded.value
finally:
bounded.value.close()
def get(url: str, *, headers=None, params=None, timeout=10.0, verify=None):
with stream(url, headers=headers, params=params, timeout=timeout, verify=verify) as response:
return response