/ tests / agent / test_openrouter_response_cache.py
test_openrouter_response_cache.py
  1  """Tests for OpenRouter response caching header injection."""
  2  
  3  from types import SimpleNamespace
  4  from unittest.mock import patch
  5  
  6  import pytest
  7  
  8  
  9  # ---------------------------------------------------------------------------
 10  # build_or_headers
 11  # ---------------------------------------------------------------------------
 12  
 13  class TestBuildOrHeaders:
 14      """Test the build_or_headers() helper in agent/auxiliary_client.py."""
 15  
 16      def test_base_attribution_always_present(self):
 17          """Attribution headers must always be included regardless of cache setting."""
 18          from agent.auxiliary_client import build_or_headers
 19  
 20          headers = build_or_headers(or_config={"response_cache": False})
 21          assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
 22          assert headers["X-OpenRouter-Title"] == "Hermes Agent"
 23          assert headers["X-OpenRouter-Categories"] == "productivity,cli-agent"
 24  
 25      def test_cache_enabled(self):
 26          """When response_cache is True, X-OpenRouter-Cache header is set."""
 27          from agent.auxiliary_client import build_or_headers
 28  
 29          headers = build_or_headers(or_config={"response_cache": True})
 30          assert headers["X-OpenRouter-Cache"] == "true"
 31  
 32      def test_cache_disabled(self):
 33          """When response_cache is False, no cache header is sent."""
 34          from agent.auxiliary_client import build_or_headers
 35  
 36          headers = build_or_headers(or_config={"response_cache": False})
 37          assert "X-OpenRouter-Cache" not in headers
 38          assert "X-OpenRouter-Cache-TTL" not in headers
 39  
 40      def test_cache_disabled_by_default_empty_config(self):
 41          """Empty config dict means no cache headers (response_cache defaults to False)."""
 42          from agent.auxiliary_client import build_or_headers
 43  
 44          headers = build_or_headers(or_config={})
 45          assert "X-OpenRouter-Cache" not in headers
 46  
 47      def test_ttl_default(self):
 48          """Default TTL (300) is included when cache is enabled."""
 49          from agent.auxiliary_client import build_or_headers
 50  
 51          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 300})
 52          assert headers["X-OpenRouter-Cache-TTL"] == "300"
 53  
 54      def test_ttl_custom(self):
 55          """Custom TTL values within range are sent."""
 56          from agent.auxiliary_client import build_or_headers
 57  
 58          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 3600})
 59          assert headers["X-OpenRouter-Cache-TTL"] == "3600"
 60  
 61      def test_ttl_max(self):
 62          """Maximum TTL (86400) is accepted."""
 63          from agent.auxiliary_client import build_or_headers
 64  
 65          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 86400})
 66          assert headers["X-OpenRouter-Cache-TTL"] == "86400"
 67  
 68      def test_ttl_out_of_range_too_high(self):
 69          """TTL above 86400 is silently ignored (no TTL header sent)."""
 70          from agent.auxiliary_client import build_or_headers
 71  
 72          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 100000})
 73          assert "X-OpenRouter-Cache-TTL" not in headers
 74          # But cache is still enabled
 75          assert headers["X-OpenRouter-Cache"] == "true"
 76  
 77      def test_ttl_out_of_range_zero(self):
 78          """TTL of 0 is below minimum — no TTL header sent."""
 79          from agent.auxiliary_client import build_or_headers
 80  
 81          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 0})
 82          assert "X-OpenRouter-Cache-TTL" not in headers
 83  
 84      def test_ttl_negative(self):
 85          """Negative TTL is ignored."""
 86          from agent.auxiliary_client import build_or_headers
 87  
 88          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": -5})
 89          assert "X-OpenRouter-Cache-TTL" not in headers
 90  
 91      def test_ttl_not_a_number(self):
 92          """Non-numeric TTL is ignored."""
 93          from agent.auxiliary_client import build_or_headers
 94  
 95          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": "five"})
 96          assert "X-OpenRouter-Cache-TTL" not in headers
 97  
 98      def test_ttl_float_truncated(self):
 99          """Float TTL values are truncated to int."""
100          from agent.auxiliary_client import build_or_headers
101  
102          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 600.7})
103          assert headers["X-OpenRouter-Cache-TTL"] == "600"
104  
105      def test_returns_fresh_dict(self):
106          """Each call returns a new dict so mutations don't leak."""
107          from agent.auxiliary_client import build_or_headers
108  
109          cfg = {"response_cache": True}
110          h1 = build_or_headers(or_config=cfg)
111          h2 = build_or_headers(or_config=cfg)
112          assert h1 is not h2
113          assert h1 == h2
114  
115      def test_none_config_falls_back_to_load_config(self):
116          """When or_config is None, build_or_headers reads from load_config()."""
117          from agent.auxiliary_client import build_or_headers
118  
119          fake_cfg = {
120              "openrouter": {"response_cache": True, "response_cache_ttl": 900},
121          }
122          with patch("hermes_cli.config.load_config", return_value=fake_cfg):
123              headers = build_or_headers(or_config=None)
124          assert headers["X-OpenRouter-Cache"] == "true"
125          assert headers["X-OpenRouter-Cache-TTL"] == "900"
126  
127      def test_none_config_load_config_fails_gracefully(self):
128          """When load_config() fails, build_or_headers still returns base headers."""
129          from agent.auxiliary_client import build_or_headers
130  
131          with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
132              headers = build_or_headers(or_config=None)
133          # Should have base attribution but no cache headers
134          assert "HTTP-Referer" in headers
135          assert "X-OpenRouter-Cache" not in headers
136  
137  
138  # ---------------------------------------------------------------------------
139  # Environment variable overrides
140  # ---------------------------------------------------------------------------
141  
142  class TestEnvVarOverrides:
143      """Test env var precedence over config.yaml for response caching."""
144  
145      def test_env_enables_cache(self, monkeypatch):
146          """HERMES_OPENROUTER_CACHE=true enables cache even when config disables it."""
147          from agent.auxiliary_client import build_or_headers
148  
149          monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true")
150          headers = build_or_headers(or_config={"response_cache": False})
151          assert headers["X-OpenRouter-Cache"] == "true"
152  
153      def test_env_disables_cache(self, monkeypatch):
154          """HERMES_OPENROUTER_CACHE=false disables cache even when config enables it."""
155          from agent.auxiliary_client import build_or_headers
156  
157          monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "false")
158          headers = build_or_headers(or_config={"response_cache": True})
159          assert "X-OpenRouter-Cache" not in headers
160  
161      @pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "Yes", "on"])
162      def test_truthy_values(self, monkeypatch, value):
163          """Various truthy strings enable caching."""
164          from agent.auxiliary_client import build_or_headers
165  
166          monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value)
167          headers = build_or_headers(or_config={})
168          assert headers["X-OpenRouter-Cache"] == "true"
169  
170      @pytest.mark.parametrize("value", ["0", "false", "no", "off", "maybe", ""])
171      def test_non_truthy_values(self, monkeypatch, value):
172          """Non-truthy strings do not enable caching (empty falls through to config)."""
173          from agent.auxiliary_client import build_or_headers
174  
175          monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value)
176          # Empty string falls through to config; others are explicitly non-truthy
177          if value == "":
178              # Empty env var falls through to config default (False)
179              headers = build_or_headers(or_config={"response_cache": False})
180          else:
181              headers = build_or_headers(or_config={"response_cache": True})
182          assert "X-OpenRouter-Cache" not in headers
183  
184      def test_env_ttl_overrides_config(self, monkeypatch):
185          """HERMES_OPENROUTER_CACHE_TTL overrides config TTL."""
186          from agent.auxiliary_client import build_or_headers
187  
188          monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true")
189          monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", "1800")
190          headers = build_or_headers(or_config={"response_cache_ttl": 300})
191          assert headers["X-OpenRouter-Cache-TTL"] == "1800"
192  
193      @pytest.mark.parametrize("ttl", ["0", "86401", "abc", "-1", "12.5"])
194      def test_invalid_env_ttl_dropped(self, monkeypatch, ttl):
195          """Invalid TTL env values are ignored; cache still enabled without TTL."""
196          from agent.auxiliary_client import build_or_headers
197  
198          monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "1")
199          monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl)
200          headers = build_or_headers(or_config={})
201          assert headers["X-OpenRouter-Cache"] == "true"
202          assert "X-OpenRouter-Cache-TTL" not in headers
203  
204      @pytest.mark.parametrize("ttl", ["1", "300", "86400"])
205      def test_valid_env_ttl_boundaries(self, monkeypatch, ttl):
206          """Boundary TTL values (1, 300, 86400) are accepted."""
207          from agent.auxiliary_client import build_or_headers
208  
209          monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "yes")
210          monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl)
211          assert build_or_headers(or_config={})["X-OpenRouter-Cache-TTL"] == ttl
212  
213      def test_no_env_vars_falls_through_to_config(self, monkeypatch):
214          """Without env vars, config.yaml controls behavior."""
215          from agent.auxiliary_client import build_or_headers
216  
217          monkeypatch.delenv("HERMES_OPENROUTER_CACHE", raising=False)
218          monkeypatch.delenv("HERMES_OPENROUTER_CACHE_TTL", raising=False)
219          headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 600})
220          assert headers["X-OpenRouter-Cache"] == "true"
221          assert headers["X-OpenRouter-Cache-TTL"] == "600"
222  
223  class TestDefaultConfig:
224      """Verify the openrouter config section is in DEFAULT_CONFIG."""
225  
226      def test_openrouter_section_exists(self):
227          from hermes_cli.config import DEFAULT_CONFIG
228  
229          assert "openrouter" in DEFAULT_CONFIG
230          or_cfg = DEFAULT_CONFIG["openrouter"]
231          assert or_cfg["response_cache"] is True
232          assert or_cfg["response_cache_ttl"] == 300
233  
234  
235  # ---------------------------------------------------------------------------
236  # _check_openrouter_cache_status
237  # ---------------------------------------------------------------------------
238  
239  class TestCheckOpenrouterCacheStatus:
240      """Test the _check_openrouter_cache_status method on AIAgent."""
241  
242      def _make_agent(self):
243          """Create a minimal AIAgent-like object with just the method under test."""
244          from run_agent import AIAgent
245  
246          # Use object.__new__ to skip __init__, then set the attributes we need
247          agent = object.__new__(AIAgent)
248          agent._or_cache_hits = 0
249          return agent
250  
251      def test_hit_increments_counter(self):
252          agent = self._make_agent()
253          resp = SimpleNamespace(headers={"x-openrouter-cache-status": "HIT"})
254          agent._check_openrouter_cache_status(resp)
255          assert agent._or_cache_hits == 1
256          # Second hit increments
257          agent._check_openrouter_cache_status(resp)
258          assert agent._or_cache_hits == 2
259  
260      def test_miss_does_not_increment(self):
261          agent = self._make_agent()
262          resp = SimpleNamespace(headers={"x-openrouter-cache-status": "MISS"})
263          agent._check_openrouter_cache_status(resp)
264          assert getattr(agent, "_or_cache_hits", 0) == 0
265  
266      def test_no_header_is_noop(self):
267          agent = self._make_agent()
268          resp = SimpleNamespace(headers={})
269          agent._check_openrouter_cache_status(resp)
270          assert getattr(agent, "_or_cache_hits", 0) == 0
271  
272      def test_none_response_is_safe(self):
273          agent = self._make_agent()
274          agent._check_openrouter_cache_status(None)  # no crash
275  
276      def test_no_headers_attr_is_safe(self):
277          agent = self._make_agent()
278          agent._check_openrouter_cache_status(object())  # no crash
279  
280      def test_case_insensitive(self):
281          agent = self._make_agent()
282          resp = SimpleNamespace(headers={"x-openrouter-cache-status": "hit"})
283          agent._check_openrouter_cache_status(resp)
284          assert agent._or_cache_hits == 1