From 9fdcbbc2797233362c92cbb2240dfc35b227dd3e Mon Sep 17 00:00:00 2001 From: patp Date: Sat, 2 May 2026 13:59:11 -0400 Subject: [PATCH] feat(agent): opt-in OpenRouter response caching via env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up OpenRouter's response caching feature (https://openrouter.ai/announcements/response-caching) through a single helper so it applies uniformly to the main agent loop and every auxiliary task that calls OpenRouter. Two new env vars (off by default): - HERMES_OPENROUTER_CACHE — truthy ("1"/"true"/"yes"/"on") adds X-OpenRouter-Cache: true to OpenRouter requests. Identical request bodies are then served from edge cache at zero token cost. - HERMES_OPENROUTER_CACHE_TTL — integer seconds (1..86400), emitted as X-OpenRouter-Cache-TTL when caching is enabled. Out-of-range or non-integer values are dropped silently and OpenRouter's 5-minute default applies. Implementation introduces openrouter_feature_headers() and openrouter_default_headers() helpers in agent/auxiliary_client.py and updates the four OpenRouter-targeting client construction sites (two in auxiliary_client.py, two in run_agent.py) to use the helper. Drive-by: collapse the duplicated inline OpenRouter header dict at run_agent.py:1424 onto the same helper so future header additions need only one edit. Tests: - tests/agent/test_openrouter_response_caching.py — 24 cases covering truthy/falsy parsing, TTL boundaries (1 / 300 / 86400), invalid TTL handling (0, 86401, "abc", "-1", "12.5"), and the cache-off-but-TTL-set edge case. - tests/run_agent/test_provider_attribution_headers.py — extended to verify the env vars thread through _apply_client_headers_for_base_url on AIAgent and that attribution headers are preserved alongside. Docs: HERMES_OPENROUTER_CACHE and HERMES_OPENROUTER_CACHE_TTL listed in website/docs/reference/environment-variables.md under "LLM Providers". How to test: echo 'HERMES_OPENROUTER_CACHE=true' >> ~/.hermes/.env hermes chat -q "ping" # first call: x-openrouter-cache-status: MISS hermes chat -q "ping" # second: x-openrouter-cache-status: HIT --- agent/auxiliary_client.py | 49 +++++++++++- run_agent.py | 15 ++-- .../agent/test_openrouter_response_caching.py | 78 +++++++++++++++++++ .../test_provider_attribution_headers.py | 45 +++++++++++ .../docs/reference/environment-variables.md | 2 + 5 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 tests/agent/test_openrouter_response_caching.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bed5c8d47081..edac24cad6f3 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -266,6 +266,49 @@ def _fixed_temperature_for_model( "X-OpenRouter-Categories": "productivity,cli-agent", } +# Truthy values for boolean env-var parsing (aligned with hermes_cli conventions). +_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"}) + + +def openrouter_feature_headers() -> Dict[str, str]: + """Build OpenRouter feature headers driven by environment variables. + + Returned headers are merged on top of ``_OR_HEADERS`` at every OpenRouter + client construction site so that opt-in features stay consistent across + the main agent loop and auxiliary tasks. + + Currently supported: + + - ``HERMES_OPENROUTER_CACHE`` — when truthy (``1``/``true``/``yes``/``on``, + case-insensitive), adds ``X-OpenRouter-Cache: true`` so OpenRouter + serves identical request bodies from edge cache at zero token cost. + - ``HERMES_OPENROUTER_CACHE_TTL`` — integer seconds, clamped to OpenRouter's + documented [1, 86400] range, emitted as ``X-OpenRouter-Cache-TTL``. + Ignored when caching is disabled or the value is not a positive int. + + See https://openrouter.ai/announcements/response-caching. + """ + extras: Dict[str, str] = {} + cache_flag = os.environ.get("HERMES_OPENROUTER_CACHE", "").strip().lower() + if cache_flag in _TRUTHY_ENV_VALUES: + extras["X-OpenRouter-Cache"] = "true" + ttl_raw = os.environ.get("HERMES_OPENROUTER_CACHE_TTL", "").strip() + if ttl_raw.isdigit(): + ttl = int(ttl_raw) + if 1 <= ttl <= 86400: + extras["X-OpenRouter-Cache-TTL"] = str(ttl) + return extras + + +def openrouter_default_headers() -> Dict[str, str]: + """Return the full OpenRouter ``default_headers`` dict (attribution + features). + + Use this at every ``OpenAI(...)``/``AsyncOpenAI(...)`` construction site + that targets ``openrouter.ai`` so feature flags (response caching, future + additions) propagate everywhere with one helper call. + """ + return {**_OR_HEADERS, **openrouter_feature_headers()} + # Vercel AI Gateway app attribution headers. HTTP-Referer maps to # referrerUrl and X-Title maps to appName in the gateway's analytics. from hermes_cli import __version__ as _HERMES_VERSION @@ -1158,14 +1201,14 @@ def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Opt base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL logger.debug("Auxiliary client: OpenRouter via pool") return OpenAI(api_key=or_key, base_url=base_url, - default_headers=_OR_HEADERS), _OPENROUTER_MODEL + default_headers=openrouter_default_headers()), _OPENROUTER_MODEL or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY") if not or_key: return None, None logger.debug("Auxiliary client: OpenRouter") return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL, - default_headers=_OR_HEADERS), _OPENROUTER_MODEL + default_headers=openrouter_default_headers()), _OPENROUTER_MODEL def _describe_openrouter_unavailable() -> str: @@ -1911,7 +1954,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): } sync_base_url = str(sync_client.base_url) if base_url_host_matches(sync_base_url, "openrouter.ai"): - async_kwargs["default_headers"] = dict(_OR_HEADERS) + async_kwargs["default_headers"] = openrouter_default_headers() elif base_url_host_matches(sync_base_url, "api.githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers diff --git a/run_agent.py b/run_agent.py index aac067ed4e85..50159c319c3f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1421,11 +1421,9 @@ def __init__( client_kwargs["args"] = self.acp_args effective_base = base_url if base_url_host_matches(effective_base, "openrouter.ai"): - client_kwargs["default_headers"] = { - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-OpenRouter-Title": "Hermes Agent", - "X-OpenRouter-Categories": "productivity,cli-agent", - } + from agent.auxiliary_client import openrouter_default_headers + + client_kwargs["default_headers"] = openrouter_default_headers() elif base_url_host_matches(effective_base, "api.routermint.com"): client_kwargs["default_headers"] = _routermint_headers() elif base_url_host_matches(effective_base, "api.githubcopilot.com"): @@ -6157,10 +6155,13 @@ def _try_refresh_anthropic_client_credentials(self) -> bool: return True def _apply_client_headers_for_base_url(self, base_url: str) -> None: - from agent.auxiliary_client import _AI_GATEWAY_HEADERS, _OR_HEADERS + from agent.auxiliary_client import ( + _AI_GATEWAY_HEADERS, + openrouter_default_headers, + ) if base_url_host_matches(base_url, "openrouter.ai"): - self._client_kwargs["default_headers"] = dict(_OR_HEADERS) + self._client_kwargs["default_headers"] = openrouter_default_headers() elif base_url_host_matches(base_url, "ai-gateway.vercel.sh"): self._client_kwargs["default_headers"] = dict(_AI_GATEWAY_HEADERS) elif base_url_host_matches(base_url, "api.routermint.com"): diff --git a/tests/agent/test_openrouter_response_caching.py b/tests/agent/test_openrouter_response_caching.py new file mode 100644 index 000000000000..6641d6d3456b --- /dev/null +++ b/tests/agent/test_openrouter_response_caching.py @@ -0,0 +1,78 @@ +"""Opt-in OpenRouter response caching headers driven by env vars. + +See https://openrouter.ai/announcements/response-caching. +""" +import os +from unittest.mock import patch + +import pytest + +from agent.auxiliary_client import ( + _OR_HEADERS, + openrouter_default_headers, + openrouter_feature_headers, +) + + +@pytest.fixture(autouse=True) +def _clear_cache_env(monkeypatch): + monkeypatch.delenv("HERMES_OPENROUTER_CACHE", raising=False) + monkeypatch.delenv("HERMES_OPENROUTER_CACHE_TTL", raising=False) + + +def test_feature_headers_empty_by_default(): + assert openrouter_feature_headers() == {} + + +def test_default_headers_match_attribution_when_disabled(): + assert openrouter_default_headers() == dict(_OR_HEADERS) + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "Yes", "on"]) +def test_truthy_env_enables_cache_header(monkeypatch, value): + monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value) + headers = openrouter_default_headers() + assert headers["X-OpenRouter-Cache"] == "true" + # Attribution headers are still present. + assert headers["HTTP-Referer"] == _OR_HEADERS["HTTP-Referer"] + assert headers["X-OpenRouter-Title"] == _OR_HEADERS["X-OpenRouter-Title"] + + +@pytest.mark.parametrize("value", ["", "0", "false", "no", "off", "maybe"]) +def test_non_truthy_env_keeps_cache_header_off(monkeypatch, value): + monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value) + assert "X-OpenRouter-Cache" not in openrouter_default_headers() + + +def test_ttl_env_emits_header_when_cache_enabled(monkeypatch): + monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true") + monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", "3600") + headers = openrouter_default_headers() + assert headers["X-OpenRouter-Cache"] == "true" + assert headers["X-OpenRouter-Cache-TTL"] == "3600" + + +def test_ttl_ignored_when_cache_disabled(monkeypatch): + monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", "3600") + assert openrouter_default_headers() == dict(_OR_HEADERS) + + +@pytest.mark.parametrize("ttl", ["0", "86401", "abc", "-1", "12.5"]) +def test_invalid_ttl_dropped_silently(monkeypatch, ttl): + """OpenRouter accepts 1..86400 sec; out-of-range/non-int values are skipped. + + Cache header still emitted so the user opts into caching with the + default TTL rather than a request hard-fail. + """ + monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "1") + monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl) + headers = openrouter_default_headers() + assert headers["X-OpenRouter-Cache"] == "true" + assert "X-OpenRouter-Cache-TTL" not in headers + + +@pytest.mark.parametrize("ttl", ["1", "300", "86400"]) +def test_ttl_boundary_values_accepted(monkeypatch, ttl): + monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "yes") + monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl) + assert openrouter_default_headers()["X-OpenRouter-Cache-TTL"] == ttl diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index cf9d8bb8fbed..5df4ea05ef3c 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -81,3 +81,48 @@ def test_unknown_base_url_clears_default_headers(mock_openai): agent._apply_client_headers_for_base_url("https://api.example.com/v1") assert "default_headers" not in agent._client_kwargs + + +@patch("run_agent.OpenAI") +def test_openrouter_cache_env_threads_into_default_headers(mock_openai, monkeypatch): + """HERMES_OPENROUTER_CACHE=true emits X-OpenRouter-Cache on the main client.""" + monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true") + monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", "1800") + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1") + + headers = agent._client_kwargs["default_headers"] + assert headers["X-OpenRouter-Cache"] == "true" + assert headers["X-OpenRouter-Cache-TTL"] == "1800" + # Attribution headers preserved. + assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com" + + +@patch("run_agent.OpenAI") +def test_openrouter_cache_env_unset_leaves_attribution_only(mock_openai, monkeypatch): + monkeypatch.delenv("HERMES_OPENROUTER_CACHE", raising=False) + monkeypatch.delenv("HERMES_OPENROUTER_CACHE_TTL", raising=False) + mock_openai.return_value = MagicMock() + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1") + + headers = agent._client_kwargs["default_headers"] + assert "X-OpenRouter-Cache" not in headers + assert "X-OpenRouter-Cache-TTL" not in headers diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index afe2c40d2a91..bb2d454b7571 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -14,6 +14,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config |----------|-------------| | `OPENROUTER_API_KEY` | OpenRouter API key (recommended for flexibility) | | `OPENROUTER_BASE_URL` | Override the OpenRouter-compatible base URL | +| `HERMES_OPENROUTER_CACHE` | Opt-in to OpenRouter [response caching](https://openrouter.ai/announcements/response-caching). Set to `1`/`true`/`yes`/`on` (case-insensitive) to add `X-OpenRouter-Cache: true` to every OpenRouter request — identical request bodies served from edge cache at zero token cost. Default off. | +| `HERMES_OPENROUTER_CACHE_TTL` | Cache retention in seconds (1..86400). Emitted as `X-OpenRouter-Cache-TTL`; ignored when `HERMES_OPENROUTER_CACHE` is unset. Defaults to OpenRouter's 5-minute server-side default when unset. | | `NOUS_BASE_URL` | Override Nous Portal base URL (rarely needed; development/testing only) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference endpoint directly | | `AI_GATEWAY_API_KEY` | Vercel AI Gateway API key ([ai-gateway.vercel.sh](https://ai-gateway.vercel.sh)) |