diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 7438bd98154..71fb245de07 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -202,7 +202,8 @@ class ChatOpenRouter(BaseChatModel): max_retries: int = 2 """Maximum number of retries. - Controls the retry backoff window via the SDK's `max_elapsed_time`. + Each unit adds ~150 seconds to the backoff window via the SDK's + `max_elapsed_time` (e.g. `max_retries=2` allows up to ~300 s). Set to `0` to disable retries. """ @@ -340,21 +341,16 @@ class ChatOpenRouter(BaseChatModel): } if self.openrouter_api_base: client_kwargs["server_url"] = self.openrouter_api_base + extra_headers: dict[str, str] = {} if self.app_url: - client_kwargs["http_referer"] = self.app_url + extra_headers["HTTP-Referer"] = self.app_url if self.app_title: - client_kwargs["x_title"] = self.app_title + extra_headers["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. + extra_headers["X-OpenRouter-Categories"] = ",".join(self.app_categories) + if extra_headers: import httpx # noqa: PLC0415 - extra_headers = { - "X-OpenRouter-Categories": ",".join(self.app_categories), - } client_kwargs["client"] = httpx.Client( headers=extra_headers, follow_redirects=True ) @@ -389,13 +385,14 @@ class ChatOpenRouter(BaseChatModel): if not self.client: try: import openrouter # noqa: PLC0415, F401 + + self.client = self._build_client() except ImportError as e: msg = ( "Could not import the `openrouter` Python SDK. " "Please install it with: pip install openrouter" ) raise ImportError(msg) from e - self.client = self._build_client() return self def _resolve_model_profile(self) -> ModelProfile | None: @@ -974,7 +971,7 @@ def _wrap_messages_for_sdk( ) -> list[dict[str, Any]] | list[Any]: """Wrap message dicts as SDK Pydantic models when file blocks are present. - The OpenRouter Python SDK (v0.6.0) does not include `file` in its + The OpenRouter Python SDK does not include `file` in its `ChatMessageContentItem` discriminated union, so Pydantic validation rejects file content blocks even though the OpenRouter **API** supports them. Using `model_construct` on the SDK's message classes bypasses @@ -996,6 +993,11 @@ def _wrap_messages_for_sdk( try: from openrouter import components # noqa: PLC0415 except ImportError: + warnings.warn( + "Could not import openrouter.components; file content blocks " + "will be sent as raw dicts which may cause validation errors.", + stacklevel=2, + ) return message_dicts role_to_model: dict[str, type[BaseModel]] = { @@ -1010,7 +1012,11 @@ def _wrap_messages_for_sdk( for msg in message_dicts: model_cls = role_to_model.get(msg.get("role", "")) if model_cls is None: - # Unknown role — pass dict through and hope for the best. + warnings.warn( + f"Unknown message role {msg.get('role')!r} encountered during " + f"SDK wrapping; passing raw dict to the API.", + stacklevel=2, + ) wrapped.append(msg) continue wrapped.append(model_cls.model_construct(**msg)) 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 58bd605e567..6d3fb91708e 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -288,7 +288,7 @@ class TestChatOpenRouterInstantiation: assert model.client is client_1 def test_app_url_passed_to_client(self) -> None: - """Test that app_url is passed as http_referer to the SDK client.""" + """Test that app_url is passed as HTTP-Referer header via httpx clients.""" with patch("openrouter.OpenRouter") as mock_cls: mock_cls.return_value = MagicMock() ChatOpenRouter( @@ -297,10 +297,10 @@ class TestChatOpenRouterInstantiation: app_url="https://myapp.com", ) call_kwargs = mock_cls.call_args[1] - assert call_kwargs["http_referer"] == "https://myapp.com" + assert call_kwargs["client"].headers["HTTP-Referer"] == "https://myapp.com" def test_app_title_passed_to_client(self) -> None: - """Test that app_title is passed as x_title to the SDK client.""" + """Test that app_title is passed as X-Title header via httpx clients.""" with patch("openrouter.OpenRouter") as mock_cls: mock_cls.return_value = MagicMock() ChatOpenRouter( @@ -309,7 +309,7 @@ class TestChatOpenRouterInstantiation: app_title="My App", ) call_kwargs = mock_cls.call_args[1] - assert call_kwargs["x_title"] == "My App" + assert call_kwargs["client"].headers["X-Title"] == "My App" def test_default_attribution_headers(self) -> None: """Test that default attribution headers are sent when not overridden.""" @@ -320,8 +320,9 @@ class TestChatOpenRouterInstantiation: api_key=SecretStr("test-key"), ) call_kwargs = mock_cls.call_args[1] - assert call_kwargs["http_referer"] == ("https://docs.langchain.com") - assert call_kwargs["x_title"] == "LangChain" + sync_headers = call_kwargs["client"].headers + assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com" + assert sync_headers["X-Title"] == "LangChain" def test_user_attribution_overrides_defaults(self) -> None: """Test that user-supplied attribution overrides the defaults.""" @@ -334,8 +335,9 @@ class TestChatOpenRouterInstantiation: app_title="My Custom App", ) call_kwargs = mock_cls.call_args[1] - assert call_kwargs["http_referer"] == "https://my-custom-app.com" - assert call_kwargs["x_title"] == "My Custom App" + sync_headers = call_kwargs["client"].headers + assert sync_headers["HTTP-Referer"] == "https://my-custom-app.com" + assert sync_headers["X-Title"] == "My Custom App" def test_app_categories_passed_to_client(self) -> None: """Test that app_categories injects custom httpx clients with header.""" @@ -360,8 +362,8 @@ class TestChatOpenRouterInstantiation: "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.""" + def test_app_categories_none_no_categories_header(self) -> None: + """Test that no X-OpenRouter-Categories header when categories unset.""" with patch("openrouter.OpenRouter") as mock_cls: mock_cls.return_value = MagicMock() ChatOpenRouter( @@ -369,11 +371,12 @@ class TestChatOpenRouterInstantiation: 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 + # httpx clients still created for X-Title default + sync_headers = call_kwargs["client"].headers + assert "X-OpenRouter-Categories" not in sync_headers - def test_app_categories_empty_list_no_custom_clients(self) -> None: - """Test that an empty list does not inject custom httpx clients.""" + def test_app_categories_empty_list_no_categories_header(self) -> None: + """Test that an empty list does not inject categories header.""" with patch("openrouter.OpenRouter") as mock_cls: mock_cls.return_value = MagicMock() ChatOpenRouter( @@ -382,8 +385,8 @@ class TestChatOpenRouterInstantiation: app_categories=[], ) call_kwargs = mock_cls.call_args[1] - assert "client" not in call_kwargs - assert "async_client" not in call_kwargs + sync_headers = call_kwargs["client"].headers + assert "X-OpenRouter-Categories" not in sync_headers def test_app_categories_with_other_attribution(self) -> None: """Test that app_categories coexists with app_url and app_title.""" @@ -397,12 +400,52 @@ class TestChatOpenRouterInstantiation: 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["HTTP-Referer"] == "https://myapp.com" + assert sync_headers["X-Title"] == "My App" assert sync_headers["X-OpenRouter-Categories"] == "cli-agent" + def test_app_title_none_no_x_title_header(self) -> None: + """Test that X-Title header is omitted when app_title is explicitly None.""" + with patch("openrouter.OpenRouter") as mock_cls: + mock_cls.return_value = MagicMock() + ChatOpenRouter( + model=MODEL_NAME, + api_key=SecretStr("test-key"), + app_title=None, + ) + call_kwargs = mock_cls.call_args[1] + sync_headers = call_kwargs["client"].headers + assert "X-Title" not in sync_headers + + def test_app_url_none_no_referer_header(self) -> None: + """Test that HTTP-Referer header is omitted when app_url is explicitly None.""" + with patch("openrouter.OpenRouter") as mock_cls: + mock_cls.return_value = MagicMock() + ChatOpenRouter( + model=MODEL_NAME, + api_key=SecretStr("test-key"), + app_url=None, + ) + call_kwargs = mock_cls.call_args[1] + sync_headers = call_kwargs["client"].headers + assert "HTTP-Referer" not in sync_headers + + def test_no_attribution_no_custom_clients(self) -> None: + """Test that no httpx clients are created when all attribution is None.""" + with patch("openrouter.OpenRouter") as mock_cls: + mock_cls.return_value = MagicMock() + ChatOpenRouter( + model=MODEL_NAME, + api_key=SecretStr("test-key"), + app_url=None, + app_title=None, + app_categories=None, + ) + call_kwargs = mock_cls.call_args[1] + assert "client" not in call_kwargs + assert "async_client" not in call_kwargs + def test_reasoning_in_params(self) -> None: """Test that `reasoning` is included in default params.""" model = _make_model(reasoning={"effort": "high"}) diff --git a/libs/partners/openrouter/uv.lock b/libs/partners/openrouter/uv.lock index 542a712278f..ea240f9d400 100644 --- a/libs/partners/openrouter/uv.lock +++ b/libs/partners/openrouter/uv.lock @@ -334,7 +334,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.22" +version = "1.2.23" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" },