mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(startup): build version-bearing User-Agents when a request is built, not at import
Every CLI start imports all provider plugins (hermes_cli.config lists their env vars), and fireworks/gmi/xai/router/kimi/opencode plus auth_constants, models, model_catalog, auxiliary_client, gemini_native_adapter, web_server_messaging, slack, yuanbao and perplexity built a `<product>/<release>` string at module level. In a source checkout the release comes from the 7-call git provenance walk (`git tag --merged HEAD` alone is ~0.35 s in a large clone), so `hermes sessions list` paid it for headers it never sends. Provider profiles take a LazyHeaders mapping (resolved on first read by the client builders, which already copy with dict()); core modules call version_info.hermes_user_agent() where the request is built. Header values are unchanged.
This commit is contained in:
1 parent
8c5a4f37f5
commit
68e4c2b31a
23 files changed
+168
-110
No files matched your search
@@ -937,13 +937,12 @@ def build_nvidia_nim_headers(base_url: str | None) -> dict:
|
||||
|
||||
|
||||
# Vercel AI Gateway attribution (HTTP-Referer → referrerUrl, X-Title → appName).
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
|
||||
_AI_GATEWAY_HEADERS = {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
|
||||
}
|
||||
|
||||
def _ai_gateway_headers() -> dict[str, str]:
|
||||
return {"HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent",
|
||||
"User-Agent": hermes_user_agent("HermesAgent")}
|
||||
|
||||
# Nous Portal attribution extra_body. Tags come from agent.portal_tags so the client= marker
|
||||
# tracks the canonical base version — never inline a literal here.
|
||||
|
||||
@@ -40,11 +40,11 @@ def _qwen_portal_headers() -> dict:
|
||||
# Builders resolve their module lazily so run_agent keeps its import-time cost and avoids cycles.
|
||||
_ROUTE_DEFAULT_HEADERS = (
|
||||
("openrouter.ai", lambda self, url: _lazy_attr("agent.auxiliary_client", "build_or_headers")()),
|
||||
("ai-gateway.vercel.sh", lambda self, url: dict(_lazy_attr("agent.auxiliary_client", "_AI_GATEWAY_HEADERS"))),
|
||||
("ai-gateway.vercel.sh", lambda self, url: _lazy_attr("agent.auxiliary_client", "_ai_gateway_headers")()),
|
||||
("integrate.api.nvidia.com", lambda self, url: _lazy_attr("agent.auxiliary_client", "build_nvidia_nim_headers")(url)),
|
||||
("api.routermint.com", lambda self, url: _routermint_headers()),
|
||||
("githubcopilot.com", lambda self, url: _lazy_attr("hermes_cli.models", "copilot_default_headers")()),
|
||||
("api.kimi.com", lambda self, url: dict(_lazy_attr("agent.auxiliary_client", "_AI_GATEWAY_HEADERS"))),
|
||||
("api.kimi.com", lambda self, url: _lazy_attr("agent.auxiliary_client", "_ai_gateway_headers")()),
|
||||
("portal.qwen.ai", lambda self, url: _qwen_portal_headers()),
|
||||
("chatgpt.com", lambda self, url: _lazy_attr("agent.codex_headers", "codex_cloudflare_headers")(
|
||||
self._client_kwargs.get("api_key", ""), base_url=url)),
|
||||
|
||||
@@ -26,9 +26,11 @@ from agent.gemini_schema import prepare_gemini_tool_parameters, sanitize_gemini_
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
|
||||
_API_CLIENT = f"hermes-agent/{get_version_info().base_version}" # client context per Gemini's partner-integration guidance
|
||||
|
||||
def _api_client() -> str:
|
||||
return hermes_user_agent("hermes-agent") # client context per Gemini's partner-integration guidance
|
||||
|
||||
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
# A Vertex AI express-mode base, when the user configures one explicitly: aiplatform serves the
|
||||
@@ -202,7 +204,7 @@ def probe_gemini_tier(
|
||||
return "unknown"
|
||||
base = normalize_gemini_base_url(base_url)
|
||||
payload = {"contents": [{"role": "user", "parts": [{"text": "hi"}]}], "generationConfig": {"maxOutputTokens": 1}}
|
||||
headers = {"Content-Type": "application/json", "X-Goog-Api-Client": _API_CLIENT}
|
||||
headers = {"Content-Type": "application/json", "X-Goog-Api-Client": _api_client()}
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(f"{base}/models/{model}:generateContent", params={"key": key}, json=payload, headers=headers)
|
||||
@@ -903,7 +905,7 @@ class GeminiNativeClient:
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Content-Type": "application/json", "Accept": "application/json", **self._auth_headers(),
|
||||
"User-Agent": f"{_API_CLIENT} (gemini-native)", "X-Goog-Api-Client": _API_CLIENT, **self._default_headers}
|
||||
"User-Agent": f"{_api_client()} (gemini-native)", "X-Goog-Api-Client": _api_client(), **self._default_headers}
|
||||
|
||||
def _http_error(self, response: httpx.Response, body_text: Optional[str] = None) -> GeminiAPIError:
|
||||
return gemini_http_error(response, body_text=body_text, api_key=self.api_key, base_url=self.base_url)
|
||||
|
||||
@@ -22,7 +22,8 @@ from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple
|
||||
from hermes_cli.auth_constants import (
|
||||
_decode_jwt_claims, AUTH_LOCK_TIMEOUT_SECONDS, AuthError,
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL,
|
||||
CODEX_OAUTH_USER_AGENT, CODEX_RATE_LIMITED_CODE, DEFAULT_CODEX_BASE_URL, _codex_err, httpx)
|
||||
CODEX_RATE_LIMITED_CODE, DEFAULT_CODEX_BASE_URL, _codex_err, httpx)
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from utils import env_float, normalize_proxy_env_vars
|
||||
|
||||
if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
|
||||
@@ -489,7 +490,7 @@ def refresh_codex_oauth_pure(
|
||||
"codex_auth_missing_refresh_token", relogin=True)
|
||||
with _codex_http_client(
|
||||
timeout=httpx.Timeout(max(5.0, float(timeout_seconds))),
|
||||
headers={"Accept": "application/json", "User-Agent": CODEX_OAUTH_USER_AGENT}) as client:
|
||||
headers={"Accept": "application/json", "User-Agent": hermes_user_agent()}) as client:
|
||||
response = client.post(
|
||||
CODEX_OAUTH_TOKEN_URL, headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
|
||||
@@ -9,7 +9,6 @@ import base64
|
||||
import json
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from hermes_cli.version_info import get_version_info
|
||||
|
||||
# httpx is imported lazily (~30ms) because hermes_cli.auth is on the interactive-CLI startup path
|
||||
# (credential_pool -> auxiliary_client -> cli_commands_mixin). The proxy resolves on first attribute
|
||||
@@ -92,7 +91,6 @@ STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"
|
||||
STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
|
||||
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
CODEX_OAUTH_USER_AGENT = f"hermes-cli/{get_version_info().base_version}"
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
XAI_OAUTH_ISSUER = "https://auth.x.ai"
|
||||
XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import NamedTuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from utils import base_url_host_matches, normalize_proxy_env_vars
|
||||
|
||||
@@ -211,7 +211,7 @@ def _anthropic_messages_probe(base: str, key: str):
|
||||
from agent.anthropic_endpoints import _requires_bearer_auth
|
||||
normalized, kwargs = _base_client_kwargs(base, None)
|
||||
auth = {"Authorization": f"Bearer {key}"} if _requires_bearer_auth(normalized) else {"x-api-key": key}
|
||||
headers = {"anthropic-version": "2023-06-01", "User-Agent": _HERMES_USER_AGENT, **auth}
|
||||
headers = {"anthropic-version": "2023-06-01", "User-Agent": hermes_user_agent(), **auth}
|
||||
model = str(_model_cfg().get("default") or "").strip() or "claude-sonnet-4-5"
|
||||
body = {"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "ping"}]}
|
||||
return httpx.post(normalized + "/v1/messages", headers=headers, params=kwargs.get("default_query"), json=body, timeout=10)
|
||||
@@ -242,7 +242,7 @@ def _apikey_request(key: str, base_env, default_url) -> tuple:
|
||||
if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"):
|
||||
base = base.rstrip("/") + "/v1"
|
||||
url = (base.rstrip("/") + "/models") if base else default_url
|
||||
headers = {"Authorization": f"Bearer {key}", "User-Agent": _HERMES_USER_AGENT}
|
||||
headers = {"Authorization": f"Bearer {key}", "User-Agent": hermes_user_agent()}
|
||||
if base_url_host_matches(base, "api.kimi.com"):
|
||||
headers["User-Agent"] = "claude-code/0.1.0"
|
||||
# Google's Generative Language API rejects ``Authorization: Bearer <api-key>`` with 401
|
||||
@@ -395,7 +395,7 @@ def _probe_github_token() -> ProbeResult:
|
||||
try:
|
||||
import httpx
|
||||
r = httpx.get(GITHUB_API_PROBE_URL, timeout=10, headers={
|
||||
"Authorization": f"Bearer {get_env_value(var)}", "User-Agent": _HERMES_USER_AGENT,
|
||||
"Authorization": f"Bearer {get_env_value(var)}", "User-Agent": hermes_user_agent(),
|
||||
"Accept": "application/vnd.github+json"})
|
||||
except Exception as e:
|
||||
return _row(name, "fail", f"({e})", ["Check network connectivity"])
|
||||
|
||||
@@ -18,7 +18,7 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from utils import atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,8 +40,6 @@ DEFAULT_FETCH_TIMEOUT = 8.0
|
||||
FETCH_RETRY_DELAY_SECONDS = 300.0
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
_HERMES_USER_AGENT = f"hermes-cli/{get_version_info().base_version}"
|
||||
|
||||
# In-process cache, invalidated against the disk file's path + mtime and TTL. The path matters:
|
||||
# under a multiplexed gateway each profile has its own ``<home>/cache/model_catalog.json``, and
|
||||
# mtime alone cannot tell two profiles' files apart.
|
||||
@@ -127,7 +125,7 @@ def _cache_path() -> Path:
|
||||
def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None:
|
||||
"""HTTP GET the manifest URL and return a validated dict, or None on failure."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT})
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": hermes_user_agent()})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||
|
||||
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from hermes_cli.route_identity import normalize_route_base_url
|
||||
from hermes_cli.urllib_security import open_credentialed_url
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from hermes_cli.models_catalog_static import (
|
||||
CuratedFallbackModels,
|
||||
CANONICAL_PROVIDERS,
|
||||
@@ -67,10 +67,6 @@ from hermes_cli.models_local import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity
|
||||
# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.
|
||||
_HERMES_USER_AGENT = f"hermes-cli/{get_version_info().base_version}"
|
||||
|
||||
COPILOT_BASE_URL = "https://api.githubcopilot.com"
|
||||
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
|
||||
COPILOT_EDITOR_VERSION = "vscode/1.104.1"
|
||||
@@ -2680,9 +2676,9 @@ def probe_api_models(
|
||||
return _probe_result(
|
||||
None, normalized.rstrip("/") + "/models", normalized,
|
||||
alternate_base if alternate_base != normalized else None)
|
||||
headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT}
|
||||
headers: dict[str, str] = {"User-Agent": hermes_user_agent()}
|
||||
if urllib.parse.urlparse(normalized).hostname == "generativelanguage.googleapis.com":
|
||||
headers["X-Goog-Api-Client"] = f"hermes-agent/{get_version_info().base_version}"
|
||||
headers["X-Goog-Api-Client"] = hermes_user_agent("hermes-agent")
|
||||
if api_key and api_mode == "anthropic_messages":
|
||||
headers["x-api-key"] = api_key
|
||||
headers["anthropic-version"] = "2023-06-01"
|
||||
@@ -2784,7 +2780,7 @@ def _fetch_deepinfra_catalog(
|
||||
if last_fail is not None and (time.monotonic() - last_fail) < _DEEPINFRA_CATALOG_NEG_TTL:
|
||||
return None
|
||||
|
||||
headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT}
|
||||
headers: dict[str, str] = {"User-Agent": hermes_user_agent()}
|
||||
api_key = _deepinfra_env("DEEPINFRA_API_KEY")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
@@ -2859,7 +2855,7 @@ def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
base_url = AI_GATEWAY_BASE_URL
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": _HERMES_USER_AGENT}
|
||||
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": hermes_user_agent()}
|
||||
try:
|
||||
url = base_url.rstrip("/") + "/models"
|
||||
data = _get_json(url, timeout=timeout, headers=headers)
|
||||
|
||||
@@ -227,7 +227,8 @@ def probe_ollama_local_models(
|
||||
"""Probe local Ollama-compatible models from native ``/api/tags`` (Ollama's authoritative local
|
||||
catalog; ``/v1/models`` is not required for local servers). ``None`` when the endpoint cannot be
|
||||
reached or returns malformed data; a list (possibly empty) when it was reachable."""
|
||||
from hermes_cli.models import _HERMES_USER_AGENT, _get_ollama_base_url, _urlopen_model_catalog_request
|
||||
from hermes_cli.models import _get_ollama_base_url, _urlopen_model_catalog_request
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
root = _root_for_ollama_native_api(base_url or _get_ollama_base_url())
|
||||
if not root:
|
||||
return None
|
||||
@@ -242,7 +243,7 @@ def probe_ollama_local_models(
|
||||
return None
|
||||
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE.pop(failure_key, None)
|
||||
try:
|
||||
request_headers = {"User-Agent": _HERMES_USER_AGENT, **(headers or {})}
|
||||
request_headers = {"User-Agent": hermes_user_agent(), **(headers or {})}
|
||||
req = urllib.request.Request(root.rstrip("/") + "/api/tags", headers=request_headers)
|
||||
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
||||
models = _parse_ollama_tags(json.loads(resp.read().decode()))
|
||||
@@ -372,10 +373,10 @@ def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]:
|
||||
|
||||
def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict:
|
||||
"""HTTP headers for LM Studio native API requests."""
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from agent.command_token_source import materialize_probe_api_key
|
||||
token = materialize_probe_api_key(api_key)
|
||||
return {"User-Agent": _HERMES_USER_AGENT, **({"Authorization": f"Bearer {token}"} if token else {})}
|
||||
return {"User-Agent": hermes_user_agent(), **({"Authorization": f"Bearer {token}"} if token else {})}
|
||||
|
||||
|
||||
def _lmstudio_fetch_raw_models(
|
||||
|
||||
@@ -225,7 +225,8 @@ def fetch_models_with_pricing(
|
||||
another's read. *include_sale_original* (Nous Portal only) copies the gateway's pre-discount
|
||||
``pricing.original`` rates through as a nested ``original`` dict for sale chrome."""
|
||||
from hermes_cli.chat_catalog import catalog_item_is_generation
|
||||
from hermes_cli.models import _HERMES_USER_AGENT, _openrouter_model_supports_tools
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
url_root = (base_url or "").rstrip("/")
|
||||
cache_key = url_root + _pricing_auth_fingerprint(api_key)
|
||||
if not force_refresh:
|
||||
@@ -234,7 +235,7 @@ def fetch_models_with_pricing(
|
||||
return cached
|
||||
|
||||
url = url_root + "/v1/models"
|
||||
headers = {"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
|
||||
headers = {"Accept": "application/json", "User-Agent": hermes_user_agent()}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
payload = _get_json(url, headers, timeout)
|
||||
@@ -451,7 +452,7 @@ def _fetch_kilocode_pricing(timeout: float = 8.0, *, force_refresh: bool = False
|
||||
The gateway's ``-1`` rate sentinel means "varies" (auto-router models); no price is shown
|
||||
rather than a fabricated one, so such rows are skipped.
|
||||
"""
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
|
||||
cache_key = _kilo_pricing_scope()
|
||||
if not cache_key:
|
||||
@@ -461,7 +462,7 @@ def _fetch_kilocode_pricing(timeout: float = 8.0, *, force_refresh: bool = False
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
headers = {"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
|
||||
headers = {"Accept": "application/json", "User-Agent": hermes_user_agent()}
|
||||
api_key = os.getenv("KILOCODE_API_KEY", "").strip()
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
@@ -673,7 +674,7 @@ def _fireworks_pricing_from_models_dev(*, force_refresh: bool = False) -> dict[s
|
||||
def _fetch_novita_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
||||
"""NovitaAI /v1/models pricing (per-million prices in units of 0.0001 USD → per-token strings),
|
||||
cached on the resolved base URL so menu renders don't re-hit the network."""
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
api_key = os.getenv("NOVITA_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
return {}
|
||||
@@ -684,7 +685,7 @@ def _fetch_novita_pricing(timeout: float = 8.0, *, force_refresh: bool = False)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json", "User-Agent": hermes_user_agent()}
|
||||
payload = _get_json(cache_key + "/models", headers, timeout)
|
||||
if payload is None:
|
||||
return _cache_catalog(cache_key, {})
|
||||
|
||||
@@ -146,9 +146,10 @@ def _seed_reasoning_caps(url: str, items: Any) -> Optional[Caps]:
|
||||
def _fetch_reasoning_caps_catalog(url: str, timeout: float) -> Optional[Caps]:
|
||||
"""Fetch one OpenRouter-shaped ``/v1/models`` catalog → per-model caps; None when unreachable or
|
||||
empty so callers remember the failure. Sends a User-Agent: the Portal 403s anonymous reads."""
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
m = _origin()
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": m._HERMES_USER_AGENT})
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": hermes_user_agent()})
|
||||
with m._urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
|
||||
@@ -274,6 +274,21 @@ def _reset_version_info_cache() -> None:
|
||||
_cached_version_info = None
|
||||
|
||||
|
||||
def _stamp_or_none() -> VersionInfo | None:
|
||||
"""The install stamp's provenance, or None without a valid stamp.
|
||||
|
||||
A malformed stamp (missing/illegal updateMechanism, mispackaged "light"
|
||||
payload) raises RuntimeError — the build lane that wrote it must be
|
||||
fixed, but a crashing CLI is the wrong failure mode for every uncached
|
||||
caller. Degrade to live git / "unknown" and let the authoring lane's own
|
||||
tests surface the malformed stamp.
|
||||
"""
|
||||
try:
|
||||
return _stamp_version_info()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def get_version_info() -> VersionInfo:
|
||||
"""Return cached provenance from install stamp, git, or unknown."""
|
||||
global _cached_version_info
|
||||
@@ -281,15 +296,7 @@ def get_version_info() -> VersionInfo:
|
||||
return _cached_version_info
|
||||
|
||||
# 1. Install stamp (packaged builds: Docker, Nix)
|
||||
# A malformed stamp (missing/illegal updateMechanism, mispackaged "light"
|
||||
# payload) raises RuntimeError — the build lane that wrote it must be
|
||||
# fixed, but a crashing CLI is the wrong failure mode for every uncached
|
||||
# caller. Degrade to "unknown" and let the authoring lane's own tests
|
||||
# surface the malformed stamp.
|
||||
try:
|
||||
info = _stamp_version_info()
|
||||
except RuntimeError:
|
||||
info = None
|
||||
info = _stamp_or_none()
|
||||
|
||||
# 2. Live git (source/dev installs)
|
||||
if info is None:
|
||||
@@ -305,6 +312,16 @@ def get_version_info() -> VersionInfo:
|
||||
return info
|
||||
|
||||
|
||||
def hermes_user_agent(product: str = "hermes-cli") -> str:
|
||||
"""``<product>/<base version>`` for an outbound request.
|
||||
|
||||
Call it where the request is built, never at module level: a source checkout resolves the
|
||||
release from git, so a module-level read made every Hermes start pay that walk whether or not
|
||||
it ever sent the request.
|
||||
"""
|
||||
return f"{product}/{get_version_info().base_version}"
|
||||
|
||||
|
||||
def get_code_identity(refresh: bool = False) -> dict:
|
||||
"""Return the running install's code identity as a flat dict.
|
||||
|
||||
@@ -330,3 +347,19 @@ def get_code_identity(refresh: bool = False) -> dict:
|
||||
"source": info.source,
|
||||
"commit_date": info.commit_date,
|
||||
}
|
||||
|
||||
|
||||
def read_checkout_sha() -> str | None:
|
||||
"""The running install's commit, re-read now: the stamp's, else ``git rev-parse HEAD``.
|
||||
|
||||
Same answer as ``get_code_identity(refresh=True)["sha"]`` for one git call instead of the
|
||||
whole provenance walk (the tag scan alone costs ~0.35 s in a large clone), which the
|
||||
pending-restart check on every CLI start was paying just to compare SHAs. Drops the cached
|
||||
identity like that refresh does: the checkout may have moved under this process.
|
||||
"""
|
||||
_reset_version_info_cache()
|
||||
stamp = _stamp_or_none()
|
||||
if stamp is not None:
|
||||
return stamp.commit
|
||||
repo_dir = _resolve_repo_dir()
|
||||
return _run_git(repo_dir, "rev-parse", "HEAD") if repo_dir is not None else None
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS, write_platform_config_field
|
||||
from hermes_cli.setup_hidden_env import is_setup_hidden_env as _is_setup_hidden_env
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
|
||||
# Same logger the code used before extraction (record parity).
|
||||
_log = logging.getLogger("hermes_cli.web_server")
|
||||
@@ -228,7 +228,6 @@ def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) ->
|
||||
|
||||
|
||||
_TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com"
|
||||
_TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{get_version_info().base_version}"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -268,7 +267,7 @@ def _telegram_onboarding_request_sync(
|
||||
method: str, path: str, *, body: dict[str, Any] | None = None, bearer_token: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
import httpx
|
||||
headers = {"Accept": "application/json", "User-Agent": _TELEGRAM_ONBOARDING_USER_AGENT}
|
||||
headers = {"Accept": "application/json", "User-Agent": hermes_user_agent("HermesDashboard")}
|
||||
request_kwargs: dict[str, Any] = {}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
from providers.base import LazyHeaders, ProviderProfile
|
||||
|
||||
|
||||
class FireworksProfile(ProviderProfile):
|
||||
@@ -33,11 +33,11 @@ fireworks = FireworksProfile(
|
||||
base_url="https://api.fireworks.ai/inference/v1", auth_type="api_key",
|
||||
# Attribution headers (canonical Hermes set); via default_headers so they
|
||||
# survive switch_model and credential rotation.
|
||||
default_headers={
|
||||
default_headers=LazyHeaders(lambda: {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
|
||||
},
|
||||
"User-Agent": hermes_user_agent("HermesAgent"),
|
||||
}),
|
||||
default_aux_model="accounts/fireworks/models/glm-5p2",
|
||||
# Picker safety net when the live catalog fetch fails.
|
||||
fallback_models=(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""GMI Cloud provider profile."""
|
||||
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
from providers.base import LazyHeaders, ProviderProfile
|
||||
|
||||
gmi = ProviderProfile(
|
||||
name="gmi", aliases=("gmi-cloud", "gmicloud"), display_name="GMI Cloud",
|
||||
@@ -10,7 +10,7 @@ gmi = ProviderProfile(
|
||||
signup_url="https://www.gmicloud.ai/", env_vars=("GMI_API_KEY", "GMI_BASE_URL"),
|
||||
base_url="https://api.gmi-serving.com/v1", auth_type="api_key",
|
||||
# Attribution so GMI can identify Hermes Agent traffic.
|
||||
default_headers={"User-Agent": f"HermesAgent/{get_version_info().base_version}"},
|
||||
default_headers=LazyHeaders(lambda: {"User-Agent": hermes_user_agent("HermesAgent")}),
|
||||
default_aux_model="google/gemini-3.1-flash-lite-preview",
|
||||
fallback_models=(
|
||||
"zai-org/GLM-5.1-FP8", "deepseek-ai/DeepSeek-V3.2", "moonshotai/Kimi-K2.5",
|
||||
|
||||
@@ -5,19 +5,20 @@ from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from agent.reasoning_effort import KIMI_K3_EFFORTS, KIMI_K3_OVERRIDES, thinking_toggle_extras
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from providers import register_provider
|
||||
from providers.base import OMIT_TEMPERATURE, ProviderProfile
|
||||
from providers.base import OMIT_TEMPERATURE, LazyHeaders, ProviderProfile
|
||||
|
||||
_HEADERS = {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
|
||||
# Exclude brotli: httpx's brotlicffi backend has a streaming decode bug on
|
||||
# Moonshot's content-encoding: br SSE responses (#28043, #48428, #59556).
|
||||
# gzip sidesteps it while still compressing the transfer.
|
||||
"Accept-Encoding": "gzip",
|
||||
}
|
||||
def _headers() -> dict[str, str]:
|
||||
return {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": hermes_user_agent("HermesAgent"),
|
||||
# Exclude brotli: httpx's brotlicffi backend has a streaming decode bug on
|
||||
# Moonshot's content-encoding: br SSE responses (#28043, #48428, #59556).
|
||||
# gzip sidesteps it while still compressing the transfer.
|
||||
"Accept-Encoding": "gzip",
|
||||
}
|
||||
|
||||
|
||||
def _is_confirmed_kimi_coding_url(base_url: str) -> bool:
|
||||
@@ -69,7 +70,7 @@ def _kimi(name: str, aliases: tuple, env_vars: tuple, base_url: str) -> KimiProf
|
||||
return KimiProfile(
|
||||
name=name, aliases=aliases, env_vars=env_vars, base_url=base_url,
|
||||
fixed_temperature=OMIT_TEMPERATURE, default_max_tokens=32000,
|
||||
default_headers=dict(_HEADERS), default_aux_model="kimi-k2-turbo-preview",
|
||||
default_headers=LazyHeaders(_headers), default_aux_model="kimi-k2-turbo-preview",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,18 +7,19 @@ chat_completions reasoning translations (GLM-5.2, Kimi K2, DeepSeek, Ox Alpha).
|
||||
from typing import Any
|
||||
|
||||
from agent import reasoning_effort as re_
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
from providers.base import LazyHeaders, ProviderProfile
|
||||
from datetime import UTC
|
||||
|
||||
# Attribution headers (same values as OpenRouter / Vercel / Fireworks); via
|
||||
# default_headers so they survive model switches and credential rotation.
|
||||
_ATTRIBUTION_HEADERS = {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
|
||||
}
|
||||
def _attribution_headers() -> dict[str, str]:
|
||||
return {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": hermes_user_agent("HermesAgent"),
|
||||
}
|
||||
|
||||
|
||||
def _flat_model_name(model: str | None) -> str:
|
||||
@@ -118,13 +119,13 @@ class OpenCodeZenProfile(ProviderProfile):
|
||||
|
||||
opencode_zen = OpenCodeZenProfile(
|
||||
name="opencode-zen", aliases=("opencode", "opencode_zen", "zen"), env_vars=("OPENCODE_ZEN_API_KEY",),
|
||||
base_url="https://opencode.ai/zen/v1", default_headers=dict(_ATTRIBUTION_HEADERS),
|
||||
base_url="https://opencode.ai/zen/v1", default_headers=LazyHeaders(_attribution_headers),
|
||||
default_aux_model="gemini-3-flash",
|
||||
)
|
||||
|
||||
opencode_go = OpenCodeGoProfile(
|
||||
name="opencode-go", aliases=("opencode_go", "go", "opencode-go-sub"), env_vars=("OPENCODE_GO_API_KEY",),
|
||||
base_url="https://opencode.ai/zen/go/v1", default_headers=dict(_ATTRIBUTION_HEADERS),
|
||||
base_url="https://opencode.ai/zen/go/v1", default_headers=LazyHeaders(_attribution_headers),
|
||||
default_aux_model="glm-5",
|
||||
# The Go relay's upstream validates tool content as a strict string: list-type tool
|
||||
# content (native vision embeds) 422s with ``messages.N.tool.content.str Input should
|
||||
|
||||
@@ -19,9 +19,9 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.reasoning_effort import EFFORT_LADDER
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile, _profile_user_agent
|
||||
from providers.base import LazyHeaders, ProviderProfile, _profile_user_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -294,7 +294,7 @@ router = RouterProfile(
|
||||
env_vars=("RAMP_ROUTER_API_KEY", "ROUTER_API_KEY", "RAMP_ROUTER_BASE_URL"), base_url=_base_url(),
|
||||
auth_type="api_key",
|
||||
# Router attributes coding-agent clients by UA prefix; its WAF rejects default UAs.
|
||||
default_headers={"User-Agent": f"Hermes-Agent/{get_version_info().base_version}"},
|
||||
default_headers=LazyHeaders(lambda: {"User-Agent": hermes_user_agent("Hermes-Agent")}),
|
||||
supports_vision=True, default_aux_model="gpt-5.4-mini",
|
||||
fallback_models=(), # account-scoped IDs; the picker uses fetch_models()
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""xAI (Grok) provider profile."""
|
||||
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
from providers.base import LazyHeaders, ProviderProfile
|
||||
|
||||
xai = ProviderProfile(
|
||||
name="xai", aliases=("grok", "x-ai", "x.ai"), api_mode="codex_responses", env_vars=("XAI_API_KEY",),
|
||||
base_url="https://api.x.ai/v1", auth_type="api_key",
|
||||
default_headers={"User-Agent": f"Hermes-Agent/{get_version_info().base_version}"},
|
||||
default_headers=LazyHeaders(lambda: {"User-Agent": hermes_user_agent("Hermes-Agent")}),
|
||||
)
|
||||
|
||||
register_provider(xai)
|
||||
@@ -67,9 +67,7 @@ except ImportError: # pragma: no cover - plugin loaded outside package context
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# User-Agent prefix (``HermesAgent/<version>``) for platform-partner attribution of API calls.
|
||||
from hermes_cli.version_info import get_version_info
|
||||
|
||||
_HERMES_SLACK_USER_AGENT_PREFIX = f"HermesAgent/{get_version_info().base_version}"
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
|
||||
_SLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024
|
||||
_BOOL_WORDS = frozenset({"1", "0", "true", "false", "yes", "no", "on", "off"})
|
||||
@@ -1755,7 +1753,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
@staticmethod
|
||||
def _new_web_client(token: str, proxy_url: Optional[str]) -> Any:
|
||||
client = AsyncWebClient(token=token, user_agent_prefix=_HERMES_SLACK_USER_AGENT_PREFIX)
|
||||
client = AsyncWebClient(token=token, user_agent_prefix=hermes_user_agent("HermesAgent"))
|
||||
_apply_slack_proxy(client, proxy_url)
|
||||
return client
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ logger = logging.getLogger(__name__)
|
||||
# AUTH_BIND / sign-token header values
|
||||
from hermes_cli.version_info import get_version_info
|
||||
|
||||
_APP_VERSION = _BOT_VERSION = get_version_info().base_version
|
||||
_YUANBAO_INSTANCE_ID = str(HERMES_INSTANCE_ID)
|
||||
_OPERATION_SYSTEM = sys.platform
|
||||
|
||||
@@ -217,8 +216,9 @@ class SignManager:
|
||||
timestamp = cls.build_timestamp()
|
||||
payload = {"app_key": app_key, "nonce": nonce,
|
||||
"signature": cls.compute_signature(nonce, timestamp, app_key, app_secret), "timestamp": timestamp}
|
||||
headers = {"Content-Type": "application/json", "X-AppVersion": _APP_VERSION, "X-OperationSystem": _OPERATION_SYSTEM,
|
||||
"X-Instance-Id": _YUANBAO_INSTANCE_ID, "X-Bot-Version": _BOT_VERSION}
|
||||
version = get_version_info().base_version
|
||||
headers = {"Content-Type": "application/json", "X-AppVersion": version, "X-OperationSystem": _OPERATION_SYSTEM,
|
||||
"X-Instance-Id": _YUANBAO_INSTANCE_ID, "X-Bot-Version": version}
|
||||
if route_env:
|
||||
headers["X-Route-Env"] = route_env
|
||||
logger.info("Sign token request: url=%s%s", url, f" (retry {attempt}/{cls.MAX_RETRIES})" if attempt > 0 else "")
|
||||
@@ -1885,10 +1885,10 @@ class ConnectionManager:
|
||||
if self._ws is None:
|
||||
return False
|
||||
uid = adapter._bot_id or token_data.get("bot_id", "")
|
||||
msg_id = str(uuid.uuid4())
|
||||
msg_id, version = str(uuid.uuid4()), get_version_info().base_version
|
||||
await self._ws.send(encode_auth_bind(
|
||||
biz_id="ybBot", uid=uid, source=token_data.get("source") or "bot", token=token_data.get("token", ""),
|
||||
msg_id=msg_id, app_version=_APP_VERSION, operation_system=_OPERATION_SYSTEM, bot_version=_BOT_VERSION,
|
||||
msg_id=msg_id, app_version=version, operation_system=_OPERATION_SYSTEM, bot_version=version,
|
||||
route_env=adapter._route_env or token_data.get("route_env", "") or "",
|
||||
))
|
||||
logger.debug("[%s] AUTH_BIND sent (msg_id=%s uid=%s)", adapter.name, msg_id, uid)
|
||||
|
||||
@@ -43,7 +43,7 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
from hermes_cli.version_info import get_version_info
|
||||
from hermes_cli.version_info import hermes_user_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,12 +53,13 @@ _KEY_URL = "https://www.perplexity.ai/account/api"
|
||||
# Identify Hermes to Perplexity: the same static harness identity Hermes sends Kimi and
|
||||
# OpenCode, plus Perplexity's integration header. No per-user identifier and no separate
|
||||
# request; the call already carries the user's own API key.
|
||||
_HEADERS = {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": f"HermesAgent/{get_version_info().base_version}",
|
||||
"X-Pplx-Integration": "hermes-agent",
|
||||
}
|
||||
def _headers() -> dict[str, str]:
|
||||
return {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": hermes_user_agent("HermesAgent"),
|
||||
"X-Pplx-Integration": "hermes-agent",
|
||||
}
|
||||
|
||||
|
||||
# Search API hard cap for search_type=web.
|
||||
@@ -92,10 +93,10 @@ def _perplexity_request(endpoint: str, payload: dict[str, Any], gateway=None) ->
|
||||
from agent.web_search_provider import get_provider_env
|
||||
|
||||
api_key = get_provider_env("PERPLEXITY_API_KEY")
|
||||
headers = _HEADERS
|
||||
headers = _headers()
|
||||
if gateway is not None:
|
||||
# Nous-owned key behind the gateway: identify the harness only, not a per-user integration.
|
||||
base_url, api_key, headers = gateway.gateway_origin.rstrip("/"), gateway.nous_user_token, {"User-Agent": _HEADERS["User-Agent"]}
|
||||
base_url, api_key, headers = gateway.gateway_origin.rstrip("/"), gateway.nous_user_token, {"User-Agent": headers["User-Agent"]}
|
||||
elif api_key:
|
||||
base_url = (get_provider_env("PERPLEXITY_BASE_URL") or _DEFAULT_BASE_URL).rstrip("/")
|
||||
else:
|
||||
|
||||
+29
-1
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -38,6 +39,33 @@ def _profile_user_agent() -> str:
|
||||
return "hermes-cli"
|
||||
|
||||
|
||||
class LazyHeaders(Mapping[str, str]):
|
||||
"""``default_headers`` built on first read instead of at import.
|
||||
|
||||
A User-Agent carrying the Hermes release resolves it from git in a source checkout, and every
|
||||
CLI start imports all provider plugins (``hermes_cli.config`` lists their env vars), so headers
|
||||
built at import made every command pay git subprocesses for a request it may never send.
|
||||
"""
|
||||
|
||||
def __init__(self, build: Callable[[], Mapping[str, str]]) -> None:
|
||||
self._build = build
|
||||
self._headers: dict[str, str] | None = None
|
||||
|
||||
def _resolved(self) -> dict[str, str]:
|
||||
if self._headers is None:
|
||||
self._headers = dict(self._build())
|
||||
return self._headers
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return self._resolved()[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._resolved())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._resolved())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderProfile:
|
||||
"""Base provider profile — subclass or instantiate with overrides."""
|
||||
@@ -135,7 +163,7 @@ class ProviderProfile:
|
||||
hostname: str = ""
|
||||
|
||||
# ── Client-level quirks (set once at client construction) ─
|
||||
default_headers: dict[str, str] = field(default_factory=dict)
|
||||
default_headers: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
# ── Request-level quirks ─────────────────────────────────
|
||||
# Temperature: None = use caller's default, OMIT_TEMPERATURE = don't send
|
||||
|
||||
Reference in new issue
Block a user