feat(models): add opt-in prompt caching support for anthropic via openrouter (#272)

* feat(models): add opt-in prompt caching support for anthropic via openrouter

* chore: don't coerce model_kwargs to dict
This commit is contained in:
dinos
2026-06-09 16:13:49 +02:00
committed by GitHub
parent 4b6a969df2
commit cd2baa9588
4 changed files with 200 additions and 0 deletions
+9
View File
@@ -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"
):
+53
View File
@@ -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
+33
View File
@@ -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."""
+105
View File
@@ -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."""