Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
15 changes: 8 additions & 7 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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"):
Expand Down
78 changes: 78 additions & 0 deletions tests/agent/test_openrouter_response_caching.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions tests/run_agent/test_provider_attribution_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |
Expand Down