mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-01 14:47:02 +00:00
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:
@@ -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
|
||||
#
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
18
libs/partners/openrouter/uv.lock
generated
18
libs/partners/openrouter/uv.lock
generated
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user