diff --git a/plugins/memory/openviking/README.md b/plugins/memory/openviking/README.md index 5ba0393a08..3627bcd730 100644 --- a/plugins/memory/openviking/README.md +++ b/plugins/memory/openviking/README.md @@ -78,6 +78,9 @@ profile's `.env`: When `OPENVIKING_API_KEY` is set, Hermes lets OpenViking derive account/user identity from the key. In local or trusted deployments without an API key, Hermes sends `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` as identity headers. +Hermes also sends `User-Agent: openviking-memory-hermes/` on +OpenViking requests. This standard harness identifier contains the Hermes +version, but no per-user identifier, and does not add a separate request. ## Tools diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index f46052bae3..996928d32b 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -52,6 +52,7 @@ from urllib.request import url2pathname from agent.message_content import flatten_message_text from agent.memory_provider import MemoryProvider from agent.skill_commands import extract_user_instruction_from_skill_message +from hermes_cli import __version__ as _HERMES_VERSION from tools.registry import tool_error from utils import atomic_json_write, env_var_enabled @@ -65,6 +66,7 @@ logger = logging.getLogger(__name__) _DEFAULT_ENDPOINT = "http://127.0.0.1:1933" _OPENVIKING_SERVICE_ENDPOINT = "https://api.vikingdb.cn-beijing.volces.com/openviking" _DEFAULT_AGENT = "hermes" +_OPENVIKING_USER_AGENT = f"openviking-memory-hermes/{_HERMES_VERSION}" _AGENT_PROMPT_LABEL = "Hermes peer ID in OpenViking" _OVCLI_CONFIG_ENV = "OPENVIKING_CLI_CONFIG_FILE" _OVCLI_DEFAULT_RELATIVE_PATH = ".openviking/ovcli.conf" @@ -340,7 +342,10 @@ class _VikingClient: if include_tenant is None: include_tenant = not bool(self._api_key) - h = {"Content-Type": "application/json"} + h = { + "Content-Type": "application/json", + "User-Agent": _OPENVIKING_USER_AGENT, + } if self._agent: h["X-OpenViking-Actor-Peer"] = self._agent if include_tenant: @@ -481,7 +486,12 @@ class _VikingClient: def _anonymous_json(self, path: str) -> dict: """Probe server identity without disclosing credentials or tenant IDs.""" resp = self._httpx.get( - self._url(path), headers={"Accept": "application/json"}, timeout=3.0 + self._url(path), + headers={ + "Accept": "application/json", + "User-Agent": _OPENVIKING_USER_AGENT, + }, + timeout=3.0, ) return self._parse_response(resp) diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py index c7cef68122..30fdfcf16f 100644 --- a/tests/openviking_plugin/test_openviking.py +++ b/tests/openviking_plugin/test_openviking.py @@ -9,6 +9,7 @@ from typing import Any, cast from urllib.parse import parse_qs, urlparse import plugins.memory.openviking as openviking_plugin +from hermes_cli import __version__ as _HERMES_VERSION from plugins.memory.openviking import OpenVikingMemoryProvider @@ -781,6 +782,10 @@ class TestOpenVikingAutoRecallPrefetch: for headers in records["headers"] ] assert all(headers.get("x-openviking-actor-peer") == "hermes" for headers in normalized_headers) + assert all( + headers.get("user-agent") == f"openviking-memory-hermes/{_HERMES_VERSION}" + for headers in normalized_headers + ) assert all(headers.get("x-openviking-account") == "acct" for headers in normalized_headers) assert all(headers.get("x-openviking-user") == "user" for headers in normalized_headers) diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 909e659788..3c6d03143e 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -11,12 +11,15 @@ from unittest.mock import MagicMock import pytest import plugins.memory.openviking as openviking_module +from hermes_cli import __version__ as _HERMES_VERSION from plugins.memory.openviking import ( OpenVikingMemoryProvider, _DEFERRED_COMMIT_TIMEOUT, _VikingClient, ) +_EXPECTED_USER_AGENT = f"openviking-memory-hermes/{_HERMES_VERSION}" + def _clear_openviking_tenant_env(monkeypatch): for name in ("OPENVIKING_ACCOUNT", "OPENVIKING_USER", "OPENVIKING_AGENT"): @@ -767,6 +770,40 @@ def test_viking_client_delete_uses_identity_headers(monkeypatch): assert captured["kwargs"]["params"] == {"uri": "viking://~/memories/x.md"} assert captured["kwargs"]["headers"]["Authorization"] == "Bearer test-key" assert captured["kwargs"]["headers"]["X-OpenViking-Actor-Peer"] == "hermes" + assert captured["kwargs"]["headers"]["User-Agent"] == _EXPECTED_USER_AGENT + + +def test_viking_client_upload_uses_user_agent_without_json_content_type( + tmp_path, + monkeypatch, +): + client = _VikingClient( + "https://example.com", + api_key="test-key", + account="acct", + user="alice", + agent="hermes", + ) + upload = tmp_path / "notes.txt" + upload.write_text("notes", encoding="utf-8") + captured = {} + + def capture_post(url, **kwargs): + captured["url"] = url + captured["kwargs"] = kwargs + return SimpleNamespace( + status_code=200, + text="", + json=lambda: {"result": {"temp_file_id": "temp-1"}}, + ) + + monkeypatch.setattr(client._httpx, "post", capture_post) + + assert client.upload_temp_file(upload) == "temp-1" + assert captured["url"] == "https://example.com/api/v1/resources/temp_upload" + headers = captured["kwargs"]["headers"] + assert headers["User-Agent"] == _EXPECTED_USER_AGENT + assert "Content-Type" not in headers def test_openviking_identity_probes_are_anonymous_before_authenticated_requests(monkeypatch): @@ -808,14 +845,20 @@ def test_openviking_identity_probes_are_anonymous_before_authenticated_requests( "/api/v1/system/status", "/api/v1/admin/accounts", ] - assert calls[0][1] == {"Accept": "application/json"} - assert calls[1][1] == {"Accept": "application/json"} + expected_anonymous_headers = { + "Accept": "application/json", + "User-Agent": _EXPECTED_USER_AGENT, + } + assert calls[0][1] == expected_anonymous_headers + assert calls[1][1] == expected_anonymous_headers for _url, headers in calls[2:]: assert headers["X-API-Key"] == "secret-key" assert headers["Authorization"] == "Bearer secret-key" -def test_repeated_openviking_health_probes_never_send_identity_headers(monkeypatch): +def test_repeated_openviking_health_probes_never_send_credentials_or_tenant_headers( + monkeypatch, +): captured_headers = [] client = _VikingClient( "https://openviking.example", @@ -838,8 +881,14 @@ def test_repeated_openviking_health_probes_never_send_identity_headers(monkeypat assert client.health() is True assert client.health() is True assert captured_headers == [ - {"Accept": "application/json"}, - {"Accept": "application/json"}, + { + "Accept": "application/json", + "User-Agent": _EXPECTED_USER_AGENT, + }, + { + "Accept": "application/json", + "User-Agent": _EXPECTED_USER_AGENT, + }, ] @@ -874,7 +923,10 @@ def test_cloud_health_retries_with_api_key_after_anonymous_auth_error(monkeypatc payload = client.health_payload() assert payload == modern assert client.health() is True - assert calls[0] == {"Accept": "application/json"} + assert calls[0] == { + "Accept": "application/json", + "User-Agent": _EXPECTED_USER_AGENT, + } assert "Authorization" in calls[1] assert calls[1]["Authorization"].startswith("Bearer account.user.") assert "X-API-Key" in calls[1] @@ -908,7 +960,12 @@ def test_cloud_health_does_not_send_key_without_api_key(monkeypatch): with pytest.raises(openviking_module._OpenVikingHTTPError): client.health_payload() - assert calls == [{"Accept": "application/json"}] + assert calls == [ + { + "Accept": "application/json", + "User-Agent": _EXPECTED_USER_AGENT, + } + ] def test_health_non_auth_errors_do_not_retry_with_credentials(monkeypatch): @@ -931,7 +988,12 @@ def test_health_non_auth_errors_do_not_retry_with_credentials(monkeypatch): with pytest.raises(openviking_module._OpenVikingHTTPError): client.health_payload() - assert calls == [{"Accept": "application/json"}] + assert calls == [ + { + "Accept": "application/json", + "User-Agent": _EXPECTED_USER_AGENT, + } + ] def test_modern_openviking_identity_does_not_probe_openapi(): diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 099d73b3eb..61b873a9f2 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -330,6 +330,9 @@ live in `ovcli.conf` (`OPENVIKING_CLI_CONFIG_FILE` or `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` are used for local/trusted mode. `OPENVIKING_AGENT` is Hermes' peer ID in OpenViking for peer-scoped memories. +Hermes sends `User-Agent: openviking-memory-hermes/` on OpenViking +requests. This standard harness identifier contains no per-user identifier and +does not add a separate request. ---