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:
Teknium committed 2026-10-25 17:50:59 -07:00
1 parent 8c5a4f37f5
commit 68e4c2b31a
23 files changed
+168 -110

No files matched your search

+5 -6
View File
@@ -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.
+2 -2
View File
@@ -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)),
+6 -4
View File
@@ -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)
+3 -2
View File
@@ -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={
-2
View File
@@ -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"
+4 -4
View File
@@ -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"])
+2 -4
View File
@@ -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:
+5 -9
View File
@@ -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)
+5 -4
View File
@@ -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(
+7 -6
View File
@@ -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, {})
+2 -1
View File
@@ -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:
+42 -9
View File
@@ -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
+2 -3
View File
@@ -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=(
+3 -3
View File
@@ -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",
+13 -12
View File
@@ -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
+3 -3
View File
@@ -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()
)
+3 -3
View File
@@ -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)
+2 -4
View File
@@ -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
+5 -5
View File
@@ -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)
+10 -9
View File
@@ -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
View File
@@ -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