diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 6baa9f341e..0ac0359e0a 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -22,10 +22,25 @@ from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple from utils import normalize_proxy_env_vars -try: - import anthropic as _anthropic_sdk -except ImportError: - _anthropic_sdk = None # type: ignore[assignment] +# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls +# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.) +# and the 3 usage sites (build_anthropic_client, build_anthropic_bedrock_client, +# read_claude_code_credentials_from_keychain) are all on cold user-triggered +# paths. Access via the `_get_anthropic_sdk()` accessor below, which caches +# the module after the first call and returns None on ImportError. +_anthropic_sdk: Any = ... # sentinel — None means "tried and missing" + + +def _get_anthropic_sdk(): + """Return the ``anthropic`` SDK module, importing lazily. None if not installed.""" + global _anthropic_sdk + if _anthropic_sdk is ...: + try: + import anthropic as _sdk + _anthropic_sdk = _sdk + except ImportError: + _anthropic_sdk = None + return _anthropic_sdk logger = logging.getLogger(__name__) @@ -395,6 +410,7 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = Returns an anthropic.Anthropic instance. """ + _anthropic_sdk = _get_anthropic_sdk() if _anthropic_sdk is None: raise ImportError( "The 'anthropic' package is required for the Anthropic provider. " @@ -492,6 +508,7 @@ def build_anthropic_bedrock_client(region: str): Auth uses the boto3 default credential chain (IAM roles, SSO, env vars). """ + _anthropic_sdk = _get_anthropic_sdk() if _anthropic_sdk is None: raise ImportError( "The 'anthropic' package is required for the Bedrock provider. " diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 8f755ebed8..a472ddbcfc 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -41,10 +41,57 @@ import threading import time from pathlib import Path # noqa: F401 — used by test mocks from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse, parse_qs, urlunparse -from openai import OpenAI +# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the +# openai SDK pulls a large type tree (~240 ms cold, including responses/*, +# graders/*). We expose `OpenAI` here as a thin proxy that imports the SDK on +# first call and forwards, so: +# (a) the 15+ in-module `OpenAI(...)` construction sites work unchanged +# (Python's function-scope name lookup resolves `OpenAI` to the proxy +# object bound in module globals here, without triggering any import); +# (b) external code can still do `auxiliary_client.OpenAI` or +# `patch("agent.auxiliary_client.OpenAI", ...)` — tests see the proxy, +# and patch replaces the module attribute as usual; +# (c) `OpenAI` as a type annotation resolves at runtime to the proxy class +# (which is harmless — annotations aren't type-checked at runtime). +# See tests/agent/test_auxiliary_client.py for patch patterns this supports. +if TYPE_CHECKING: + from openai import OpenAI # noqa: F401 — type hints only + +_OPENAI_CLS_CACHE: Optional[type] = None + + +def _load_openai_cls() -> type: + """Import and cache ``openai.OpenAI``.""" + global _OPENAI_CLS_CACHE + if _OPENAI_CLS_CACHE is None: + from openai import OpenAI as _cls + _OPENAI_CLS_CACHE = _cls + return _OPENAI_CLS_CACHE + + +class _OpenAIProxy: + """Module-level proxy that looks like the ``openai.OpenAI`` class. + + Forwards ``OpenAI(...)`` calls and ``isinstance(x, OpenAI)`` checks to the + real SDK class, importing the SDK lazily on first use. + """ + + __slots__ = () + + def __call__(self, *args, **kwargs): + return _load_openai_cls()(*args, **kwargs) + + def __instancecheck__(self, obj): + return isinstance(obj, _load_openai_cls()) + + def __repr__(self): + return "" + + +OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance from agent.credential_pool import load_pool from hermes_cli.config import get_hermes_home diff --git a/cli.py b/cli.py index 5cef4068bb..dc73c8f089 100644 --- a/cli.py +++ b/cli.py @@ -69,7 +69,9 @@ from agent.usage_pricing import ( format_duration_compact, format_token_count_compact, ) -from agent.account_usage import fetch_account_usage, render_account_usage_lines +# NOTE: `from agent.account_usage import ...` is deliberately NOT at module +# top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only +# needed when the user runs `/limits`. Lazy-imported inside the handler below. from hermes_cli.banner import _format_context_length, format_banner_version_label _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") @@ -7285,6 +7287,8 @@ class HermesCLI: provider = getattr(agent, "provider", None) or getattr(self, "provider", None) base_url = getattr(agent, "base_url", None) or getattr(self, "base_url", None) api_key = getattr(agent, "api_key", None) or getattr(self, "api_key", None) + # Lazy import — pulls the OpenAI SDK chain, only needed here. + from agent.account_usage import fetch_account_usage, render_account_usage_lines account_snapshot = None if provider: with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool: diff --git a/gateway/run.py b/gateway/run.py index 9126beb5c0..4d6f5b86ad 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -31,6 +31,12 @@ from pathlib import Path from datetime import datetime from typing import Dict, Optional, Any, List +# account_usage imports the OpenAI SDK chain (~230 ms). Only needed by +# /usage; we still import it at module top in the gateway because test +# patches (tests/gateway/test_usage_command.py) target +# `gateway.run.fetch_account_usage` as a module-level attribute. The +# gateway is a long-running daemon, so its boot cost matters less than +# preserving the established test-patch surface. from agent.account_usage import fetch_account_usage, render_account_usage_lines # --- Agent cache tuning --------------------------------------------------- diff --git a/run_agent.py b/run_agent.py index cc9df8e485..48b7381096 100644 --- a/run_agent.py +++ b/run_agent.py @@ -41,13 +41,48 @@ import urllib.request import uuid from typing import List, Dict, Any, Optional from urllib.parse import urlparse, parse_qs, urlunparse -from openai import OpenAI +# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the +# SDK pulls ~240 ms of imports. We expose `OpenAI` as a thin proxy object +# that imports the SDK on first call/isinstance check. This preserves: +# (a) the single in-module `OpenAI(**client_kwargs)` call site at +# _create_openai_client, and +# (b) `patch("run_agent.OpenAI", ...)` test patterns used by ~28 test files. import fire from datetime import datetime from pathlib import Path from hermes_constants import get_hermes_home + +_OPENAI_CLS_CACHE: Optional[type] = None + + +def _load_openai_cls() -> type: + """Import and cache ``openai.OpenAI``.""" + global _OPENAI_CLS_CACHE + if _OPENAI_CLS_CACHE is None: + from openai import OpenAI as _cls + _OPENAI_CLS_CACHE = _cls + return _OPENAI_CLS_CACHE + + +class _OpenAIProxy: + """Module-level proxy that looks like ``openai.OpenAI`` but imports lazily.""" + + __slots__ = () + + def __call__(self, *args, **kwargs): + return _load_openai_cls()(*args, **kwargs) + + def __instancecheck__(self, obj): + return isinstance(obj, _load_openai_cls()) + + def __repr__(self): + return "" + + +OpenAI = _OpenAIProxy() + # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. from hermes_cli.env_loader import load_hermes_dotenv @@ -5243,6 +5278,8 @@ class AIAgent: keepalive_http = self._build_keepalive_http_client(client_kwargs.get("base_url", "")) if keepalive_http is not None: client_kwargs["http_client"] = keepalive_http + # Uses the module-level `OpenAI` name, resolved lazily on first + # access via __getattr__ below. Tests patch via `run_agent.OpenAI`. client = OpenAI(**client_kwargs) logger.info( "OpenAI client created (%s, shared=%s) %s", diff --git a/tools/web_tools.py b/tools/web_tools.py index bc4b8703f0..4ae4cd66ad 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -47,7 +47,10 @@ import re import asyncio from typing import List, Dict, Any, Optional import httpx -from firecrawl import Firecrawl +# NOTE: `from firecrawl import Firecrawl` is deliberately NOT at module top — +# the SDK pulls ~200 ms of imports (httpcore, firecrawl.v1/v2 type trees) and +# we only need it when the backend is actually "firecrawl". See +# _get_firecrawl_client() below for the lazy import. from agent.auxiliary_client import ( async_call_llm, extract_content_or_reasoning, @@ -236,6 +239,8 @@ def _get_firecrawl_client(): if _firecrawl_client is not None and _firecrawl_client_config == client_config: return _firecrawl_client + # Lazy import — ~200 ms of SDK init, only paid when firecrawl is actually used. + from firecrawl import Firecrawl # noqa: E402 _firecrawl_client = Firecrawl(**kwargs) _firecrawl_client_config = client_config return _firecrawl_client