diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index a14f7a3..1d5b589 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -219,6 +219,8 @@ class EvoScientistConfig: ui_backend: Literal["cli", "tui", "webui"] = "tui" log_level: str = "warning" reasoning_effort: str = "high" + # Opt into Anthropic prompt caching for OpenRouter anthropic/* models. + openrouter_anthropic_prompt_cache: bool = False # Channel Settings channel_enabled: str = "" # "imessage" | "telegram" | "discord" | "slack" | "wechat" | "dingtalk" | "feishu" | "email" | "qq" | "signal" | "" (comma-separated for multiple) @@ -631,6 +633,9 @@ _ENV_MAPPINGS = { "auxiliary_provider": "EVOSCIENTIST_AUXILIARY_PROVIDER", "auxiliary_model": "EVOSCIENTIST_AUXILIARY_MODEL", "reasoning_effort": "EVOSCIENTIST_REASONING_EFFORT", + "openrouter_anthropic_prompt_cache": ( + "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE" + ), "channel_debug_tracing": "EVOSCIENTIST_CHANNEL_DEBUG_TRACING", "ccproxy_port": "EVOSCIENTIST_CCPROXY_PORT", "use_responses_api": "EVOSCIENTIST_USE_RESPONSES_API", @@ -752,6 +757,10 @@ def apply_config_to_env(config: EvoScientistConfig) -> None: os.environ["TAVILY_API_KEY"] = config.tavily_api_key if config.reasoning_effort and not os.environ.get("EVOSCIENTIST_REASONING_EFFORT"): os.environ["EVOSCIENTIST_REASONING_EFFORT"] = config.reasoning_effort + if config.openrouter_anthropic_prompt_cache and not os.environ.get( + "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE" + ): + os.environ["EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE"] = "true" if config.use_responses_api and not os.environ.get( "EVOSCIENTIST_USE_RESPONSES_API" ): diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index d0b021b..87659f6 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -10,6 +10,7 @@ endpoints) and convenient short names for common models. from __future__ import annotations import os +import warnings from typing import Any from langchain.chat_models import init_chat_model @@ -64,6 +65,8 @@ _ANTHROPIC_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { # Anthropic-routed providers that support extended thinking. _THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"} +_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} + # Model registry: list of (short_name, model_id, provider) # Allows same short_name across different providers. _MODEL_ENTRIES: list[tuple[str, str, str]] = [ @@ -235,6 +238,55 @@ def get_models_for_provider(provider: str) -> list[tuple[str, str]]: return [(name, model_id) for name, model_id, p in _MODEL_ENTRIES if p == provider] +def _env_flag_enabled(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV_VALUES + + +def _supports_openrouter_anthropic_prompt_cache(provider: str, model_id: str) -> bool: + """Return whether EvoScientist should declare OpenRouter Claude caching.""" + return provider == "openrouter" and model_id.startswith( + ("anthropic/", "~anthropic/") + ) + + +def _has_cache_control_override(kwargs: dict[str, Any]) -> bool: + """Return whether the caller already supplied cache-control settings.""" + if "cache_control" in kwargs: + return True + model_kwargs = kwargs.get("model_kwargs") + if model_kwargs is None: + return False + if not isinstance(model_kwargs, dict): + warnings.warn( + "OpenRouter Anthropic prompt caching was not applied because " + "`model_kwargs` is not a dict; pass cache_control explicitly or use " + "a dict-shaped model_kwargs.", + UserWarning, + stacklevel=3, + ) + return True + return "cache_control" in model_kwargs + + +def _apply_openrouter_anthropic_prompt_cache( + provider: str, + model_id: str, + kwargs: dict[str, Any], +) -> None: + """Opt into OpenRouter Claude prompt caching when explicitly requested. + + OpenRouter already handles implicit caching for most providers, but Claude + prompt caching needs Anthropic-style cache-control declaration. + """ + if not _env_flag_enabled("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE"): + return + if not _supports_openrouter_anthropic_prompt_cache(provider, model_id): + return + if _has_cache_control_override(kwargs): + return + kwargs.setdefault("model_kwargs", {})["cache_control"] = {"type": "ephemeral"} + + def _apply_auto_config( provider: str, model_id: str, @@ -458,6 +510,7 @@ def get_chat_model( kwargs["base_url"] = base_url _apply_auto_config(provider, model_id, _is_third_party, kwargs, _original_provider) + _apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs) # User-level override for the OpenAI Responses API vs Chat Completions. # When "false", force Chat Completions and drop reasoning (which triggers diff --git a/tests/test_config.py b/tests/test_config.py index da8a55d..d95b543 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -52,6 +52,7 @@ def temp_config_dir(tmp_path, monkeypatch): "EVOSCIENTIST_MEMORY_WORKERS_ENABLED", "EVOSCIENTIST_AUXILIARY_MODEL", "EVOSCIENTIST_AUXILIARY_PROVIDER", + "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", ]: monkeypatch.delenv(key, raising=False) return config_dir @@ -73,6 +74,7 @@ def clean_env(monkeypatch): "EVOSCIENTIST_MEMORY_WORKERS_ENABLED", "EVOSCIENTIST_AUXILIARY_MODEL", "EVOSCIENTIST_AUXILIARY_PROVIDER", + "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", ]: monkeypatch.delenv(key, raising=False) @@ -98,6 +100,7 @@ class TestEvoScientistConfig: assert config.ui_backend == "tui" assert config.log_level == "warning" assert config.reasoning_effort == "high" + assert config.openrouter_anthropic_prompt_cache is False assert config.memory_profile_enabled is True assert config.memory_observations_enabled is True assert config.memory_observation_writer == MemoryObservationWriter.ALL @@ -541,6 +544,23 @@ class TestPriorityChain: config = get_effective_config() assert config.openai_auth_mode == "oauth" + def test_env_openrouter_anthropic_prompt_cache_override( + self, temp_config_dir, monkeypatch + ): + """Test OpenRouter Anthropic prompt cache flag from env overrides file.""" + save_config(EvoScientistConfig(openrouter_anthropic_prompt_cache=False)) + monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") + + config = get_effective_config() + assert config.openrouter_anthropic_prompt_cache is True + + def test_set_openrouter_anthropic_prompt_cache(self, temp_config_dir, clean_env): + """Test OpenRouter Anthropic prompt cache can be set through config.""" + save_config(EvoScientistConfig()) + + assert set_config_value("openrouter_anthropic_prompt_cache", "true") is True + assert get_config_value("openrouter_anthropic_prompt_cache") is True + # ============================================================================= # Test apply_config_to_env @@ -578,6 +598,19 @@ class TestApplyConfigToEnv: assert os.environ.get("ANTHROPIC_API_KEY") is None assert os.environ.get("OPENAI_API_KEY") is None + assert os.environ.get("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE") is None + + def test_openrouter_anthropic_prompt_cache_applied(self, clean_env, monkeypatch): + """Test OpenRouter Anthropic prompt cache config is applied to env.""" + monkeypatch.delenv( + "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", raising=False + ) + config = EvoScientistConfig(openrouter_anthropic_prompt_cache=True) + apply_config_to_env(config) + + assert os.environ.get("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE") == ( + "true" + ) def test_ollama_base_url_applied(self, clean_env, monkeypatch): """Test that ollama_base_url is applied to OLLAMA_BASE_URL env var.""" diff --git a/tests/test_llm.py b/tests/test_llm.py index 63f7a4a..b8228a5 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -439,6 +439,111 @@ class TestThirdPartyRouting: call_kwargs = mock_init.call_args[1] assert call_kwargs["reasoning"] == {"effort": "medium", "summary": "auto"} + @patch("EvoScientist.llm.models.init_chat_model") + def test_openrouter_anthropic_prompt_cache_disabled_by_default( + self, mock_init, monkeypatch + ): + """OpenRouter Anthropic prompt caching should be opt-in.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.delenv( + "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", raising=False + ) + + get_chat_model("claude-sonnet-4.6", provider="openrouter") + + call_kwargs = mock_init.call_args[1] + assert "cache_control" not in call_kwargs + assert "cache_control" not in call_kwargs.get("model_kwargs", {}) + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openrouter_anthropic_prompt_cache_opt_in(self, mock_init, monkeypatch): + """The opt-in flag should declare caching for OpenRouter Claude models.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") + + get_chat_model("claude-sonnet-4.6", provider="openrouter") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["model_provider"] == "openrouter" + assert call_kwargs["model"] == "anthropic/claude-sonnet-4.6" + assert call_kwargs["model_kwargs"]["cache_control"] == {"type": "ephemeral"} + + @patch("EvoScientist.llm.models.init_chat_model") + def test_prompt_cache_opt_in_skips_non_anthropic_openrouter( + self, mock_init, monkeypatch + ): + """OpenRouter models with implicit caching should be left alone.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") + + get_chat_model("x-ai/grok-4.3", provider="openrouter") + + call_kwargs = mock_init.call_args[1] + assert "cache_control" not in call_kwargs + assert "cache_control" not in call_kwargs.get("model_kwargs", {}) + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openrouter_anthropic_prompt_cache_preserves_top_level_override( + self, mock_init, monkeypatch + ): + """The default should not duplicate a caller's cache_control kwarg.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") + override = {"type": "ephemeral", "ttl": "1h"} + + get_chat_model( + "claude-sonnet-4.6", + provider="openrouter", + cache_control=override, + ) + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["cache_control"] == override + assert "cache_control" not in call_kwargs.get("model_kwargs", {}) + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openrouter_anthropic_prompt_cache_preserves_model_kwargs_override( + self, mock_init, monkeypatch + ): + """The default should not duplicate model_kwargs cache_control.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") + override = {"type": "ephemeral", "ttl": "1h"} + + get_chat_model( + "claude-sonnet-4.6", + provider="openrouter", + model_kwargs={"cache_control": override}, + ) + + call_kwargs = mock_init.call_args[1] + assert "cache_control" not in call_kwargs + assert call_kwargs["model_kwargs"]["cache_control"] == override + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openrouter_anthropic_prompt_cache_warns_on_invalid_model_kwargs( + self, mock_init, monkeypatch + ): + """Invalid model_kwargs shape should warn and skip cache injection.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") + + with pytest.warns(UserWarning, match="model_kwargs` is not a dict"): + get_chat_model( + "claude-sonnet-4.6", + provider="openrouter", + model_kwargs="bad", + ) + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["model_kwargs"] == "bad" + @patch("EvoScientist.llm.models.init_chat_model") def test_custom_routes_through_openai(self, mock_init, monkeypatch): """Custom provider should route through OpenAI with env-configured base_url."""