diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index c9526a3ca4e..1aec0eea655 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -513,8 +513,7 @@ class ChatOpenRouter(BaseChatModel): message_dicts, params = self._create_message_dicts(messages, stop) params = {**params, **kwargs} _strip_internal_kwargs(params) - sdk_messages = _wrap_messages_for_sdk(message_dicts) - response = self.client.chat.send(messages=sdk_messages, **params) + response = self.client.chat.send(messages=message_dicts, **params) return self._create_chat_result(response) async def _agenerate( @@ -532,8 +531,7 @@ class ChatOpenRouter(BaseChatModel): message_dicts, params = self._create_message_dicts(messages, stop) params = {**params, **kwargs} _strip_internal_kwargs(params) - sdk_messages = _wrap_messages_for_sdk(message_dicts) - response = await self.client.chat.send_async(messages=sdk_messages, **params) + response = await self.client.chat.send_async(messages=message_dicts, **params) return self._create_chat_result(response) def _stream( # noqa: C901, PLR0912 @@ -548,10 +546,9 @@ class ChatOpenRouter(BaseChatModel): if self.stream_usage: params["stream_options"] = {"include_usage": True} _strip_internal_kwargs(params) - sdk_messages = _wrap_messages_for_sdk(message_dicts) default_chunk_class: type[BaseMessageChunk] = AIMessageChunk - for chunk in self.client.chat.send(messages=sdk_messages, **params): + for chunk in self.client.chat.send(messages=message_dicts, **params): chunk_dict = chunk.model_dump(by_alias=True) if not chunk_dict.get("choices"): if error := chunk_dict.get("error"): @@ -634,11 +631,10 @@ class ChatOpenRouter(BaseChatModel): if self.stream_usage: params["stream_options"] = {"include_usage": True} _strip_internal_kwargs(params) - sdk_messages = _wrap_messages_for_sdk(message_dicts) default_chunk_class: type[BaseMessageChunk] = AIMessageChunk async for chunk in await self.client.chat.send_async( - messages=sdk_messages, **params + messages=message_dicts, **params ): chunk_dict = chunk.model_dump(by_alias=True) if not chunk_dict.get("choices"): @@ -1010,74 +1006,6 @@ def _strip_internal_kwargs(params: dict[str, Any]) -> None: params.pop(key, None) -def _has_file_content_blocks(message_dicts: list[dict[str, Any]]) -> bool: - """Return `True` if any message dict contains a `file` content block.""" - for msg in message_dicts: - content = msg.get("content") - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get("type") == "file": - return True - return False - - -def _wrap_messages_for_sdk( - message_dicts: list[dict[str, Any]], -) -> list[dict[str, Any]] | list[Any]: - """Wrap message dicts as SDK Pydantic models when file blocks are present. - - 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 - validation while still producing the correct JSON payload. - - When no file blocks are detected the original dicts are returned unchanged - so the normal (validated) code path is preserved. - - Args: - message_dicts: Message dicts produced by `_convert_message_to_dict`. - - Returns: - The original list when no file blocks are present, or a list of SDK - Pydantic model instances otherwise. - """ - if not _has_file_content_blocks(message_dicts): - return message_dicts - - 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]] = { - "user": components.ChatUserMessage, - "system": components.ChatSystemMessage, - "assistant": components.ChatAssistantMessage, - "tool": components.ChatToolMessage, - "developer": components.ChatDeveloperMessage, - } - - wrapped: list[Any] = [] - for msg in message_dicts: - model_cls = role_to_model.get(msg.get("role", "")) - if model_cls is None: - 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)) - return wrapped - - # # Type conversion helpers # diff --git a/libs/partners/openrouter/pyproject.toml b/libs/partners/openrouter/pyproject.toml index 1f60d29f960..ad7eafeb875 100644 --- a/libs/partners/openrouter/pyproject.toml +++ b/libs/partners/openrouter/pyproject.toml @@ -24,7 +24,7 @@ version = "0.2.3" requires-python = ">=3.10.0,<4.0.0" dependencies = [ "langchain-core>=1.4.7,<2.0.0", - "openrouter>=0.7.11,<1.0.0", + "openrouter>=0.9.2,<1.0.0", ] [project.urls] 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 aad8e0663a9..e5f698b3e8e 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -31,8 +31,6 @@ from langchain_openrouter.chat_models import ( _convert_video_block_to_openrouter, _create_usage_metadata, _format_message_content, - _has_file_content_blocks, - _wrap_messages_for_sdk, ) MODEL_NAME = "openai/gpt-5.5" @@ -2817,51 +2815,19 @@ class TestFormatMessageContent: } -class TestWrapMessagesForSdk: - """Tests for `_wrap_messages_for_sdk` SDK validation bypass.""" +class TestSdkFileContentValidation: + """Verify the OpenRouter SDK natively validates `file` content parts. - def test_no_file_blocks_returns_dicts(self) -> None: - """Messages without file blocks should be returned as plain dicts.""" - msgs: list[dict[str, Any]] = [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hi there"}, - ] - result = _wrap_messages_for_sdk(msgs) - # Should be the exact same list object (no wrapping needed) - assert result is msgs + The minimum `openrouter` floor is `>=0.9.2`, where `file` was added to the + `ChatContentItems` discriminated union. These tests guard against + regressions if the floor is ever lowered below that fix. + """ - def test_has_file_content_blocks_detection(self) -> None: - """Test `_has_file_content_blocks` detects file blocks correctly.""" - assert not _has_file_content_blocks([{"role": "user", "content": "plain text"}]) - assert not _has_file_content_blocks( - [ - { - "role": "user", - "content": [{"type": "text", "text": "hi"}], - } - ] - ) - assert _has_file_content_blocks( - [ - { - "role": "user", - "content": [ - {"type": "text", "text": "hi"}, - { - "type": "file", - "file": {"file_data": "https://example.com/a.pdf"}, - }, - ], - } - ] - ) - - def test_wraps_as_pydantic_models(self) -> None: - """File-containing messages should be wrapped as SDK Pydantic models.""" + def test_file_content_part_validates(self) -> None: + """A `file` content part validates and serializes to the right payload.""" from openrouter import components # noqa: PLC0415 - msgs: list[dict[str, Any]] = [ - {"role": "system", "content": "You are helpful."}, + msg = components.ChatUserMessage.model_validate( { "role": "user", "content": [ @@ -2869,78 +2835,23 @@ class TestWrapMessagesForSdk: { "type": "file", "file": { - "file_data": "https://example.com/doc.pdf", + "file_data": "data:application/pdf;base64,abc", "filename": "doc.pdf", }, }, ], - }, - ] - result = _wrap_messages_for_sdk(msgs) - assert len(result) == 2 - assert isinstance(result[0], components.ChatSystemMessage) - assert isinstance(result[1], components.ChatUserMessage) - - def test_wrapped_serializes_correctly(self) -> None: - """Wrapped models should serialize to the correct JSON payload.""" - import warnings # noqa: PLC0415 - - msgs: list[dict[str, Any]] = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Read this."}, - { - "type": "file", - "file": {"file_data": "data:application/pdf;base64,abc"}, - }, - ], - }, - ] - result = _wrap_messages_for_sdk(msgs) - wrapped_msg = result[0] - assert hasattr(wrapped_msg, "model_dump") - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - dumped = wrapped_msg.model_dump(by_alias=True, exclude_none=True) - assert dumped["role"] == "user" - assert dumped["content"][0] == {"type": "text", "text": "Read this."} + } + ) + dumped = msg.model_dump(by_alias=True, exclude_none=True) + assert dumped["content"][0] == {"type": "text", "text": "Summarize this."} assert dumped["content"][1] == { "type": "file", - "file": {"file_data": "data:application/pdf;base64,abc"}, + "file": { + "file_data": "data:application/pdf;base64,abc", + "filename": "doc.pdf", + }, } - def test_all_roles_wrapped(self) -> None: - """All standard roles should be wrapped correctly.""" - from openrouter import components # noqa: PLC0415 - - msgs: list[dict[str, Any]] = [ - {"role": "system", "content": "System prompt."}, - { - "role": "user", - "content": [ - {"type": "file", "file": {"file_data": "https://x.com/f.pdf"}}, - ], - }, - { - "role": "assistant", - "content": "Summary here.", - "tool_calls": [ - { - "id": "c1", - "type": "function", - "function": {"name": "fn", "arguments": "{}"}, - } - ], - }, - {"role": "tool", "content": "result", "tool_call_id": "c1"}, - ] - result = _wrap_messages_for_sdk(msgs) - assert isinstance(result[0], components.ChatSystemMessage) - assert isinstance(result[1], components.ChatUserMessage) - assert isinstance(result[2], components.ChatAssistantMessage) - assert isinstance(result[3], components.ChatToolMessage) - # =========================================================================== # Structured output tests diff --git a/libs/partners/openrouter/uv.lock b/libs/partners/openrouter/uv.lock index 96d66340573..48ed32a0b51 100644 --- a/libs/partners/openrouter/uv.lock +++ b/libs/partners/openrouter/uv.lock @@ -376,6 +376,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, ] +[[package]] +name = "jsonpath-python" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/18/4ca8742534a5993ff383f7602e325ce2d5d7cc93d72ac5e1cdedbea8a458/jsonpath_python-1.1.6.tar.gz", hash = "sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671", size = 88178, upload-time = "2026-05-07T01:26:34.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" }, +] + [[package]] name = "jsonpointer" version = "3.1.1" @@ -476,7 +485,7 @@ typing = [ [package.metadata] requires-dist = [ { name = "langchain-core", editable = "../../core" }, - { name = "openrouter", specifier = ">=0.7.11,<1.0.0" }, + { name = "openrouter", specifier = ">=0.9.2,<1.0.0" }, ] [package.metadata.requires-dev] @@ -896,16 +905,17 @@ wheels = [ [[package]] name = "openrouter" -version = "0.8.0" +version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpcore" }, { name = "httpx" }, + { name = "jsonpath-python" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/c1/44c5c17fdcaf36e8af25eda268d097e40799583ced48ca94adc5015576c3/openrouter-0.8.0.tar.gz", hash = "sha256:28efd76f89bcd13e195fd2729e9ed356a71d9ad88c78ded4ce073f6935381886", size = 207489, upload-time = "2026-03-27T19:20:13.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/35/9d368e4f4780658556b21b7d911215822bfe3cc0da930d84505b7361c9b7/openrouter-0.10.0.tar.gz", hash = "sha256:04567dfc39f11f8e4ff693c966f338610bd3032ee7abceb4b71c99409b8a92e4", size = 317872, upload-time = "2026-06-17T13:50:30.628Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/d1/413bbdd98aac44bf094055cb282b3edaa61a1e1b7e54ebdbf9afebfd5178/openrouter-0.8.0-py3-none-any.whl", hash = "sha256:77cf76f14f2090747ff2c153734a9112dfb27faccb5f90d760efbb94ea7e0838", size = 442960, upload-time = "2026-03-27T19:20:12.135Z" }, + { url = "https://files.pythonhosted.org/packages/89/64/4166b81b5c8a899a80f457b7d340fa202df3083eac13cbe1cb6bede772a4/openrouter-0.10.0-py3-none-any.whl", hash = "sha256:0e318c1ea61fe1c78633dcef97440f43fca7322989d52e5bcea780fabd9150d6", size = 704688, upload-time = "2026-06-17T13:50:31.982Z" }, ] [[package]]