From 486b692ddd801f8f665d3fff023149fb1cb6509e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 12 May 2026 20:49:20 -0700 Subject: [PATCH] feat(nous): unified client=hermes-client-v tag on every Portal request (#24779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(nous): unified client=hermes-client-v tag on every Portal request Every Hermes request to Nous Portal now carries the same client=hermes-client-v<__version__> tag (e.g. client=hermes-client-v0.13.0 on this release), sourced live from hermes_cli.__version__. The release script's regex bump auto-aligns it on every release. Centralized in agent/portal_tags.py and wired into all four call sites: - NousProfile.build_extra_body (main agent loop, every chat completion) - auxiliary_client.NOUS_EXTRA_BODY + _build_call_kwargs (aux client) - run_agent.py compression-summary fallback path - tools/web_tools.py web_extract fallback Replaces the client=aux marker added in #24194 with the unified version tag. Tests assert against the helper output (invariant) rather than the literal string, so they don't need updating on every release. * feat(nous): cover /goal judge and kanban specify aux paths Two aux-using surfaces bypassed call_llm by invoking client.chat.completions.create() directly without extra_body, so they were missing the unified Portal client tag: - hermes_cli/goals.py — /goal standing-goal judge - hermes_cli/kanban_specify.py — kanban triage specifier Both now pass extra_body=get_auxiliary_extra_body() or None so they inherit the version tag when the aux client points at Nous Portal, and emit nothing otherwise (no tag leak to OpenRouter/Anthropic auxes). --- agent/auxiliary_client.py | 27 +++++++- agent/portal_tags.py | 64 +++++++++++++++++++ hermes_cli/goals.py | 3 +- hermes_cli/kanban_specify.py | 3 +- plugins/model-providers/nous/__init__.py | 3 +- run_agent.py | 3 +- tests/agent/test_portal_tags.py | 61 ++++++++++++++++++ .../agent/transports/test_chat_completions.py | 3 +- tests/providers/test_profile_wiring.py | 3 +- tests/providers/test_provider_profiles.py | 3 +- tests/providers/test_transport_parity.py | 3 +- tests/run_agent/test_provider_parity.py | 3 +- tools/web_tools.py | 3 +- 13 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 agent/portal_tags.py create mode 100644 tests/agent/test_portal_tags.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 377e4ba22ea..de7b6db2b1d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -382,7 +382,28 @@ _AI_GATEWAY_HEADERS = { # Nous Portal extra_body for product attribution. # Callers should pass this as extra_body in chat.completions.create() # when the auxiliary client is backed by Nous Portal. -NOUS_EXTRA_BODY = {"tags": ["product=hermes-agent", "client=aux"]} +# +# The tags are computed from agent.portal_tags so the client= marker stays +# in lockstep with hermes_cli.__version__ across every Portal call site +# (main loop, aux, compression, web_extract). Do not inline a literal here; +# see agent/portal_tags.py for the rationale. +from agent.portal_tags import nous_portal_tags as _nous_portal_tags + + +def _nous_extra_body() -> dict: + """Return a fresh Nous Portal ``extra_body`` dict. + + Computed at call time so a hot-reloaded ``hermes_cli.__version__`` is + reflected without restarting long-running processes. + """ + return {"tags": _nous_portal_tags()} + + +# Backwards-compatible module attribute. Some callers (tests, third-party +# plugins) read ``NOUS_EXTRA_BODY`` directly; keep it as a snapshot of the +# current tags. Callers that need the freshest value should call +# ``_nous_extra_body()`` or import ``nous_portal_tags`` directly. +NOUS_EXTRA_BODY = _nous_extra_body() # Set at resolve time — True if the auxiliary client points to Nous Portal auxiliary_is_nous: bool = False @@ -3437,7 +3458,7 @@ def get_auxiliary_extra_body() -> dict: Includes Nous Portal product tags when the auxiliary client is backed by Nous Portal. Returns empty dict otherwise. """ - return dict(NOUS_EXTRA_BODY) if auxiliary_is_nous else {} + return _nous_extra_body() if auxiliary_is_nous else {} def auxiliary_max_tokens_param(value: int) -> dict: @@ -4026,7 +4047,7 @@ def _build_call_kwargs( # Provider-specific extra_body merged_extra = dict(extra_body or {}) if provider == "nous" or auxiliary_is_nous: - merged_extra.setdefault("tags", []).extend(NOUS_EXTRA_BODY["tags"]) + merged_extra.setdefault("tags", []).extend(_nous_portal_tags()) if merged_extra: kwargs["extra_body"] = merged_extra diff --git a/agent/portal_tags.py b/agent/portal_tags.py new file mode 100644 index 00000000000..647c52a076a --- /dev/null +++ b/agent/portal_tags.py @@ -0,0 +1,64 @@ +"""Centralized Nous Portal request tags. + +Every Hermes request that hits the Nous Portal — main agent loop, auxiliary +client (compression / titles / vision / web_extract / session_search / etc.), +and any future code path — must carry the same product-attribution tags so +Nous can attribute usage to Hermes Agent and bucket it by client release. + +Tag shape (sent in OpenAI-compatible ``extra_body['tags']``): + + [ + "product=hermes-agent", + "client=hermes-client-v<__version__>", + ] + +The version is sourced live from ``hermes_cli.__version__`` so it auto-aligns +to whatever release is installed; the release script +(``scripts/release.py``) regex-bumps that single string, and every Portal +request picks up the new tag on the next process start. + +Why one helper instead of inlining the literal at each site: +* Four call sites (main loop profile, aux client, run_agent compression + fallback, web_tools fallback) used to drift apart — see PR #24194 which + only got the aux site, leaving the main loop sending a different tag set. +* Tests should assert the same tag list everywhere; centralizing makes that + assertion a one-liner against this module. + +Do NOT pre-compute these as module-level constants in the consumers. The +version can change at runtime (editable installs, hot-reload tooling), and +``hermes_cli.__version__`` is the canonical source of truth. +""" + +from __future__ import annotations + +from typing import List + + +def _hermes_version() -> str: + """Return the current Hermes release version, e.g. ``"0.13.0"``. + + Falls back to ``"unknown"`` if ``hermes_cli`` cannot be imported (should + never happen in a real install — guarded for defensive testing). + """ + try: + from hermes_cli import __version__ + return __version__ + except Exception: + return "unknown" + + +def hermes_client_tag() -> str: + """Return the ``client=...`` tag for Nous Portal requests. + + Format: ``client=hermes-client-v..``. + """ + return f"client=hermes-client-v{_hermes_version()}" + + +def nous_portal_tags() -> List[str]: + """Return the canonical list of Nous Portal product tags. + + Always returns a fresh list so callers can mutate it freely + (e.g. ``merged_extra.setdefault("tags", []).extend(nous_portal_tags())``). + """ + return ["product=hermes-agent", hermes_client_tag()] diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 9e8742e08ae..6a8a2ae971f 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -307,7 +307,7 @@ def judge_goal( return "continue", "empty response (nothing to evaluate)", False try: - from agent.auxiliary_client import get_text_auxiliary_client + from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client except Exception as exc: logger.debug("goal judge: auxiliary client import failed: %s", exc) return "continue", "auxiliary client unavailable", False @@ -336,6 +336,7 @@ def judge_goal( temperature=0, max_tokens=200, timeout=timeout, + extra_body=get_auxiliary_extra_body() or None, ) except Exception as exc: logger.info("goal judge: API call failed (%s) — falling through to continue", exc) diff --git a/hermes_cli/kanban_specify.py b/hermes_cli/kanban_specify.py index d069e5ee1af..0d57fbb2504 100644 --- a/hermes_cli/kanban_specify.py +++ b/hermes_cli/kanban_specify.py @@ -155,7 +155,7 @@ def specify_task( ) try: - from agent.auxiliary_client import get_text_auxiliary_client + from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client except Exception as exc: # pragma: no cover — import smoke test logger.debug("specify: auxiliary client import failed: %s", exc) return SpecifyOutcome(task_id, False, "auxiliary client unavailable") @@ -187,6 +187,7 @@ def specify_task( temperature=0.3, max_tokens=1500, timeout=timeout or 120, + extra_body=get_auxiliary_extra_body() or None, ) except Exception as exc: logger.info( diff --git a/plugins/model-providers/nous/__init__.py b/plugins/model-providers/nous/__init__.py index f89e56c23ab..5a61952d745 100644 --- a/plugins/model-providers/nous/__init__.py +++ b/plugins/model-providers/nous/__init__.py @@ -2,6 +2,7 @@ from typing import Any +from agent.portal_tags import nous_portal_tags from providers import register_provider from providers.base import ProviderProfile @@ -12,7 +13,7 @@ class NousProfile(ProviderProfile): def build_extra_body( self, *, session_id: str | None = None, **context ) -> dict[str, Any]: - return {"tags": ["product=hermes-agent"]} + return {"tags": nous_portal_tags()} def build_api_kwargs_extras( self, diff --git a/run_agent.py b/run_agent.py index 1c4c35c96e0..f0597c90880 100644 --- a/run_agent.py +++ b/run_agent.py @@ -11542,7 +11542,8 @@ class AIAgent: "effort": "medium" } if _is_nous: - summary_extra_body["tags"] = ["product=hermes-agent"] + from agent.portal_tags import nous_portal_tags as _portal_tags + summary_extra_body["tags"] = _portal_tags() if self.api_mode == "codex_responses": codex_kwargs = self._build_api_kwargs(api_messages) diff --git a/tests/agent/test_portal_tags.py b/tests/agent/test_portal_tags.py new file mode 100644 index 00000000000..7c873ef0f60 --- /dev/null +++ b/tests/agent/test_portal_tags.py @@ -0,0 +1,61 @@ +"""Tests for agent.portal_tags — Nous Portal request tag contract.""" + +from __future__ import annotations + + +def test_hermes_client_tag_includes_current_version(): + """The client tag must reflect hermes_cli.__version__ verbatim.""" + from hermes_cli import __version__ + from agent.portal_tags import hermes_client_tag + + assert hermes_client_tag() == f"client=hermes-client-v{__version__}" + + +def test_hermes_client_tag_format(): + """The client tag has the exact shape Nous Portal expects.""" + from agent.portal_tags import hermes_client_tag + + tag = hermes_client_tag() + assert tag.startswith("client=hermes-client-v") + # No spaces, no commas — single tag value + assert " " not in tag + assert "," not in tag + + +def test_nous_portal_tags_contains_product_and_client(): + """Every Nous Portal request gets BOTH the product tag and the version tag.""" + from agent.portal_tags import hermes_client_tag, nous_portal_tags + + tags = nous_portal_tags() + assert "product=hermes-agent" in tags + assert hermes_client_tag() in tags + assert len(tags) == 2 + + +def test_nous_portal_tags_returns_fresh_list(): + """Callers mutate the returned list; we must not share state across calls.""" + from agent.portal_tags import nous_portal_tags + + a = nous_portal_tags() + a.append("client=test-mutation") + b = nous_portal_tags() + assert "client=test-mutation" not in b + + +def test_auxiliary_client_nous_extra_body_uses_helper(): + """auxiliary_client.NOUS_EXTRA_BODY must match the canonical helper output.""" + from agent.auxiliary_client import NOUS_EXTRA_BODY + from agent.portal_tags import nous_portal_tags + + assert NOUS_EXTRA_BODY == {"tags": nous_portal_tags()} + + +def test_nous_provider_profile_uses_helper(): + """The Nous provider profile (main agent loop) must use the canonical tags.""" + from agent.portal_tags import nous_portal_tags + from providers import get_provider_profile + + profile = get_provider_profile("nous") + assert profile is not None + body = profile.build_extra_body() + assert body["tags"] == nous_portal_tags() diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 47d402a215b..7ed0d4da634 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -147,11 +147,12 @@ class TestChatCompletionsBuildKwargs: ] def test_nous_tags(self, transport): + from agent.portal_tags import nous_portal_tags from providers import get_provider_profile profile = get_provider_profile("nous") msgs = [{"role": "user", "content": "Hi"}] kw = transport.build_kwargs(model="gpt-4o", messages=msgs, provider_profile=profile) - assert kw["extra_body"]["tags"] == ["product=hermes-agent"] + assert kw["extra_body"]["tags"] == nous_portal_tags() def test_reasoning_default(self, transport): msgs = [{"role": "user", "content": "Hi"}] diff --git a/tests/providers/test_profile_wiring.py b/tests/providers/test_profile_wiring.py index 9096c82b6a3..258ff531806 100644 --- a/tests/providers/test_profile_wiring.py +++ b/tests/providers/test_profile_wiring.py @@ -273,12 +273,13 @@ class TestRequestOverridesParity: def test_extra_body_override_merges_with_provider_body(self, transport): """Override extra_body merges WITH provider extra_body, not replaces.""" + from agent.portal_tags import nous_portal_tags kw = transport.build_kwargs( model="hermes-3", messages=_msgs(), tools=None, provider_profile=get_provider_profile("nous"), request_overrides={"extra_body": {"custom": True}}, ) - assert kw["extra_body"]["tags"] == ["product=hermes-agent"] # from profile + assert kw["extra_body"]["tags"] == nous_portal_tags() # from profile assert kw["extra_body"]["custom"] is True # from override def test_top_level_override(self, transport): diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 68f7b5f4970..c79ed2aea9b 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -210,9 +210,10 @@ class TestOpenRouterProfile: class TestNousProfile: def test_tags(self): + from agent.portal_tags import nous_portal_tags p = get_provider_profile("nous") body = p.build_extra_body() - assert body["tags"] == ["product=hermes-agent"] + assert body["tags"] == nous_portal_tags() def test_auth_type(self): p = get_provider_profile("nous") diff --git a/tests/providers/test_transport_parity.py b/tests/providers/test_transport_parity.py index be88bc580a1..8c1fb6eb4f1 100644 --- a/tests/providers/test_transport_parity.py +++ b/tests/providers/test_transport_parity.py @@ -165,13 +165,14 @@ class TestNousParity: """Nous: product tags, reasoning, omit when disabled.""" def test_tags(self, transport): + from agent.portal_tags import nous_portal_tags kw = transport.build_kwargs( model="hermes-3-llama-3.1-405b", messages=_simple_messages(), tools=None, provider_profile=get_provider_profile("nous"), ) - assert kw["extra_body"]["tags"] == ["product=hermes-agent"] + assert kw["extra_body"]["tags"] == nous_portal_tags() def test_reasoning_omitted_when_disabled(self, transport): """Nous special case: reasoning omitted entirely when disabled.""" diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index f97885a0382..d3a5a1b37fa 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -343,11 +343,12 @@ class TestBuildApiKwargsAIGateway: class TestBuildApiKwargsNousPortal: def test_includes_nous_product_tags(self, monkeypatch): + from agent.portal_tags import nous_portal_tags agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") messages = [{"role": "user", "content": "hi"}] kwargs = agent._build_api_kwargs(messages) extra = kwargs.get("extra_body", {}) - assert extra.get("tags") == ["product=hermes-agent"] + assert extra.get("tags") == nous_portal_tags() def test_uses_chat_completions_format(self, monkeypatch): agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") diff --git a/tools/web_tools.py b/tools/web_tools.py index b9df0cd3be1..79ddc8d27f2 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -593,7 +593,8 @@ def _resolve_web_extract_auxiliary(model: Optional[str] = None) -> tuple[Optiona extra_body: Dict[str, Any] = {} if client is not None and _is_nous_auxiliary_client(client): from agent.auxiliary_client import get_auxiliary_extra_body - extra_body = get_auxiliary_extra_body() or {"tags": ["product=hermes-agent"]} + from agent.portal_tags import nous_portal_tags + extra_body = get_auxiliary_extra_body() or {"tags": nous_portal_tags()} return client, effective_model, extra_body