* feat(llm): add OpenRouter app attribution headers (#339) Attach EvoScientist app-attribution at the shared model-init layer so all OpenRouter calls are credited to the project. langchain-openrouter maps app_url/app_title/app_categories -> HTTP-Referer / X-Title / X-OpenRouter-Categories. Applied only for the openrouter provider, via setdefault so explicit caller kwargs win. Configurable through new openrouter_http_referer / openrouter_app_title / openrouter_app_categories settings and their EVOSCIENTIST_OPENROUTER_* env vars. Closes #339 * refactor(llm): centralize OpenRouter attribution defaults + cap categories Address PR #344 review: - Define the app-attribution default constants once in config/settings.py (the config fields and llm/models.py both use them) instead of duplicating the literals across the two modules. - Reduce the default categories to creative-writing,personal-agent and cap the sent list to OpenRouter's 2-per-request limit, warning when a configured list exceeds it, so extras are dropped predictably (and surfaced) here rather than being silently truncated server-side. --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
This commit is contained in:
@@ -77,6 +77,9 @@ def temp_config_dir(tmp_path, monkeypatch):
|
||||
"EVOSCIENTIST_AUXILIARY_MODEL",
|
||||
"EVOSCIENTIST_AUXILIARY_PROVIDER",
|
||||
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
|
||||
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
|
||||
"EVOSCIENTIST_DANGEROUS_MODE",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
@@ -104,6 +107,9 @@ def clean_env(monkeypatch):
|
||||
"EVOSCIENTIST_AUXILIARY_MODEL",
|
||||
"EVOSCIENTIST_AUXILIARY_PROVIDER",
|
||||
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
|
||||
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
|
||||
"EVOSCIENTIST_DANGEROUS_MODE",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
@@ -131,6 +137,11 @@ class TestEvoScientistConfig:
|
||||
assert config.log_level == "warning"
|
||||
assert config.reasoning_effort == "high"
|
||||
assert config.openrouter_anthropic_prompt_cache is True
|
||||
assert config.openrouter_http_referer == (
|
||||
"https://github.com/EvoScientist/EvoScientist"
|
||||
)
|
||||
assert config.openrouter_app_title == "EvoScientist"
|
||||
assert config.openrouter_app_categories == "creative-writing,personal-agent"
|
||||
assert config.memory_profile_enabled is True
|
||||
assert config.memory_observations_enabled is True
|
||||
assert config.memory_observation_writer == MemoryObservationWriter.ALL
|
||||
@@ -672,6 +683,22 @@ class TestPriorityChain:
|
||||
config = get_effective_config()
|
||||
assert config.openrouter_anthropic_prompt_cache is False
|
||||
|
||||
def test_env_openrouter_app_attribution_override(
|
||||
self, temp_config_dir, monkeypatch
|
||||
):
|
||||
"""OpenRouter app-attribution env vars should override file config."""
|
||||
save_config(EvoScientistConfig())
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_HTTP_REFERER", "https://acme.test")
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_TITLE", "Acme")
|
||||
monkeypatch.setenv(
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", "cli-agent,programming-app"
|
||||
)
|
||||
|
||||
config = get_effective_config()
|
||||
assert config.openrouter_http_referer == "https://acme.test"
|
||||
assert config.openrouter_app_title == "Acme"
|
||||
assert config.openrouter_app_categories == "cli-agent,programming-app"
|
||||
|
||||
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())
|
||||
@@ -732,6 +759,60 @@ class TestApplyConfigToEnv:
|
||||
"false"
|
||||
)
|
||||
|
||||
def test_openrouter_app_attribution_applied_to_env(self, clean_env, monkeypatch):
|
||||
"""Config app-attribution values are exported to env for models.py."""
|
||||
for env in (
|
||||
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
|
||||
):
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
config = EvoScientistConfig(
|
||||
openrouter_http_referer="https://acme.test",
|
||||
openrouter_app_title="Acme",
|
||||
openrouter_app_categories="cli-agent,programming-app",
|
||||
)
|
||||
apply_config_to_env(config)
|
||||
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_HTTP_REFERER") == (
|
||||
"https://acme.test"
|
||||
)
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE") == "Acme"
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES") == (
|
||||
"cli-agent,programming-app"
|
||||
)
|
||||
|
||||
def test_openrouter_app_attribution_env_not_overwritten(
|
||||
self, clean_env, monkeypatch
|
||||
):
|
||||
"""apply_config_to_env must not clobber an already-set attribution env var."""
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_TITLE", "existing-title")
|
||||
config = EvoScientistConfig(openrouter_app_title="config-title")
|
||||
apply_config_to_env(config)
|
||||
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE") == "existing-title"
|
||||
|
||||
def test_openrouter_app_attribution_empty_config_not_applied(
|
||||
self, clean_env, monkeypatch
|
||||
):
|
||||
"""Empty-string attribution config must not create env vars."""
|
||||
for env in (
|
||||
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
|
||||
):
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
config = EvoScientistConfig(
|
||||
openrouter_http_referer="",
|
||||
openrouter_app_title="",
|
||||
openrouter_app_categories="",
|
||||
)
|
||||
apply_config_to_env(config)
|
||||
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_HTTP_REFERER") is None
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE") is None
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES") is None
|
||||
|
||||
def test_dangerous_mode_round_trips_to_env(self, clean_env, monkeypatch):
|
||||
"""dangerous_mode set via CLI override must survive a fresh re-read.
|
||||
|
||||
|
||||
@@ -439,6 +439,200 @@ class TestThirdPartyRouting:
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs["reasoning"] == {"effort": "medium", "summary": "auto"}
|
||||
|
||||
# --- OpenRouter app attribution (issue #339) ---
|
||||
|
||||
_APP_ATTR_ENV = (
|
||||
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
|
||||
)
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_app_attribution_defaults(self, mock_init, monkeypatch):
|
||||
"""OpenRouter init should carry EvoScientist's default app attribution."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
# Isolate from any leaked env overrides so we assert the built-in defaults.
|
||||
for _env in self._APP_ATTR_ENV:
|
||||
monkeypatch.delenv(_env, raising=False)
|
||||
|
||||
get_chat_model("x-ai/grok-4.3", provider="openrouter")
|
||||
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs["app_url"] == "https://github.com/EvoScientist/EvoScientist"
|
||||
assert call_kwargs["app_title"] == "EvoScientist"
|
||||
# Must be a list[str] (not the comma string) — langchain-openrouter joins it.
|
||||
assert call_kwargs["app_categories"] == ["creative-writing", "personal-agent"]
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_app_attribution_from_env(self, mock_init, monkeypatch):
|
||||
"""Env vars should override the default app attribution values."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_HTTP_REFERER", "https://acme.test")
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_TITLE", "Acme")
|
||||
# Include a space to prove each category is stripped.
|
||||
monkeypatch.setenv(
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", "cli-agent, programming-app"
|
||||
)
|
||||
|
||||
get_chat_model("x-ai/grok-4.3", provider="openrouter")
|
||||
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs["app_url"] == "https://acme.test"
|
||||
assert call_kwargs["app_title"] == "Acme"
|
||||
assert call_kwargs["app_categories"] == ["cli-agent", "programming-app"]
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_app_attribution_user_override_not_clobbered(
|
||||
self, mock_init, monkeypatch
|
||||
):
|
||||
"""Caller-supplied attribution kwargs must beat both env and defaults."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
# Env is also set, to prove an explicit kwarg outranks the env override
|
||||
# (not just the built-in default).
|
||||
monkeypatch.setenv(
|
||||
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER", "https://env.example"
|
||||
)
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_TITLE", "EnvTitle")
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", "env-cat")
|
||||
|
||||
get_chat_model(
|
||||
"x-ai/grok-4.3",
|
||||
provider="openrouter",
|
||||
app_url="https://mine.example",
|
||||
app_title="MyApp",
|
||||
app_categories=["only-this"],
|
||||
)
|
||||
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs["app_url"] == "https://mine.example"
|
||||
assert call_kwargs["app_title"] == "MyApp"
|
||||
# An explicit list is preserved verbatim, not re-split.
|
||||
assert call_kwargs["app_categories"] == ["only-this"]
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_non_openrouter_providers_get_no_app_attribution(
|
||||
self, mock_init, monkeypatch
|
||||
):
|
||||
"""Only the openrouter provider should receive app-attribution kwargs."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-real")
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
|
||||
for model, provider in (
|
||||
("claude-sonnet-4-6", "anthropic"),
|
||||
("llama3.1:8b", "ollama"),
|
||||
):
|
||||
get_chat_model(model, provider=provider)
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert "app_url" not in call_kwargs
|
||||
assert "app_title" not in call_kwargs
|
||||
assert "app_categories" not in call_kwargs
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_app_attribution_coexists_with_reasoning_and_cache(
|
||||
self, mock_init, monkeypatch
|
||||
):
|
||||
"""Attribution must not disturb reasoning or Anthropic prompt caching."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.delenv("EVOSCIENTIST_REASONING_EFFORT", raising=False)
|
||||
monkeypatch.delenv(
|
||||
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", raising=False
|
||||
)
|
||||
for _env in self._APP_ATTR_ENV:
|
||||
monkeypatch.delenv(_env, raising=False)
|
||||
|
||||
get_chat_model("claude-sonnet-4.6", provider="openrouter")
|
||||
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
# Existing behavior intact.
|
||||
assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"}
|
||||
assert call_kwargs["model_kwargs"]["cache_control"] == {"type": "ephemeral"}
|
||||
# Attribution added alongside.
|
||||
assert call_kwargs["app_url"] == "https://github.com/EvoScientist/EvoScientist"
|
||||
assert call_kwargs["app_title"] == "EvoScientist"
|
||||
assert call_kwargs["app_categories"] == ["creative-writing", "personal-agent"]
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_app_categories_env_strips_blank_items(
|
||||
self, mock_init, monkeypatch
|
||||
):
|
||||
"""A messy comma value (stray commas / spaces) yields a clean list."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", "a,, b ")
|
||||
|
||||
get_chat_model("x-ai/grok-4.3", provider="openrouter")
|
||||
|
||||
assert mock_init.call_args[1]["app_categories"] == ["a", "b"]
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_app_categories_capped_to_per_request_limit(
|
||||
self, mock_init, monkeypatch
|
||||
):
|
||||
"""Over-configuring categories caps to the first N and warns the user."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv(
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
|
||||
"cli-agent,programming-app,personal-agent,writing-assistant",
|
||||
)
|
||||
|
||||
with pytest.warns(UserWarning, match="at most 2 app categories"):
|
||||
get_chat_model("x-ai/grok-4.3", provider="openrouter")
|
||||
|
||||
# OpenRouter honors at most 2 per request, so only the first 2 are sent.
|
||||
assert mock_init.call_args[1]["app_categories"] == [
|
||||
"cli-agent",
|
||||
"programming-app",
|
||||
]
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_app_categories_all_separators_omit_kwarg(
|
||||
self, mock_init, monkeypatch
|
||||
):
|
||||
"""A categories value with no real items omits the kwarg entirely."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", " , , ")
|
||||
|
||||
get_chat_model("x-ai/grok-4.3", provider="openrouter")
|
||||
|
||||
# No app_categories kwarg at all — not an empty list (which the library
|
||||
# would reject / send as an empty header).
|
||||
assert "app_categories" not in mock_init.call_args[1]
|
||||
|
||||
def test_openrouter_app_attribution_lands_on_real_model(self, monkeypatch):
|
||||
"""Build a REAL ChatOpenRouter (no mock) and assert the attribution
|
||||
values land on the instance rather than being silently dumped into
|
||||
model_kwargs.
|
||||
|
||||
The mocked tests above assert on the kwargs handed to init_chat_model,
|
||||
so they cannot catch a param-name typo or a langchain-openrouter version
|
||||
that accepts these only as passthrough model params (which the library
|
||||
does with a warning, not an error). This test is the guard for both.
|
||||
"""
|
||||
from langchain_openrouter import ChatOpenRouter
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
for _env in self._APP_ATTR_ENV:
|
||||
monkeypatch.delenv(_env, raising=False)
|
||||
|
||||
model = get_chat_model("x-ai/grok-4.3", provider="openrouter")
|
||||
|
||||
assert isinstance(model, ChatOpenRouter)
|
||||
assert model.app_url == "https://github.com/EvoScientist/EvoScientist"
|
||||
assert model.app_title == "EvoScientist"
|
||||
assert model.app_categories == ["creative-writing", "personal-agent"]
|
||||
# Not silently swallowed into model_kwargs (the passthrough failure mode).
|
||||
model_kwargs = model.model_kwargs or {}
|
||||
assert "app_url" not in model_kwargs
|
||||
assert "app_title" not in model_kwargs
|
||||
assert "app_categories" not in model_kwargs
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openrouter_anthropic_prompt_cache_enabled_by_default(
|
||||
self, mock_init, monkeypatch
|
||||
|
||||
Reference in New Issue
Block a user