From 2a16ee9b73014524db540f22d3250754e864b2a5 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Wed, 25 Mar 2026 10:58:17 -0400 Subject: [PATCH] feat(openrouter): add `app_categories` field for marketplace attribution (#36205) Add support for the `X-OpenRouter-Categories` header via a new `app_categories` field on `ChatOpenRouter`, and extract inline client construction into a dedicated `_build_client` method. --- .../langchain_openrouter/chat_models.py | 104 ++++++++++++------ .../tests/unit_tests/test_chat_models.py | 66 +++++++++++ 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 357cc70f27d..7438bd98154 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -118,6 +118,7 @@ class ChatOpenRouter(BaseChatModel): | `timeout` | `int | None` | Timeout in milliseconds. | | `app_url` | `str | None` | App URL for attribution. | | `app_title` | `str | None` | App title for attribution. | + | `app_categories` | `list[str] | None` | Marketplace attribution categories. | | `max_retries` | `int` | Max retries (default `2`). Set to `0` to disable. | ??? info "Instantiate" @@ -174,12 +175,27 @@ class ChatOpenRouter(BaseChatModel): Maps to `X-Title` header. - Defaults to `'LangChain'`. Set this to your app's URL to get attribution for - API usage in the OpenRouter dashboard. + Defaults to `'LangChain'`. Set this to your app's name to get attribution + for API usage in the OpenRouter dashboard. See https://openrouter.ai/docs/app-attribution for details. """ + app_categories: list[str] | None = Field( + default=None, + ) + """Marketplace categories for OpenRouter attribution. + + Maps to `X-OpenRouter-Categories` header. Pass a list of lowercase, + hyphen-separated category strings (max 30 characters each), + e.g. `['cli-agent', 'programming-app']`. + + Only recognized categories are accepted (unrecognized values are silently + dropped by OpenRouter). + + See https://openrouter.ai/docs/app-attribution for recognized categories. + """ + request_timeout: int | None = Field(default=None, alias="timeout") """Timeout for requests in milliseconds. Maps to SDK `timeout_ms`.""" @@ -307,6 +323,59 @@ class ChatOpenRouter(BaseChatModel): values["model_kwargs"] = extra return values + def _build_client(self) -> Any: + """Build and return an `openrouter.OpenRouter` SDK client. + + Returns: + An `openrouter.OpenRouter` SDK client instance. + """ + import openrouter # noqa: PLC0415 + from openrouter.utils import ( # noqa: PLC0415 + BackoffStrategy, + RetryConfig, + ) + + client_kwargs: dict[str, Any] = { + "api_key": self.openrouter_api_key.get_secret_value(), # type: ignore[union-attr] + } + if self.openrouter_api_base: + client_kwargs["server_url"] = self.openrouter_api_base + if self.app_url: + client_kwargs["http_referer"] = self.app_url + if self.app_title: + client_kwargs["x_title"] = self.app_title + if self.app_categories: + # The SDK lacks a native constructor param for X-OpenRouter-Categories, + # so inject the header via custom httpx clients. The SDK sets its own + # headers (Authorization, HTTP-Referer, X-Title) per-request, and httpx + # merges client-default headers with per-request headers, so nothing is + # lost. + import httpx # noqa: PLC0415 + + extra_headers = { + "X-OpenRouter-Categories": ",".join(self.app_categories), + } + client_kwargs["client"] = httpx.Client( + headers=extra_headers, follow_redirects=True + ) + client_kwargs["async_client"] = httpx.AsyncClient( + headers=extra_headers, follow_redirects=True + ) + if self.request_timeout is not None: + client_kwargs["timeout_ms"] = self.request_timeout + if self.max_retries > 0: + client_kwargs["retry_config"] = RetryConfig( + strategy="backoff", + backoff=BackoffStrategy( + initial_interval=500, + max_interval=60000, + exponent=1.5, + max_elapsed_time=self.max_retries * 150_000, + ), + retry_connection_errors=True, + ) + return openrouter.OpenRouter(**client_kwargs) + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate configuration and build the SDK client.""" @@ -319,41 +388,14 @@ class ChatOpenRouter(BaseChatModel): if not self.client: try: - import openrouter # noqa: PLC0415 - from openrouter.utils import ( # noqa: PLC0415 - BackoffStrategy, - RetryConfig, - ) + import openrouter # noqa: PLC0415, F401 except ImportError as e: msg = ( "Could not import the `openrouter` Python SDK. " "Please install it with: pip install openrouter" ) raise ImportError(msg) from e - - client_kwargs: dict[str, Any] = { - "api_key": self.openrouter_api_key.get_secret_value(), - } - if self.openrouter_api_base: - client_kwargs["server_url"] = self.openrouter_api_base - if self.app_url: - client_kwargs["http_referer"] = self.app_url - if self.app_title: - client_kwargs["x_title"] = self.app_title - if self.request_timeout is not None: - client_kwargs["timeout_ms"] = self.request_timeout - if self.max_retries > 0: - client_kwargs["retry_config"] = RetryConfig( - strategy="backoff", - backoff=BackoffStrategy( - initial_interval=500, - max_interval=60000, - exponent=1.5, - max_elapsed_time=self.max_retries * 150_000, - ), - retry_connection_errors=True, - ) - self.client = openrouter.OpenRouter(**client_kwargs) + self.client = self._build_client() return self def _resolve_model_profile(self) -> ModelProfile | None: diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index fc352da3301..58bd605e567 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -337,6 +337,72 @@ class TestChatOpenRouterInstantiation: assert call_kwargs["http_referer"] == "https://my-custom-app.com" assert call_kwargs["x_title"] == "My Custom App" + def test_app_categories_passed_to_client(self) -> None: + """Test that app_categories injects custom httpx clients with header.""" + with patch("openrouter.OpenRouter") as mock_cls: + mock_cls.return_value = MagicMock() + ChatOpenRouter( + model=MODEL_NAME, + api_key=SecretStr("test-key"), + app_categories=["cli-agent", "programming-app"], + ) + call_kwargs = mock_cls.call_args[1] + # Custom httpx clients should be created + assert "client" in call_kwargs + assert "async_client" in call_kwargs + # Verify the header value is comma-joined + sync_headers = call_kwargs["client"].headers + assert sync_headers["X-OpenRouter-Categories"] == ( + "cli-agent,programming-app" + ) + async_headers = call_kwargs["async_client"].headers + assert async_headers["X-OpenRouter-Categories"] == ( + "cli-agent,programming-app" + ) + + def test_app_categories_none_no_custom_clients(self) -> None: + """Test that no custom httpx clients are created when categories unset.""" + with patch("openrouter.OpenRouter") as mock_cls: + mock_cls.return_value = MagicMock() + ChatOpenRouter( + model=MODEL_NAME, + api_key=SecretStr("test-key"), + ) + call_kwargs = mock_cls.call_args[1] + assert "client" not in call_kwargs + assert "async_client" not in call_kwargs + + def test_app_categories_empty_list_no_custom_clients(self) -> None: + """Test that an empty list does not inject custom httpx clients.""" + with patch("openrouter.OpenRouter") as mock_cls: + mock_cls.return_value = MagicMock() + ChatOpenRouter( + model=MODEL_NAME, + api_key=SecretStr("test-key"), + app_categories=[], + ) + call_kwargs = mock_cls.call_args[1] + assert "client" not in call_kwargs + assert "async_client" not in call_kwargs + + def test_app_categories_with_other_attribution(self) -> None: + """Test that app_categories coexists with app_url and app_title.""" + with patch("openrouter.OpenRouter") as mock_cls: + mock_cls.return_value = MagicMock() + ChatOpenRouter( + model=MODEL_NAME, + api_key=SecretStr("test-key"), + app_url="https://myapp.com", + app_title="My App", + app_categories=["cli-agent"], + ) + call_kwargs = mock_cls.call_args[1] + assert call_kwargs["http_referer"] == "https://myapp.com" + assert call_kwargs["x_title"] == "My App" + assert "client" in call_kwargs + sync_headers = call_kwargs["client"].headers + assert sync_headers["X-OpenRouter-Categories"] == "cli-agent" + def test_reasoning_in_params(self) -> None: """Test that `reasoning` is included in default params.""" model = _make_model(reasoning={"effort": "high"})