diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 7794a6caa0c..6aacc71b154 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -13,18 +13,40 @@ import os import re import time from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse -import requests import yaml +if TYPE_CHECKING: # pragma: no cover — runtime import is lazy (see below) + import requests + from utils import atomic_json_write, base_url_host_matches, base_url_hostname from hermes_constants import OPENROUTER_MODELS_URL logger = logging.getLogger(__name__) +# ``requests`` (with urllib3) costs ~27 ms of the `import cli` waterfall and +# is only used inside the fetch functions below. It's resolved lazily: +# ``_ensure_requests()`` populates the module global on the runtime path, and +# the PEP 562 ``__getattr__`` covers external attribute access — notably +# ``patch("agent.model_metadata.requests.get")`` in tests, which resolves the +# attribute at patch time. + + +def _ensure_requests(): + if "requests" not in globals(): + import requests as _requests + globals()["requests"] = _requests + return globals()["requests"] + + +def __getattr__(name: str): + if name == "requests": + return _ensure_requests() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + def _resolve_requests_verify() -> bool | str: """Resolve SSL verify setting for `requests` calls from env vars. @@ -995,6 +1017,7 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any return _model_metadata_cache try: + _ensure_requests() # Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per # retry stage through proxies that 403 CONNECT, ballooning to minutes # (#46620). 5s connect / 10s read fails fast on unreachable hosts. @@ -1051,6 +1074,7 @@ def fetch_endpoint_model_metadata( normalized = _normalize_base_url(base_url) if not normalized or _is_openrouter_base_url(normalized): return {} + _ensure_requests() if not force_refresh: cached = _endpoint_model_metadata_cache.get(normalized) @@ -1962,6 +1986,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> "x-api-key": api_key, "anthropic-version": "2023-06-01", } + _ensure_requests() resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) if resp.status_code != 200: return None @@ -2069,6 +2094,7 @@ def _fetch_codex_oauth_context_lengths_with_source( headers["ChatGPT-Account-Id"] = acct_id try: + _ensure_requests() resp = requests.get( "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", headers=headers, diff --git a/cron/jobs.py b/cron/jobs.py index b306448961a..e1b775bb4b5 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -41,11 +41,25 @@ logger = logging.getLogger(__name__) from hermes_time import now as _hermes_now from utils import atomic_replace, atomic_write_text -try: - from croniter import croniter - HAS_CRONITER = True -except ImportError: - HAS_CRONITER = False +# ``croniter`` compiles ~15 ms of regexes at import and only matters for +# 5-field cron expressions. Resolve lazily; ``HAS_CRONITER`` stays a module +# attribute (tests monkeypatch it, and a monkeypatched value wins because +# ``_ensure_croniter`` only probes while it's still None). +croniter = None +HAS_CRONITER: Optional[bool] = None + + +def _ensure_croniter() -> bool: + """Import croniter on first use; honor a pre-set HAS_CRONITER override.""" + global croniter, HAS_CRONITER + if HAS_CRONITER is None: + try: + from croniter import croniter as _croniter + croniter = _croniter + HAS_CRONITER = True + except ImportError: + HAS_CRONITER = False + return bool(HAS_CRONITER) # ============================================================================= # Configuration @@ -585,7 +599,7 @@ def parse_schedule(schedule: str) -> Dict[str, Any]: if len(parts) >= 5 and all( re.match(r'^[\d\*\-,/]+$', p) for p in parts[:5] ): - if not HAS_CRONITER: + if not _ensure_croniter(): raise ValueError("Cron expressions require 'croniter' package. Install with: pip install croniter") # Validate cron expression try: @@ -738,7 +752,7 @@ def _compute_grace_seconds(schedule: dict) -> int: grace = period_seconds // 2 return max(MIN_GRACE, min(grace, MAX_GRACE)) - if kind == "cron" and HAS_CRONITER: + if kind == "cron" and _ensure_croniter(): expr = schedule.get("expr") if expr: try: @@ -791,7 +805,7 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None expr = schedule.get("expr") if not expr: return None - if not HAS_CRONITER: + if not _ensure_croniter(): logger.warning( "Cannot compute next run for cron schedule %r: 'croniter' is " "not installed. croniter is a core dependency as of v0.9.x; " diff --git a/tools/browser_supervisor.py b/tools/browser_supervisor.py index ebd1a8d39e2..d17806f4b2a 100644 --- a/tools/browser_supervisor.py +++ b/tools/browser_supervisor.py @@ -26,10 +26,14 @@ import logging import threading import time from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING -import websockets -from websockets.asyncio.client import ClientConnection +# ``websockets`` costs ~22 ms at import and is only needed when a supervisor +# actually connects to a CDP endpoint (``_connect_ws``). With +# ``from __future__ import annotations`` in force the ``ClientConnection`` +# annotation is string-only, so the type import stays under TYPE_CHECKING. +if TYPE_CHECKING: + from websockets.asyncio.client import ClientConnection logger = logging.getLogger(__name__) @@ -649,6 +653,7 @@ class CDPSupervisor: attempt = 0 last_success_at = 0.0 backoff = 0.5 + import websockets # deferred: only supervisors that connect pay the import while not self._stop_requested: try: self._ws = await asyncio.wait_for( diff --git a/tools/vision_tools.py b/tools/vision_tools.py index fb5a3820ec6..cef07ee0237 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -40,7 +40,28 @@ from pathlib import Path from typing import Any, Awaitable, Dict, Optional from urllib.parse import urlparse import httpx -from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning + +# ``agent.auxiliary_client`` pulls credential_pool → hermes_cli.auth → httpx +# → rich (~50 ms cold); only vision handlers need it. Loaded lazily; both +# names stay module attributes so tests can keep patching +# ``tools.vision_tools.async_call_llm``. Truthy-skip: injected mocks win. +async_call_llm: Any = None +extract_content_or_reasoning: Any = None + + +def _load_auxiliary_client() -> None: + global async_call_llm, extract_content_or_reasoning + if async_call_llm is None or extract_content_or_reasoning is None: + from agent.auxiliary_client import ( + async_call_llm as _acl, + extract_content_or_reasoning as _ecr, + ) + if async_call_llm is None: + async_call_llm = _acl + if extract_content_or_reasoning is None: + extract_content_or_reasoning = _ecr + + from hermes_constants import get_hermes_dir from tools.debug_helpers import DebugSession from tools.website_policy import check_website_access @@ -1251,6 +1272,7 @@ async def vision_analyze_tool( } if model: call_kwargs["model"] = model + _load_auxiliary_client() # Try full-size image first; on size-related rejection, downscale and retry. try: response = await async_call_llm(**call_kwargs) @@ -1758,6 +1780,7 @@ async def video_analyze_tool( if model: call_kwargs["model"] = model + _load_auxiliary_client() response = await async_call_llm(**call_kwargs) analysis = extract_content_or_reasoning(response)