diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 0793a9c..d541c6d 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -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" ): diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 5484676..6304f77 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 8799da5..df7a7a7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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. diff --git a/tests/test_llm.py b/tests/test_llm.py index 23168d0..562280c 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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