mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 10:47:12 +08:00
Enable OpenRouter's response caching feature (beta) via X-OpenRouter-Cache
headers. When enabled, identical API requests return cached responses for
free (zero billing), reducing both latency and cost.
Configuration via config.yaml:
openrouter:
response_cache: true # default: on
response_cache_ttl: 300 # 1-86400 seconds
Changes:
- Add openrouter config section to DEFAULT_CONFIG (response_cache + TTL)
- Add build_or_headers() in auxiliary_client.py that builds attribution
headers plus optional cache headers based on config
- Replace inline _OR_HEADERS dicts with build_or_headers() at all 5 sites:
run_agent.py __init__, _apply_client_headers_for_base_url(), and
auxiliary_client.py _try_openrouter() + _to_async_client()
- Add _check_openrouter_cache_status() method to AIAgent that reads
X-OpenRouter-Cache-Status from streaming response headers and logs
HIT/MISS status
- Document in cli-config.yaml.example
- Add 28 tests (22 unit + 6 integration)
Ref: https://openrouter.ai/docs/guides/features/response-caching
285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""Tests for OpenRouter response caching header injection."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_or_headers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBuildOrHeaders:
|
|
"""Test the build_or_headers() helper in agent/auxiliary_client.py."""
|
|
|
|
def test_base_attribution_always_present(self):
|
|
"""Attribution headers must always be included regardless of cache setting."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": False})
|
|
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
|
assert headers["X-OpenRouter-Title"] == "Hermes Agent"
|
|
assert headers["X-OpenRouter-Categories"] == "productivity,cli-agent"
|
|
|
|
def test_cache_enabled(self):
|
|
"""When response_cache is True, X-OpenRouter-Cache header is set."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True})
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
|
|
def test_cache_disabled(self):
|
|
"""When response_cache is False, no cache header is sent."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": False})
|
|
assert "X-OpenRouter-Cache" not in headers
|
|
assert "X-OpenRouter-Cache-TTL" not in headers
|
|
|
|
def test_cache_disabled_by_default_empty_config(self):
|
|
"""Empty config dict means no cache headers (response_cache defaults to False)."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={})
|
|
assert "X-OpenRouter-Cache" not in headers
|
|
|
|
def test_ttl_default(self):
|
|
"""Default TTL (300) is included when cache is enabled."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 300})
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "300"
|
|
|
|
def test_ttl_custom(self):
|
|
"""Custom TTL values within range are sent."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 3600})
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "3600"
|
|
|
|
def test_ttl_max(self):
|
|
"""Maximum TTL (86400) is accepted."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 86400})
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "86400"
|
|
|
|
def test_ttl_out_of_range_too_high(self):
|
|
"""TTL above 86400 is silently ignored (no TTL header sent)."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 100000})
|
|
assert "X-OpenRouter-Cache-TTL" not in headers
|
|
# But cache is still enabled
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
|
|
def test_ttl_out_of_range_zero(self):
|
|
"""TTL of 0 is below minimum — no TTL header sent."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 0})
|
|
assert "X-OpenRouter-Cache-TTL" not in headers
|
|
|
|
def test_ttl_negative(self):
|
|
"""Negative TTL is ignored."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": -5})
|
|
assert "X-OpenRouter-Cache-TTL" not in headers
|
|
|
|
def test_ttl_not_a_number(self):
|
|
"""Non-numeric TTL is ignored."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": "five"})
|
|
assert "X-OpenRouter-Cache-TTL" not in headers
|
|
|
|
def test_ttl_float_truncated(self):
|
|
"""Float TTL values are truncated to int."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 600.7})
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "600"
|
|
|
|
def test_returns_fresh_dict(self):
|
|
"""Each call returns a new dict so mutations don't leak."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
cfg = {"response_cache": True}
|
|
h1 = build_or_headers(or_config=cfg)
|
|
h2 = build_or_headers(or_config=cfg)
|
|
assert h1 is not h2
|
|
assert h1 == h2
|
|
|
|
def test_none_config_falls_back_to_load_config(self):
|
|
"""When or_config is None, build_or_headers reads from load_config()."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
fake_cfg = {
|
|
"openrouter": {"response_cache": True, "response_cache_ttl": 900},
|
|
}
|
|
with patch("hermes_cli.config.load_config", return_value=fake_cfg):
|
|
headers = build_or_headers(or_config=None)
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "900"
|
|
|
|
def test_none_config_load_config_fails_gracefully(self):
|
|
"""When load_config() fails, build_or_headers still returns base headers."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
|
|
headers = build_or_headers(or_config=None)
|
|
# Should have base attribution but no cache headers
|
|
assert "HTTP-Referer" in headers
|
|
assert "X-OpenRouter-Cache" not in headers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Environment variable overrides
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEnvVarOverrides:
|
|
"""Test env var precedence over config.yaml for response caching."""
|
|
|
|
def test_env_enables_cache(self, monkeypatch):
|
|
"""HERMES_OPENROUTER_CACHE=true enables cache even when config disables it."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true")
|
|
headers = build_or_headers(or_config={"response_cache": False})
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
|
|
def test_env_disables_cache(self, monkeypatch):
|
|
"""HERMES_OPENROUTER_CACHE=false disables cache even when config enables it."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "false")
|
|
headers = build_or_headers(or_config={"response_cache": True})
|
|
assert "X-OpenRouter-Cache" not in headers
|
|
|
|
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "Yes", "on"])
|
|
def test_truthy_values(self, monkeypatch, value):
|
|
"""Various truthy strings enable caching."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value)
|
|
headers = build_or_headers(or_config={})
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
|
|
@pytest.mark.parametrize("value", ["0", "false", "no", "off", "maybe", ""])
|
|
def test_non_truthy_values(self, monkeypatch, value):
|
|
"""Non-truthy strings do not enable caching (empty falls through to config)."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value)
|
|
# Empty string falls through to config; others are explicitly non-truthy
|
|
if value == "":
|
|
# Empty env var falls through to config default (False)
|
|
headers = build_or_headers(or_config={"response_cache": False})
|
|
else:
|
|
headers = build_or_headers(or_config={"response_cache": True})
|
|
assert "X-OpenRouter-Cache" not in headers
|
|
|
|
def test_env_ttl_overrides_config(self, monkeypatch):
|
|
"""HERMES_OPENROUTER_CACHE_TTL overrides config TTL."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true")
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", "1800")
|
|
headers = build_or_headers(or_config={"response_cache_ttl": 300})
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "1800"
|
|
|
|
@pytest.mark.parametrize("ttl", ["0", "86401", "abc", "-1", "12.5"])
|
|
def test_invalid_env_ttl_dropped(self, monkeypatch, ttl):
|
|
"""Invalid TTL env values are ignored; cache still enabled without TTL."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "1")
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl)
|
|
headers = build_or_headers(or_config={})
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
assert "X-OpenRouter-Cache-TTL" not in headers
|
|
|
|
@pytest.mark.parametrize("ttl", ["1", "300", "86400"])
|
|
def test_valid_env_ttl_boundaries(self, monkeypatch, ttl):
|
|
"""Boundary TTL values (1, 300, 86400) are accepted."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "yes")
|
|
monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl)
|
|
assert build_or_headers(or_config={})["X-OpenRouter-Cache-TTL"] == ttl
|
|
|
|
def test_no_env_vars_falls_through_to_config(self, monkeypatch):
|
|
"""Without env vars, config.yaml controls behavior."""
|
|
from agent.auxiliary_client import build_or_headers
|
|
|
|
monkeypatch.delenv("HERMES_OPENROUTER_CACHE", raising=False)
|
|
monkeypatch.delenv("HERMES_OPENROUTER_CACHE_TTL", raising=False)
|
|
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 600})
|
|
assert headers["X-OpenRouter-Cache"] == "true"
|
|
assert headers["X-OpenRouter-Cache-TTL"] == "600"
|
|
|
|
class TestDefaultConfig:
|
|
"""Verify the openrouter config section is in DEFAULT_CONFIG."""
|
|
|
|
def test_openrouter_section_exists(self):
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
assert "openrouter" in DEFAULT_CONFIG
|
|
or_cfg = DEFAULT_CONFIG["openrouter"]
|
|
assert or_cfg["response_cache"] is True
|
|
assert or_cfg["response_cache_ttl"] == 300
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _check_openrouter_cache_status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCheckOpenrouterCacheStatus:
|
|
"""Test the _check_openrouter_cache_status method on AIAgent."""
|
|
|
|
def _make_agent(self):
|
|
"""Create a minimal AIAgent-like object with just the method under test."""
|
|
from run_agent import AIAgent
|
|
|
|
# Use object.__new__ to skip __init__, then set the attributes we need
|
|
agent = object.__new__(AIAgent)
|
|
agent._or_cache_hits = 0
|
|
return agent
|
|
|
|
def test_hit_increments_counter(self):
|
|
agent = self._make_agent()
|
|
resp = SimpleNamespace(headers={"x-openrouter-cache-status": "HIT"})
|
|
agent._check_openrouter_cache_status(resp)
|
|
assert agent._or_cache_hits == 1
|
|
# Second hit increments
|
|
agent._check_openrouter_cache_status(resp)
|
|
assert agent._or_cache_hits == 2
|
|
|
|
def test_miss_does_not_increment(self):
|
|
agent = self._make_agent()
|
|
resp = SimpleNamespace(headers={"x-openrouter-cache-status": "MISS"})
|
|
agent._check_openrouter_cache_status(resp)
|
|
assert getattr(agent, "_or_cache_hits", 0) == 0
|
|
|
|
def test_no_header_is_noop(self):
|
|
agent = self._make_agent()
|
|
resp = SimpleNamespace(headers={})
|
|
agent._check_openrouter_cache_status(resp)
|
|
assert getattr(agent, "_or_cache_hits", 0) == 0
|
|
|
|
def test_none_response_is_safe(self):
|
|
agent = self._make_agent()
|
|
agent._check_openrouter_cache_status(None) # no crash
|
|
|
|
def test_no_headers_attr_is_safe(self):
|
|
agent = self._make_agent()
|
|
agent._check_openrouter_cache_status(object()) # no crash
|
|
|
|
def test_case_insensitive(self):
|
|
agent = self._make_agent()
|
|
resp = SimpleNamespace(headers={"x-openrouter-cache-status": "hit"})
|
|
agent._check_openrouter_cache_status(resp)
|
|
assert agent._or_cache_hits == 1
|