* 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:
@@ -123,6 +123,16 @@ def get_config_path() -> Path:
|
||||
# Configuration dataclass
|
||||
# =============================================================================
|
||||
|
||||
# OpenRouter app-attribution defaults (issue #339). Single source of truth: the
|
||||
# EvoScientistConfig fields below default to these, and llm/models.py imports
|
||||
# them for its env-fallback, so the values never drift across the two layers.
|
||||
OPENROUTER_DEFAULT_HTTP_REFERER = "https://github.com/EvoScientist/EvoScientist"
|
||||
OPENROUTER_DEFAULT_APP_TITLE = "EvoScientist"
|
||||
# OpenRouter honors only the first 2 categories per request (server-side limit)
|
||||
# and silently ignores the rest, so keep the two most relevant ones. Chosen per
|
||||
# maintainer review — creative-writing is a less competitive marketplace group.
|
||||
OPENROUTER_DEFAULT_APP_CATEGORIES = "creative-writing,personal-agent"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvoScientistConfig:
|
||||
@@ -282,6 +292,15 @@ class EvoScientistConfig:
|
||||
# Anthropic prompt caching for OpenRouter anthropic/* models. Opt out if
|
||||
# cache-write costs outweigh the benefit for a workflow.
|
||||
openrouter_anthropic_prompt_cache: bool = True
|
||||
# OpenRouter app attribution (issue #339). Sent only for the openrouter
|
||||
# provider; identifies EvoScientist in OpenRouter's app rankings/analytics.
|
||||
# Override (e.g. a private fork) via these fields or their env vars.
|
||||
# Defaults live in the module constants above (also imported by llm/models.py).
|
||||
openrouter_http_referer: str = OPENROUTER_DEFAULT_HTTP_REFERER
|
||||
openrouter_app_title: str = OPENROUTER_DEFAULT_APP_TITLE
|
||||
# Comma-separated; split into a list before being passed to
|
||||
# langchain-openrouter (its app_categories kwarg expects list[str]).
|
||||
openrouter_app_categories: str = OPENROUTER_DEFAULT_APP_CATEGORIES
|
||||
|
||||
# Channel Settings
|
||||
channel_enabled: str = "" # "imessage" | "telegram" | "discord" | "slack" | "wechat" | "dingtalk" | "feishu" | "email" | "qq" | "signal" | "" (comma-separated for multiple)
|
||||
@@ -761,6 +780,9 @@ _ENV_MAPPINGS = {
|
||||
"openrouter_anthropic_prompt_cache": (
|
||||
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE"
|
||||
),
|
||||
"openrouter_http_referer": "EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
|
||||
"openrouter_app_title": "EVOSCIENTIST_OPENROUTER_APP_TITLE",
|
||||
"openrouter_app_categories": "EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
|
||||
"dangerous_mode": "EVOSCIENTIST_DANGEROUS_MODE",
|
||||
"channel_debug_tracing": "EVOSCIENTIST_CHANNEL_DEBUG_TRACING",
|
||||
"ccproxy_port": "EVOSCIENTIST_CCPROXY_PORT",
|
||||
@@ -892,6 +914,22 @@ 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_http_referer and not os.environ.get(
|
||||
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER"
|
||||
):
|
||||
os.environ["EVOSCIENTIST_OPENROUTER_HTTP_REFERER"] = (
|
||||
config.openrouter_http_referer
|
||||
)
|
||||
if config.openrouter_app_title and not os.environ.get(
|
||||
"EVOSCIENTIST_OPENROUTER_APP_TITLE"
|
||||
):
|
||||
os.environ["EVOSCIENTIST_OPENROUTER_APP_TITLE"] = config.openrouter_app_title
|
||||
if config.openrouter_app_categories and not os.environ.get(
|
||||
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES"
|
||||
):
|
||||
os.environ["EVOSCIENTIST_OPENROUTER_APP_CATEGORIES"] = (
|
||||
config.openrouter_app_categories
|
||||
)
|
||||
if not config.openrouter_anthropic_prompt_cache and not os.environ.get(
|
||||
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE"
|
||||
):
|
||||
|
||||
@@ -15,6 +15,11 @@ from typing import Any
|
||||
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
from ..config.settings import (
|
||||
OPENROUTER_DEFAULT_APP_CATEGORIES,
|
||||
OPENROUTER_DEFAULT_APP_TITLE,
|
||||
OPENROUTER_DEFAULT_HTTP_REFERER,
|
||||
)
|
||||
from .context_window import apply_known_context_window
|
||||
from .patches import (
|
||||
_is_ccproxy_codex,
|
||||
@@ -68,6 +73,14 @@ _THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"}
|
||||
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
|
||||
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
|
||||
|
||||
# OpenRouter app attribution (issue #339). Default values are the single source
|
||||
# of truth in config/settings.py (imported above); langchain-openrouter maps
|
||||
# app_url → HTTP-Referer, app_title → X-Title, app_categories →
|
||||
# X-OpenRouter-Categories. OpenRouter honors at most this many categories per
|
||||
# request (server-side limit) and silently ignores the rest, so the sent list is
|
||||
# capped to this many below. https://openrouter.ai/docs/app-attribution
|
||||
_OPENROUTER_MAX_CATEGORIES_PER_REQUEST = 2
|
||||
|
||||
# Model registry: list of (short_name, model_id, provider)
|
||||
# Allows same short_name across different providers.
|
||||
_MODEL_ENTRIES: list[tuple[str, str, str]] = [
|
||||
@@ -497,6 +510,52 @@ def get_chat_model(
|
||||
# so enabling `summary` is safe. See langchain-ai/langchain#37777.
|
||||
effort = os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or "high"
|
||||
kwargs.setdefault("reasoning", {"effort": effort, "summary": "auto"})
|
||||
# App attribution (issue #339): identify EvoScientist to OpenRouter so
|
||||
# usage is credited to the project (app rankings, model app tabs,
|
||||
# analytics) rather than langchain-openrouter's LangChain-branded
|
||||
# defaults. setdefault so an explicit caller kwarg wins; values are
|
||||
# configurable via EVOSCIENTIST_OPENROUTER_* env (fed from the config
|
||||
# file by apply_config_to_env). Applied only here, so no other provider
|
||||
# ever receives these kwargs.
|
||||
kwargs.setdefault(
|
||||
"app_url",
|
||||
os.environ.get("EVOSCIENTIST_OPENROUTER_HTTP_REFERER", "").strip()
|
||||
or OPENROUTER_DEFAULT_HTTP_REFERER,
|
||||
)
|
||||
kwargs.setdefault(
|
||||
"app_title",
|
||||
os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE", "").strip()
|
||||
or OPENROUTER_DEFAULT_APP_TITLE,
|
||||
)
|
||||
# app_categories must be a list[str] (langchain-openrouter joins it into
|
||||
# the X-OpenRouter-Categories header); split the comma-separated config
|
||||
# value and drop blanks so a stray comma/space can't emit an empty one.
|
||||
_app_categories_raw = (
|
||||
os.environ.get("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", "").strip()
|
||||
or OPENROUTER_DEFAULT_APP_CATEGORIES
|
||||
)
|
||||
_app_categories = [
|
||||
c.strip() for c in _app_categories_raw.split(",") if c.strip()
|
||||
]
|
||||
# Cap to the per-request limit and warn, so a misconfigured extra is
|
||||
# dropped predictably here (and surfaced to the user) rather than being
|
||||
# silently truncated server-side.
|
||||
_limit = _OPENROUTER_MAX_CATEGORIES_PER_REQUEST
|
||||
if len(_app_categories) > _limit:
|
||||
warnings.warn(
|
||||
f"OpenRouter accepts at most {_limit} app categories per "
|
||||
f"request, so only the first {_limit} are sent: "
|
||||
f"{_app_categories[:_limit]}. Ignoring the rest: "
|
||||
f"{_app_categories[_limit:]}. Set "
|
||||
f"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES (or the "
|
||||
f"openrouter_app_categories config) to at most {_limit} "
|
||||
f"categories to silence this warning.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
_app_categories = _app_categories[:_limit]
|
||||
if _app_categories:
|
||||
kwargs.setdefault("app_categories", _app_categories)
|
||||
_patch_openrouter_strip_responses_reasoning()
|
||||
|
||||
# Anthropic-routed providers → route through Anthropic provider with base_url
|
||||
|
||||
@@ -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