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