This commit is contained in:
Mason Daugherty
2026-02-13 21:47:38 -05:00
parent d4f739c5fd
commit 835e9b0a65
2 changed files with 164 additions and 39 deletions

View File

@@ -86,7 +86,7 @@ def _get_default_model_profile(model_name: str) -> ModelProfile:
class ChatOpenRouter(BaseChatModel):
"""OpenRouter chat model integration.
OpenRouter is a unified API that provides access to models from
OpenRouter is a unified API that provides access to hundreds of models from
multiple providers (OpenAI, Anthropic, Google, Meta, etc.).
???+ info "Setup"
@@ -119,6 +119,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. |
| `max_retries` | `int` | Max retries (default `2`). Set to `0` to disable. |
??? info "Instantiate"
@@ -137,9 +138,39 @@ class ChatOpenRouter(BaseChatModel):
"""
client: Any = Field(default=None, exclude=True)
"""Underlying SDK client (`openrouter.OpenRouter`).
"""Underlying SDK client (`openrouter.OpenRouter`)."""
Created automatically during validation.
openrouter_api_key: SecretStr | None = Field(
alias="api_key",
default_factory=secret_from_env("OPENROUTER_API_KEY", default=None),
)
"""OpenRouter API key."""
openrouter_api_base: str | None = Field(
default_factory=from_env("OPENROUTER_API_BASE", default=None),
alias="base_url",
)
"""OpenRouter API base URL. Maps to SDK `server_url`."""
app_url: str | None = Field(
default_factory=from_env("OPENROUTER_APP_URL", default=None),
)
"""Application URL for OpenRouter attribution. Maps to `HTTP-Referer` header."""
app_title: str | None = Field(
default_factory=from_env("OPENROUTER_APP_TITLE", default=None),
)
"""Application title for OpenRouter attribution. Maps to `X-Title` header."""
request_timeout: int | None = Field(default=None, alias="timeout")
"""Timeout for requests in milliseconds. Maps to SDK `timeout_ms`."""
max_retries: int = 2
"""Maximum number of retries.
Controls the retry backoff window via the SDK's `max_elapsed_time`.
Set to `0` to disable retries.
"""
model_name: str = Field(alias="model")
@@ -178,39 +209,6 @@ class ChatOpenRouter(BaseChatModel):
model_kwargs: dict[str, Any] = Field(default_factory=dict)
"""Any extra model parameters for the OpenRouter API."""
openrouter_api_key: SecretStr | None = Field(
alias="api_key",
default_factory=secret_from_env("OPENROUTER_API_KEY", default=None),
)
"""OpenRouter API key."""
openrouter_api_base: str | None = Field(
default_factory=from_env("OPENROUTER_API_BASE", default=None),
alias="base_url",
)
"""OpenRouter API base URL. Maps to SDK `server_url`."""
app_url: str | None = Field(
default_factory=from_env("OPENROUTER_APP_URL", default=None),
)
"""Application URL for OpenRouter attribution. Maps to `HTTP-Referer` header."""
app_title: str | None = Field(
default_factory=from_env("OPENROUTER_APP_TITLE", default=None),
)
"""Application title for OpenRouter attribution. Maps to `X-Title` header."""
request_timeout: int | None = Field(default=None, alias="timeout")
"""Timeout for requests in milliseconds. Maps to SDK `timeout_ms`."""
max_retries: int = 2
"""Maximum number of retries.
Controls the retry backoff window via the SDK's `max_elapsed_time`.
Set to `0` to disable retries.
"""
openrouter_reasoning: dict[str, Any] | None = None
"""Reasoning settings to pass to OpenRouter.
@@ -769,6 +767,31 @@ def _strip_internal_kwargs(params: dict[str, Any]) -> None:
#
# Type conversion helpers
#
def _convert_video_block_to_openrouter(block: dict[str, Any]) -> dict[str, Any]:
"""Convert a LangChain video content block to OpenRouter's `video_url` format.
Args:
block: A LangChain `VideoContentBlock`.
Returns:
A dict in OpenRouter's `video_url` format.
Raises:
ValueError: If no video source is provided.
"""
if "url" in block:
return {"type": "video_url", "video_url": {"url": block["url"]}}
if "base64" in block or block.get("source_type") == "base64":
base64_data = block["data"] if "source_type" in block else block["base64"]
mime_type = block.get("mime_type", "video/mp4")
return {
"type": "video_url",
"video_url": {"url": f"data:{mime_type};base64,{base64_data}"},
}
msg = "Video block must have either 'url' or 'base64' data."
raise ValueError(msg)
def _format_message_content(content: Any) -> Any:
"""Format message content for OpenRouter API.
@@ -784,7 +807,10 @@ def _format_message_content(content: Any) -> Any:
formatted: list = []
for block in content:
if isinstance(block, dict) and is_data_content_block(block):
formatted.append(convert_to_openai_data_block(block))
if block.get("type") == "video":
formatted.append(_convert_video_block_to_openrouter(block))
else:
formatted.append(convert_to_openai_data_block(block))
else:
formatted.append(block)
return formatted
@@ -1023,11 +1049,13 @@ def _create_usage_metadata(token_usage: dict) -> UsageMetadata:
or {}
)
cache_read = input_details_dict.get("cached_tokens")
input_token_details: dict = {
"cache_read": input_details_dict.get("cached_tokens"),
"cache_read": int(cache_read) if cache_read is not None else None,
}
reasoning_tokens = output_details_dict.get("reasoning_tokens")
output_token_details: dict = {
"reasoning": output_details_dict.get("reasoning_tokens"),
"reasoning": int(reasoning_tokens) if reasoning_tokens is not None else None,
}
usage_metadata: UsageMetadata = {
"input_tokens": input_tokens,

View File

@@ -26,6 +26,7 @@ from langchain_openrouter.chat_models import (
_convert_chunk_to_message_chunk,
_convert_dict_to_message,
_convert_message_to_dict,
_convert_video_block_to_openrouter,
_create_usage_metadata,
_format_message_content,
)
@@ -1596,6 +1597,102 @@ class TestFormatMessageContent:
assert result[0]["type"] == "text"
assert result[1]["type"] == "image_url"
def test_image_base64_block(self) -> None:
"""Test that base64 image blocks are converted to image_url format."""
content = [
{
"type": "image",
"base64": "iVBORw0KGgo=",
"mime_type": "image/png",
},
]
result = _format_message_content(content)
assert len(result) == 1
assert result[0]["type"] == "image_url"
assert result[0]["image_url"]["url"].startswith("data:image/png;base64,")
def test_audio_base64_block(self) -> None:
"""Test that base64 audio blocks are converted to input_audio format."""
content = [
{"type": "text", "text": "Transcribe this audio."},
{
"type": "audio",
"base64": "UklGR...",
"mime_type": "audio/wav",
},
]
result = _format_message_content(content)
assert len(result) == 2
assert result[0]["type"] == "text"
assert result[1]["type"] == "input_audio"
assert result[1]["input_audio"]["data"] == "UklGR..."
assert result[1]["input_audio"]["format"] == "wav"
def test_video_url_block(self) -> None:
"""Test that video URL blocks are converted to video_url format."""
content = [
{"type": "text", "text": "Describe this video."},
{
"type": "video",
"url": "https://example.com/video.mp4",
},
]
result = _format_message_content(content)
assert len(result) == 2
assert result[0]["type"] == "text"
assert result[1] == {
"type": "video_url",
"video_url": {"url": "https://example.com/video.mp4"},
}
def test_video_base64_block(self) -> None:
"""Test that base64 video blocks are converted to video_url data URI."""
content = [
{
"type": "video",
"base64": "AAAAIGZ0...",
"mime_type": "video/mp4",
},
]
result = _format_message_content(content)
assert len(result) == 1
assert result[0]["type"] == "video_url"
assert result[0]["video_url"]["url"] == (
"data:video/mp4;base64,AAAAIGZ0..."
)
def test_video_base64_default_mime_type(self) -> None:
"""Test that video base64 defaults to video/mp4 when mime_type is missing."""
content = [
{
"type": "video",
"base64": "AAAAIGZ0...",
},
]
result = _format_message_content(content)
assert result[0]["video_url"]["url"].startswith("data:video/mp4;base64,")
def test_video_block_missing_source_raises(self) -> None:
"""Test that video blocks without url or base64 raise ValueError."""
block: dict[str, Any] = {"type": "video", "mime_type": "video/mp4"}
with pytest.raises(ValueError, match="url.*base64"):
_convert_video_block_to_openrouter(block)
def test_mixed_multimodal_content(self) -> None:
"""Test formatting a message with text, image, audio, and video blocks."""
content = [
{"type": "text", "text": "Analyze these inputs."},
{"type": "image", "url": "https://example.com/img.png"},
{"type": "audio", "base64": "audio_data", "mime_type": "audio/mp3"},
{"type": "video", "url": "https://example.com/clip.mp4"},
]
result = _format_message_content(content)
assert len(result) == 4
assert result[0]["type"] == "text"
assert result[1]["type"] == "image_url"
assert result[2]["type"] == "input_audio"
assert result[3]["type"] == "video_url"
# ===========================================================================
# Structured output tests