chore(openrouter): bump openrouter floor to 0.9.2, drop file workaround (#38216)

`ChatOpenRouter` users sending PDF/file content blocks previously relied
on a workaround: the OpenRouter Python SDK's `ChatContentItems`
discriminated union didn't include a `file` tag, so file parts failed
Pydantic validation. To work around it, `langchain-openrouter` wrapped
message dicts with `model_construct()` to bypass SDK validation while
still producing correct JSON.

The SDK fixed this in `openrouter` 0.9.2 by adding `file` to the
`ChatContentItems` union (verified against
OpenRouterTeam/python-sdk#38). This PR raises the dependency floor to
`openrouter>=0.9.2` and removes the now-unnecessary
`_wrap_messages_for_sdk` / `_has_file_content_blocks` helpers, passing
plain message dicts straight to the SDK so file parts go through the
normal validated path.

The old workaround's unit tests are replaced with a test asserting the
SDK now validates a `file` content part natively, guarding against a
regression if the floor is ever lowered. The existing `file`-block
conversion helpers and their tests are unchanged.

_This change was authored with the help of an AI agent._

Made by [Open
SWE](https://openswe.vercel.app/agents/9f95ffe6-2298-1045-9316-935f55d00fa7)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-22 23:38:28 -04:00
committed by GitHub
parent 237bb61692
commit 6188e9270e
4 changed files with 37 additions and 188 deletions

View File

@@ -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
#

View File

@@ -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]

View File

@@ -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

View File

@@ -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]]