feat(nous): unified client=hermes-client-v<version> tag on every Portal request (#24779)
Some checks failed
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Docker Build and Publish / move-latest (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / e2e (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled

* feat(nous): unified client=hermes-client-v<version> 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).
This commit is contained in:
Teknium 2026-05-12 20:49:20 -07:00 committed by GitHub
parent b06e999302
commit 486b692ddd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 169 additions and 13 deletions

View file

@ -382,7 +382,28 @@ _AI_GATEWAY_HEADERS = {
# Nous Portal extra_body for product attribution. # Nous Portal extra_body for product attribution.
# Callers should pass this as extra_body in chat.completions.create() # Callers should pass this as extra_body in chat.completions.create()
# when the auxiliary client is backed by Nous Portal. # 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 # Set at resolve time — True if the auxiliary client points to Nous Portal
auxiliary_is_nous: bool = False 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 Includes Nous Portal product tags when the auxiliary client is backed
by Nous Portal. Returns empty dict otherwise. 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: def auxiliary_max_tokens_param(value: int) -> dict:
@ -4026,7 +4047,7 @@ def _build_call_kwargs(
# Provider-specific extra_body # Provider-specific extra_body
merged_extra = dict(extra_body or {}) merged_extra = dict(extra_body or {})
if provider == "nous" or auxiliary_is_nous: 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: if merged_extra:
kwargs["extra_body"] = merged_extra kwargs["extra_body"] = merged_extra

64
agent/portal_tags.py Normal file
View file

@ -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<MAJOR>.<MINOR>.<PATCH>``.
"""
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()]

View file

@ -307,7 +307,7 @@ def judge_goal(
return "continue", "empty response (nothing to evaluate)", False return "continue", "empty response (nothing to evaluate)", False
try: 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: except Exception as exc:
logger.debug("goal judge: auxiliary client import failed: %s", exc) logger.debug("goal judge: auxiliary client import failed: %s", exc)
return "continue", "auxiliary client unavailable", False return "continue", "auxiliary client unavailable", False
@ -336,6 +336,7 @@ def judge_goal(
temperature=0, temperature=0,
max_tokens=200, max_tokens=200,
timeout=timeout, timeout=timeout,
extra_body=get_auxiliary_extra_body() or None,
) )
except Exception as exc: except Exception as exc:
logger.info("goal judge: API call failed (%s) — falling through to continue", exc) logger.info("goal judge: API call failed (%s) — falling through to continue", exc)

View file

@ -155,7 +155,7 @@ def specify_task(
) )
try: 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 except Exception as exc: # pragma: no cover — import smoke test
logger.debug("specify: auxiliary client import failed: %s", exc) logger.debug("specify: auxiliary client import failed: %s", exc)
return SpecifyOutcome(task_id, False, "auxiliary client unavailable") return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
@ -187,6 +187,7 @@ def specify_task(
temperature=0.3, temperature=0.3,
max_tokens=1500, max_tokens=1500,
timeout=timeout or 120, timeout=timeout or 120,
extra_body=get_auxiliary_extra_body() or None,
) )
except Exception as exc: except Exception as exc:
logger.info( logger.info(

View file

@ -2,6 +2,7 @@
from typing import Any from typing import Any
from agent.portal_tags import nous_portal_tags
from providers import register_provider from providers import register_provider
from providers.base import ProviderProfile from providers.base import ProviderProfile
@ -12,7 +13,7 @@ class NousProfile(ProviderProfile):
def build_extra_body( def build_extra_body(
self, *, session_id: str | None = None, **context self, *, session_id: str | None = None, **context
) -> dict[str, Any]: ) -> dict[str, Any]:
return {"tags": ["product=hermes-agent"]} return {"tags": nous_portal_tags()}
def build_api_kwargs_extras( def build_api_kwargs_extras(
self, self,

View file

@ -11542,7 +11542,8 @@ class AIAgent:
"effort": "medium" "effort": "medium"
} }
if _is_nous: 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": if self.api_mode == "codex_responses":
codex_kwargs = self._build_api_kwargs(api_messages) codex_kwargs = self._build_api_kwargs(api_messages)

View file

@ -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()

View file

@ -147,11 +147,12 @@ class TestChatCompletionsBuildKwargs:
] ]
def test_nous_tags(self, transport): def test_nous_tags(self, transport):
from agent.portal_tags import nous_portal_tags
from providers import get_provider_profile from providers import get_provider_profile
profile = get_provider_profile("nous") profile = get_provider_profile("nous")
msgs = [{"role": "user", "content": "Hi"}] msgs = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(model="gpt-4o", messages=msgs, provider_profile=profile) 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): def test_reasoning_default(self, transport):
msgs = [{"role": "user", "content": "Hi"}] msgs = [{"role": "user", "content": "Hi"}]

View file

@ -273,12 +273,13 @@ class TestRequestOverridesParity:
def test_extra_body_override_merges_with_provider_body(self, transport): def test_extra_body_override_merges_with_provider_body(self, transport):
"""Override extra_body merges WITH provider extra_body, not replaces.""" """Override extra_body merges WITH provider extra_body, not replaces."""
from agent.portal_tags import nous_portal_tags
kw = transport.build_kwargs( kw = transport.build_kwargs(
model="hermes-3", messages=_msgs(), tools=None, model="hermes-3", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("nous"), provider_profile=get_provider_profile("nous"),
request_overrides={"extra_body": {"custom": True}}, 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 assert kw["extra_body"]["custom"] is True # from override
def test_top_level_override(self, transport): def test_top_level_override(self, transport):

View file

@ -210,9 +210,10 @@ class TestOpenRouterProfile:
class TestNousProfile: class TestNousProfile:
def test_tags(self): def test_tags(self):
from agent.portal_tags import nous_portal_tags
p = get_provider_profile("nous") p = get_provider_profile("nous")
body = p.build_extra_body() body = p.build_extra_body()
assert body["tags"] == ["product=hermes-agent"] assert body["tags"] == nous_portal_tags()
def test_auth_type(self): def test_auth_type(self):
p = get_provider_profile("nous") p = get_provider_profile("nous")

View file

@ -165,13 +165,14 @@ class TestNousParity:
"""Nous: product tags, reasoning, omit when disabled.""" """Nous: product tags, reasoning, omit when disabled."""
def test_tags(self, transport): def test_tags(self, transport):
from agent.portal_tags import nous_portal_tags
kw = transport.build_kwargs( kw = transport.build_kwargs(
model="hermes-3-llama-3.1-405b", model="hermes-3-llama-3.1-405b",
messages=_simple_messages(), messages=_simple_messages(),
tools=None, tools=None,
provider_profile=get_provider_profile("nous"), 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): def test_reasoning_omitted_when_disabled(self, transport):
"""Nous special case: reasoning omitted entirely when disabled.""" """Nous special case: reasoning omitted entirely when disabled."""

View file

@ -343,11 +343,12 @@ class TestBuildApiKwargsAIGateway:
class TestBuildApiKwargsNousPortal: class TestBuildApiKwargsNousPortal:
def test_includes_nous_product_tags(self, monkeypatch): 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") agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
messages = [{"role": "user", "content": "hi"}] messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages) kwargs = agent._build_api_kwargs(messages)
extra = kwargs.get("extra_body", {}) 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): def test_uses_chat_completions_format(self, monkeypatch):
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")

View file

@ -593,7 +593,8 @@ def _resolve_web_extract_auxiliary(model: Optional[str] = None) -> tuple[Optiona
extra_body: Dict[str, Any] = {} extra_body: Dict[str, Any] = {}
if client is not None and _is_nous_auxiliary_client(client): if client is not None and _is_nous_auxiliary_client(client):
from agent.auxiliary_client import get_auxiliary_extra_body 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 return client, effective_model, extra_body