From 835e9b0a6538e36ada3988d5eb95425d542cc1e2 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Fri, 13 Feb 2026 21:47:38 -0500 Subject: [PATCH] cr --- .../langchain_openrouter/chat_models.py | 106 +++++++++++------- .../tests/unit_tests/test_chat_models.py | 97 ++++++++++++++++ 2 files changed, 164 insertions(+), 39 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 6ff873f5ce8..f093e7468c5 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -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, 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 37022ca0769..f27216620e6 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -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