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:
@@ -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"
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user