libs/partners/openrouter/tests/unit_tests/test_chat_models.py PYTHON 3,734 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 3,734.
1"""Unit tests for `ChatOpenRouter` chat model."""23from __future__ import annotations45import warnings6from typing import Any, Literal7from unittest.mock import AsyncMock, MagicMock, patch89import pytest10from langchain_core.load import dumpd, dumps, load11from langchain_core.messages import (12    AIMessage,13    AIMessageChunk,14    ChatMessage,15    ChatMessageChunk,16    HumanMessage,17    HumanMessageChunk,18    SystemMessage,19    SystemMessageChunk,20    ToolMessage,21)22from langchain_core.runnables import RunnableBinding23from pydantic import BaseModel, Field, SecretStr2425from langchain_openrouter.chat_models import (26    ChatOpenRouter,27    _convert_chunk_to_message_chunk,28    _convert_dict_to_message,29    _convert_file_block_to_openrouter,30    _convert_message_to_dict,31    _convert_video_block_to_openrouter,32    _create_usage_metadata,33    _format_message_content,34)3536MODEL_NAME = "openai/gpt-5.5"373839def _make_model(**kwargs: Any) -> ChatOpenRouter:40    """Create a `ChatOpenRouter` with sane defaults for unit tests."""41    defaults: dict[str, Any] = {"model": MODEL_NAME, "api_key": SecretStr("test-key")}42    defaults.update(kwargs)43    return ChatOpenRouter(**defaults)444546# ---------------------------------------------------------------------------47# Pydantic schemas used across multiple test classes48# ---------------------------------------------------------------------------495051class GetWeather(BaseModel):52    """Get the current weather in a given location."""5354    location: str = Field(description="The city and state")555657class GenerateUsername(BaseModel):58    """Generate a username from a full name."""5960    name: str = Field(description="The full name")61    hair_color: str = Field(description="The hair color")626364# ---------------------------------------------------------------------------65# Mock helpers for SDK responses66# ---------------------------------------------------------------------------6768_SIMPLE_RESPONSE_DICT: dict[str, Any] = {69    "id": "gen-abc123",70    "choices": [71        {72            "message": {"role": "assistant", "content": "Hello!"},73            "finish_reason": "stop",74            "index": 0,75        }76    ],77    "usage": {78        "prompt_tokens": 10,79        "completion_tokens": 5,80        "total_tokens": 15,81    },82    "model": MODEL_NAME,83    "object": "chat.completion",84    "created": 1700000000.0,85}8687_TOOL_RESPONSE_DICT: dict[str, Any] = {88    "id": "gen-tool123",89    "choices": [90        {91            "message": {92                "role": "assistant",93                "content": None,94                "tool_calls": [95                    {96                        "id": "call_1",97                        "type": "function",98                        "function": {99                            "name": "GetWeather",100                            "arguments": '{"location": "San Francisco"}',101                        },102                    }103                ],104            },105            "finish_reason": "tool_calls",106            "index": 0,107        }108    ],109    "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},110    "model": MODEL_NAME,111    "object": "chat.completion",112    "created": 1700000000.0,113}114115_STREAM_CHUNKS: list[dict[str, Any]] = [116    {117        "choices": [{"delta": {"role": "assistant", "content": ""}, "index": 0}],118        "model": MODEL_NAME,119        "object": "chat.completion.chunk",120        "created": 1700000000.0,121        "id": "gen-stream1",122    },123    {124        "choices": [{"delta": {"content": "Hello"}, "index": 0}],125        "model": MODEL_NAME,126        "object": "chat.completion.chunk",127        "created": 1700000000.0,128        "id": "gen-stream1",129    },130    {131        "choices": [{"delta": {"content": " world"}, "index": 0}],132        "model": MODEL_NAME,133        "object": "chat.completion.chunk",134        "created": 1700000000.0,135        "id": "gen-stream1",136    },137    {138        "choices": [{"delta": {}, "finish_reason": "stop", "index": 0}],139        "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},140        "model": MODEL_NAME,141        "object": "chat.completion.chunk",142        "created": 1700000000.0,143        "id": "gen-stream1",144    },145]146147_DUPLICATE_FINISH_STREAM_CHUNKS: list[dict[str, Any]] = [148    {149        "choices": [{"delta": {"role": "assistant", "content": "Hello"}, "index": 0}],150        "model": MODEL_NAME,151        "object": "chat.completion.chunk",152        "created": 1700000000.0,153        "id": "gen-stream1",154    },155    {156        "choices": [{"delta": {}, "finish_reason": "stop", "index": 0}],157        "model": MODEL_NAME,158        "object": "chat.completion.chunk",159        "created": 1700000000.0,160        "id": "gen-stream1",161    },162    {163        "choices": [164            {165                "delta": {},166                "finish_reason": "stop",167                "native_finish_reason": "end_turn",168                "index": 0,169            }170        ],171        "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},172        "model": MODEL_NAME,173        "object": "chat.completion.chunk",174        "created": 1700000000.0,175        "id": "gen-stream1",176        "system_fingerprint": "fp_duplicate",177    },178]179180181def _make_sdk_response(response_dict: dict[str, Any]) -> MagicMock:182    """Build a MagicMock that behaves like an SDK ChatResponse."""183    mock = MagicMock()184    mock.model_dump.return_value = response_dict185    return mock186187188def _assert_duplicate_finish_result(result: Any) -> None:189    generation = result.generations[0][0]190    assert generation.text == "Hello"191    assert generation.generation_info == {192        "finish_reason": "stop",193        "model_name": MODEL_NAME,194        "id": "gen-stream1",195        "created": 1700000000,196        "object": "chat.completion.chunk",197        "model_provider": "openrouter",198        "system_fingerprint": "fp_duplicate",199        "native_finish_reason": "end_turn",200    }201    assert generation.message.response_metadata == generation.generation_info202    assert generation.message.usage_metadata == {203        "input_tokens": 5,204        "output_tokens": 2,205        "total_tokens": 7,206    }207208209class _MockSyncStream:210    """Synchronous iterator that mimics the SDK EventStream."""211212    def __init__(self, chunks: list[dict[str, Any]]) -> None:213        # Copy so `__next__`'s `pop(0)` never drains a caller-supplied list214        # (e.g. a shared module-level fixture), mirroring `_MockAsyncStream`.215        self._chunks = list(chunks)216217    def __iter__(self) -> _MockSyncStream:218        return self219220    def __next__(self) -> MagicMock:221        if not self._chunks:222            raise StopIteration223        chunk = self._chunks.pop(0)224        mock = MagicMock()225        mock.model_dump.return_value = chunk226        return mock227228229class _MockAsyncStream:230    """Async iterator that mimics the SDK EventStreamAsync."""231232    def __init__(self, chunks: list[dict[str, Any]]) -> None:233        self._chunks = list(chunks)234235    def __aiter__(self) -> _MockAsyncStream:236        return self237238    async def __anext__(self) -> MagicMock:239        if not self._chunks:240            raise StopAsyncIteration241        chunk = self._chunks.pop(0)242        mock = MagicMock()243        mock.model_dump.return_value = chunk244        return mock245246247# ===========================================================================248# Instantiation tests249# ===========================================================================250251252class TestChatOpenRouterInstantiation:253    """Tests for `ChatOpenRouter` instantiation."""254255    def test_basic_instantiation(self) -> None:256        """Test basic model instantiation with required params."""257        model = _make_model()258        assert model.model_name == MODEL_NAME259        assert model.model == MODEL_NAME260        assert model.openrouter_api_base is None261262    def test_api_key_from_field(self) -> None:263        """Test that API key is properly set."""264        model = _make_model()265        assert model.openrouter_api_key is not None266        assert model.openrouter_api_key.get_secret_value() == "test-key"267268    def test_api_key_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:269        """Test that API key is read from OPENROUTER_API_KEY env var."""270        monkeypatch.setenv("OPENROUTER_API_KEY", "env-key-123")271        model = ChatOpenRouter(model=MODEL_NAME)272        assert model.openrouter_api_key is not None273        assert model.openrouter_api_key.get_secret_value() == "env-key-123"274275    def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:276        """Test that missing API key raises ValueError."""277        monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)278        with pytest.raises(ValueError, match="OPENROUTER_API_KEY must be set"):279            ChatOpenRouter(model=MODEL_NAME)280281    def test_model_required(self) -> None:282        """Test that model name is required."""283        with pytest.raises((ValueError, TypeError)):284            ChatOpenRouter(api_key=SecretStr("test-key"))  # type: ignore[call-arg]285286    def test_secret_masking(self) -> None:287        """Test that API key is not exposed in string representation."""288        model = _make_model(api_key=SecretStr("super-secret"))289        model_str = str(model)290        assert "super-secret" not in model_str291292    def test_secret_masking_repr(self) -> None:293        """Test that API key is masked in repr too."""294        model = _make_model(api_key=SecretStr("super-secret"))295        assert "super-secret" not in repr(model)296297    def test_api_key_is_secret_str(self) -> None:298        """Test that openrouter_api_key is a SecretStr instance."""299        model = _make_model()300        assert isinstance(model.openrouter_api_key, SecretStr)301302    def test_llm_type(self) -> None:303        """Test _llm_type property."""304        model = _make_model()305        assert model._llm_type == "openrouter-chat"306307    def test_ls_params(self) -> None:308        """Test LangSmith params include openrouter provider."""309        model = _make_model()310        ls_params = model._get_ls_params()311        assert ls_params["ls_provider"] == "openrouter"312313    def test_ls_params_includes_max_tokens(self) -> None:314        """Test that ls_max_tokens is set when max_tokens is configured."""315        model = _make_model(max_tokens=512)316        ls_params = model._get_ls_params()317        assert ls_params["ls_max_tokens"] == 512318319    def test_ls_params_stop_string_wrapped_in_list(self) -> None:320        """Test that a string stop value is wrapped in a list for ls_stop."""321        model = _make_model(stop_sequences="END")322        ls_params = model._get_ls_params()323        assert ls_params["ls_stop"] == ["END"]324325    def test_ls_params_stop_list_passthrough(self) -> None:326        """Test that a list stop value is passed through directly."""327        model = _make_model(stop_sequences=["END", "STOP"])328        ls_params = model._get_ls_params()329        assert ls_params["ls_stop"] == ["END", "STOP"]330331    def test_metadata_versions(self) -> None:332        """Test that metadata reports the correct version info."""333        model = _make_model()334        assert model.metadata is not None335        versions = model.metadata["lc_versions"]336        assert "langchain-core" in versions337        assert "langchain-openrouter" in versions338339    def test_client_created(self) -> None:340        """Test that OpenRouter SDK client is created."""341        model = _make_model()342        assert model.client is not None343344    def test_client_reused_for_same_params(self) -> None:345        """Test that the SDK client is reused when model is re-validated."""346        model = _make_model()347        client_1 = model.client348        # Re-validate does not replace the existing client349        model.validate_environment()  # type: ignore[operator]350        assert model.client is client_1351352    def test_app_url_passed_to_client(self) -> None:353        """Test that app_url is passed as HTTP-Referer header via httpx clients."""354        with patch("openrouter.OpenRouter") as mock_cls:355            mock_cls.return_value = MagicMock()356            ChatOpenRouter(357                model=MODEL_NAME,358                api_key=SecretStr("test-key"),359                app_url="https://myapp.com",360            )361            call_kwargs = mock_cls.call_args[1]362            assert call_kwargs["client"].headers["HTTP-Referer"] == "https://myapp.com"363364    def test_app_title_passed_to_client(self) -> None:365        """Test that app_title is passed as X-Title header via httpx clients."""366        with patch("openrouter.OpenRouter") as mock_cls:367            mock_cls.return_value = MagicMock()368            ChatOpenRouter(369                model=MODEL_NAME,370                api_key=SecretStr("test-key"),371                app_title="My App",372            )373            call_kwargs = mock_cls.call_args[1]374            assert call_kwargs["client"].headers["X-Title"] == "My App"375376    def test_default_attribution_headers(self) -> None:377        """Test that default attribution headers are sent when not overridden."""378        with patch("openrouter.OpenRouter") as mock_cls:379            mock_cls.return_value = MagicMock()380            ChatOpenRouter(381                model=MODEL_NAME,382                api_key=SecretStr("test-key"),383            )384            call_kwargs = mock_cls.call_args[1]385            sync_headers = call_kwargs["client"].headers386            assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"387            assert sync_headers["X-Title"] == "LangChain"388389    def test_user_attribution_overrides_defaults(self) -> None:390        """Test that user-supplied attribution overrides the defaults."""391        with patch("openrouter.OpenRouter") as mock_cls:392            mock_cls.return_value = MagicMock()393            ChatOpenRouter(394                model=MODEL_NAME,395                api_key=SecretStr("test-key"),396                app_url="https://my-custom-app.com",397                app_title="My Custom App",398            )399            call_kwargs = mock_cls.call_args[1]400            sync_headers = call_kwargs["client"].headers401            assert sync_headers["HTTP-Referer"] == "https://my-custom-app.com"402            assert sync_headers["X-Title"] == "My Custom App"403404    def test_app_categories_passed_to_client(self) -> None:405        """Test that app_categories injects custom httpx clients with header."""406        with patch("openrouter.OpenRouter") as mock_cls:407            mock_cls.return_value = MagicMock()408            ChatOpenRouter(409                model=MODEL_NAME,410                api_key=SecretStr("test-key"),411                app_categories=["cli-agent", "programming-app"],412            )413            call_kwargs = mock_cls.call_args[1]414            # Custom httpx clients should be created415            assert "client" in call_kwargs416            assert "async_client" in call_kwargs417            # Verify the header value is comma-joined418            sync_headers = call_kwargs["client"].headers419            assert sync_headers["X-OpenRouter-Categories"] == (420                "cli-agent,programming-app"421            )422            async_headers = call_kwargs["async_client"].headers423            assert async_headers["X-OpenRouter-Categories"] == (424                "cli-agent,programming-app"425            )426427    def test_app_categories_none_no_categories_header(self) -> None:428        """Test that no X-OpenRouter-Categories header when categories unset."""429        with patch("openrouter.OpenRouter") as mock_cls:430            mock_cls.return_value = MagicMock()431            ChatOpenRouter(432                model=MODEL_NAME,433                api_key=SecretStr("test-key"),434            )435            call_kwargs = mock_cls.call_args[1]436            # httpx clients still created for X-Title default437            sync_headers = call_kwargs["client"].headers438            assert "X-OpenRouter-Categories" not in sync_headers439440    def test_app_categories_empty_list_no_categories_header(self) -> None:441        """Test that an empty list does not inject categories header."""442        with patch("openrouter.OpenRouter") as mock_cls:443            mock_cls.return_value = MagicMock()444            ChatOpenRouter(445                model=MODEL_NAME,446                api_key=SecretStr("test-key"),447                app_categories=[],448            )449            call_kwargs = mock_cls.call_args[1]450            sync_headers = call_kwargs["client"].headers451            assert "X-OpenRouter-Categories" not in sync_headers452453    def test_app_categories_with_other_attribution(self) -> None:454        """Test that app_categories coexists with app_url and app_title."""455        with patch("openrouter.OpenRouter") as mock_cls:456            mock_cls.return_value = MagicMock()457            ChatOpenRouter(458                model=MODEL_NAME,459                api_key=SecretStr("test-key"),460                app_url="https://myapp.com",461                app_title="My App",462                app_categories=["cli-agent"],463            )464            call_kwargs = mock_cls.call_args[1]465            sync_headers = call_kwargs["client"].headers466            assert sync_headers["HTTP-Referer"] == "https://myapp.com"467            assert sync_headers["X-Title"] == "My App"468            assert sync_headers["X-OpenRouter-Categories"] == "cli-agent"469470    def test_app_title_none_no_x_title_header(self) -> None:471        """Test that X-Title header is omitted when app_title is explicitly None."""472        with patch("openrouter.OpenRouter") as mock_cls:473            mock_cls.return_value = MagicMock()474            ChatOpenRouter(475                model=MODEL_NAME,476                api_key=SecretStr("test-key"),477                app_title=None,478            )479            call_kwargs = mock_cls.call_args[1]480            sync_headers = call_kwargs["client"].headers481            assert "X-Title" not in sync_headers482483    def test_app_url_none_no_referer_header(self) -> None:484        """Test that HTTP-Referer header is omitted when app_url is explicitly None."""485        with patch("openrouter.OpenRouter") as mock_cls:486            mock_cls.return_value = MagicMock()487            ChatOpenRouter(488                model=MODEL_NAME,489                api_key=SecretStr("test-key"),490                app_url=None,491            )492            call_kwargs = mock_cls.call_args[1]493            sync_headers = call_kwargs["client"].headers494            assert "HTTP-Referer" not in sync_headers495496    def test_no_attribution_no_custom_clients(self) -> None:497        """Test that no httpx clients are created when all attribution is None."""498        with patch("openrouter.OpenRouter") as mock_cls:499            mock_cls.return_value = MagicMock()500            ChatOpenRouter(501                model=MODEL_NAME,502                api_key=SecretStr("test-key"),503                app_url=None,504                app_title=None,505                app_categories=None,506            )507            call_kwargs = mock_cls.call_args[1]508            assert "client" not in call_kwargs509            assert "async_client" not in call_kwargs510511    def test_default_headers_passed_to_client(self) -> None:512        """Test that `default_headers` are forwarded to the httpx clients.513514        Before this field existed, setting `default_headers` had no effect on515        the HTTP layer: `build_extra` diverted the unrecognized parameter into516        `model_kwargs` (with a "not default parameter" warning), so the header517        never reached the outbound request.518        """519        with patch("openrouter.OpenRouter") as mock_cls:520            mock_cls.return_value = MagicMock()521            ChatOpenRouter(522                model=MODEL_NAME,523                api_key=SecretStr("test-key"),524                default_headers={"x-grok-conv-id": "session-abc-123"},525            )526            call_kwargs = mock_cls.call_args[1]527            # Custom httpx clients are created and the header is set on both.528            assert "client" in call_kwargs529            assert "async_client" in call_kwargs530            sync_headers = call_kwargs["client"].headers531            assert sync_headers["x-grok-conv-id"] == "session-abc-123"532            async_headers = call_kwargs["async_client"].headers533            assert async_headers["x-grok-conv-id"] == "session-abc-123"534535    def test_default_headers_coexist_with_app_attribution(self) -> None:536        """Test that `default_headers` merges with built-in attribution headers."""537        with patch("openrouter.OpenRouter") as mock_cls:538            mock_cls.return_value = MagicMock()539            ChatOpenRouter(540                model=MODEL_NAME,541                api_key=SecretStr("test-key"),542                app_url="https://myapp.com",543                app_title="My App",544                default_headers={545                    "x-grok-conv-id": "session-xyz",546                    "x-custom-trace-id": "trace-001",547                },548            )549            call_kwargs = mock_cls.call_args[1]550            sync_headers = call_kwargs["client"].headers551            # Built-in attribution preserved552            assert sync_headers["HTTP-Referer"] == "https://myapp.com"553            assert sync_headers["X-Title"] == "My App"554            # User-supplied headers also present555            assert sync_headers["x-grok-conv-id"] == "session-xyz"556            assert sync_headers["x-custom-trace-id"] == "trace-001"557558    def test_default_headers_override_app_attribution(self) -> None:559        """Test that `default_headers` takes precedence over colliding built-in keys."""560        with patch("openrouter.OpenRouter") as mock_cls:561            mock_cls.return_value = MagicMock()562            ChatOpenRouter(563                model=MODEL_NAME,564                api_key=SecretStr("test-key"),565                app_title="Default Title",566                default_headers={"X-Title": "Override Title"},567            )568            call_kwargs = mock_cls.call_args[1]569            sync_headers = call_kwargs["client"].headers570            # default_headers wins over the built-in app_title-derived value571            assert sync_headers["X-Title"] == "Override Title"572573    def test_default_headers_none_no_custom_headers(self) -> None:574        """Test that `default_headers=None` doesn't interfere with default behavior."""575        with patch("openrouter.OpenRouter") as mock_cls:576            mock_cls.return_value = MagicMock()577            ChatOpenRouter(578                model=MODEL_NAME,579                api_key=SecretStr("test-key"),580                default_headers=None,581            )582            call_kwargs = mock_cls.call_args[1]583            # Default app-attribution headers still present584            sync_headers = call_kwargs["client"].headers585            assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"586            assert sync_headers["X-Title"] == "LangChain"587            # No spurious extra headers588            assert "x-grok-conv-id" not in sync_headers589590    def test_default_headers_sole_source_creates_client(self) -> None:591        """`default_headers` alone (no app attribution) still creates httpx clients.592593        Guards against a regression where the `if extra_headers:` check runs594        before `default_headers` is merged in  the header would then be595        dropped and no custom client created.596        """597        with patch("openrouter.OpenRouter") as mock_cls:598            mock_cls.return_value = MagicMock()599            ChatOpenRouter(600                model=MODEL_NAME,601                api_key=SecretStr("test-key"),602                app_url=None,603                app_title=None,604                app_categories=None,605                default_headers={"x-grok-conv-id": "session-solo"},606            )607            call_kwargs = mock_cls.call_args[1]608            assert "client" in call_kwargs609            assert "async_client" in call_kwargs610            assert call_kwargs["client"].headers["x-grok-conv-id"] == "session-solo"611            assert (612                call_kwargs["async_client"].headers["x-grok-conv-id"] == "session-solo"613            )614615    def test_default_headers_override_app_categories(self) -> None:616        """`default_headers` can override the built-in `X-OpenRouter-Categories`."""617        with patch("openrouter.OpenRouter") as mock_cls:618            mock_cls.return_value = MagicMock()619            ChatOpenRouter(620                model=MODEL_NAME,621                api_key=SecretStr("test-key"),622                app_categories=["programming", "translation"],623                default_headers={"X-OpenRouter-Categories": "custom-category"},624            )625            call_kwargs = mock_cls.call_args[1]626            sync_headers = call_kwargs["client"].headers627            assert sync_headers["X-OpenRouter-Categories"] == "custom-category"628629    def test_default_headers_empty_dict_no_custom_client(self) -> None:630        """An empty `default_headers` dict behaves like `None` (no custom client)."""631        with patch("openrouter.OpenRouter") as mock_cls:632            mock_cls.return_value = MagicMock()633            ChatOpenRouter(634                model=MODEL_NAME,635                api_key=SecretStr("test-key"),636                app_url=None,637                app_title=None,638                app_categories=None,639                default_headers={},640            )641            call_kwargs = mock_cls.call_args[1]642            assert "client" not in call_kwargs643            assert "async_client" not in call_kwargs644645    def test_default_headers_override_is_case_insensitive(self) -> None:646        """A case-variant user header overrides the built-in, not doubles it.647648        HTTP header names are case-insensitive, so `default_headers` keyed with649        different casing than a built-in attribution header (`http-referer` vs650        `HTTP-Referer`) must replace it rather than send both values.651        """652        with patch("openrouter.OpenRouter") as mock_cls:653            mock_cls.return_value = MagicMock()654            ChatOpenRouter(655                model=MODEL_NAME,656                api_key=SecretStr("test-key"),657                app_url="https://builtin.example",658                default_headers={"http-referer": "https://override.example"},659            )660            sync_headers = mock_cls.call_args[1]["client"].headers661            # httpx headers are case-insensitive: the override value wins under662            # either spelling, with no comma-joined doubling.663            assert sync_headers["HTTP-Referer"] == "https://override.example"664            assert sync_headers["http-referer"] == "https://override.example"665            assert "," not in sync_headers["HTTP-Referer"]666667    def test_default_headers_not_swept_into_model_kwargs(self) -> None:668        """`default_headers` is a first-class field, not extra `model_kwargs`.669670        Guards the motivating regression: `build_extra` must recognize671        `default_headers`, leave it out of `model_kwargs`, and not emit the672        "not default parameter" warning that unrecognized kwargs trigger.673        """674        with warnings.catch_warnings(record=True) as caught:675            warnings.simplefilter("always")676            model = ChatOpenRouter(677                model=MODEL_NAME,678                api_key=SecretStr("test-key"),679                default_headers={"x-grok-conv-id": "session-abc-123"},680            )681        assert model.model_kwargs == {}682        assert not any("not default parameter" in str(w.message) for w in caught)683684    def test_reasoning_in_params(self) -> None:685        """Test that `reasoning` is included in default params."""686        model = _make_model(reasoning={"effort": "high"})687        params = model._default_params688        assert params["reasoning"] == {"effort": "high"}689690    def test_openrouter_provider_in_params(self) -> None:691        """Test that `openrouter_provider` is included in default params."""692        model = _make_model(openrouter_provider={"order": ["Anthropic"]})693        params = model._default_params694        assert params["provider"] == {"order": ["Anthropic"]}695696    def test_route_in_params(self) -> None:697        """Test that `route` is included in default params."""698        model = _make_model(route="fallback")699        params = model._default_params700        assert params["route"] == "fallback"701702    def test_optional_params_excluded_when_none(self) -> None:703        """Test that None optional params are not in default params."""704        model = _make_model()705        params = model._default_params706        assert "temperature" not in params707        assert "max_tokens" not in params708        assert "top_p" not in params709        assert "reasoning" not in params710711    def test_temperature_included_when_set(self) -> None:712        """Test that temperature is included when explicitly set."""713        model = _make_model(temperature=0.5)714        params = model._default_params715        assert params["temperature"] == 0.5716717718# ===========================================================================719# Serialization tests720# ===========================================================================721722723class TestSerialization:724    """Tests for serialization round-trips."""725726    def test_is_lc_serializable(self) -> None:727        """Test that ChatOpenRouter declares itself as serializable."""728        assert ChatOpenRouter.is_lc_serializable() is True729730    @pytest.mark.filterwarnings("ignore:The function `load` is in beta")731    def test_dumpd_load_roundtrip(self) -> None:732        """Test that dumpd/load round-trip preserves model config."""733        model = _make_model(temperature=0.7, max_tokens=100)734        serialized = dumpd(model)735        deserialized = load(736            serialized,737            valid_namespaces=["langchain_openrouter"],738            allowed_objects="all",739            secrets_from_env=False,740            secrets_map={"OPENROUTER_API_KEY": "test-key"},741        )742        assert isinstance(deserialized, ChatOpenRouter)743        assert deserialized.model_name == MODEL_NAME744        assert deserialized.temperature == 0.7745        assert deserialized.max_tokens == 100746747    def test_dumps_does_not_leak_secrets(self) -> None:748        """Test that dumps output does not contain the raw API key."""749        model = _make_model(api_key=SecretStr("super-secret-key"))750        serialized = dumps(model)751        assert "super-secret-key" not in serialized752753    def test_dumpd_excludes_default_headers(self) -> None:754        """Test that default_headers are excluded from serialized kwargs."""755        model = _make_model(756            default_headers={757                "Authorization": "Bearer provider-token",758                "x-grok-conv-id": "session-abc-123",759            }760        )761762        serialized = dumpd(model)763764        assert "default_headers" not in serialized["kwargs"]765766    def test_dumps_does_not_leak_default_headers(self) -> None:767        """Test that dumps output does not contain default header values."""768        model = _make_model(769            default_headers={770                "Authorization": "Bearer provider-token",771                "x-grok-conv-id": "session-abc-123",772            }773        )774775        serialized = dumps(model)776777        assert "provider-token" not in serialized778        assert "session-abc-123" not in serialized779780781# ===========================================================================782# Mocked generate / stream tests783# ===========================================================================784785786class TestMockedGenerate:787    """Tests for _generate / _agenerate with a mocked SDK client."""788789    def test_invoke_basic(self) -> None:790        """Test basic invoke returns an AIMessage via mocked SDK."""791        model = _make_model()792        model.client = MagicMock()793        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)794795        result = model.invoke("Hello")796        assert isinstance(result, AIMessage)797        assert result.content == "Hello!"798        model.client.chat.send.assert_called_once()799800    def test_invoke_with_tool_response(self) -> None:801        """Test invoke that returns tool calls."""802        model = _make_model()803        model.client = MagicMock()804        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)805806        result = model.invoke("What's the weather?")807        assert isinstance(result, AIMessage)808        assert len(result.tool_calls) == 1809        assert result.tool_calls[0]["name"] == "GetWeather"810811    def test_invoke_passes_correct_messages(self) -> None:812        """Test that invoke converts messages and passes them to the SDK."""813        model = _make_model()814        model.client = MagicMock()815        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)816817        model.invoke([HumanMessage(content="Hi")])818        call_kwargs = model.client.chat.send.call_args[1]819        assert call_kwargs["messages"] == [{"role": "user", "content": "Hi"}]820821    def test_invoke_strips_internal_kwargs(self) -> None:822        """Test that LangChain-internal kwargs are stripped before SDK call."""823        model = _make_model()824        model.client = MagicMock()825        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)826827        model._generate(828            [HumanMessage(content="Hi")],829            ls_structured_output_format={"kwargs": {"method": "function_calling"}},830        )831        call_kwargs = model.client.chat.send.call_args[1]832        assert "ls_structured_output_format" not in call_kwargs833834    def test_invoke_usage_metadata(self) -> None:835        """Test that usage metadata is populated on the response."""836        model = _make_model()837        model.client = MagicMock()838        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)839840        result = model.invoke("Hello")841        assert isinstance(result, AIMessage)842        assert result.usage_metadata is not None843        assert result.usage_metadata["input_tokens"] == 10844        assert result.usage_metadata["output_tokens"] == 5845        assert result.usage_metadata["total_tokens"] == 15846847    def test_stream_basic(self) -> None:848        """Test streaming returns AIMessageChunks via mocked SDK."""849        model = _make_model()850        model.client = MagicMock()851        model.client.chat.send.return_value = _MockSyncStream(852            [dict(c) for c in _STREAM_CHUNKS]853        )854855        chunks = list(model.stream("Hello"))856        assert len(chunks) > 0857        assert all(isinstance(c, AIMessageChunk) for c in chunks)858        # Concatenated content should be "Hello world"859        full_content = "".join(c.content for c in chunks if isinstance(c.content, str))860        assert "Hello" in full_content861        assert "world" in full_content862863    def test_stream_passes_stream_true(self) -> None:864        """Test that stream sends stream=True to the SDK."""865        model = _make_model()866        model.client = MagicMock()867        model.client.chat.send.return_value = _MockSyncStream(868            [dict(c) for c in _STREAM_CHUNKS]869        )870871        list(model.stream("Hello"))872        call_kwargs = model.client.chat.send.call_args[1]873        assert call_kwargs["stream"] is True874875    def test_invoke_with_streaming_flag(self) -> None:876        """Test that invoke delegates to stream when streaming=True."""877        model = _make_model(streaming=True)878        model.client = MagicMock()879        model.client.chat.send.return_value = _MockSyncStream(880            [dict(c) for c in _STREAM_CHUNKS]881        )882883        result = model.invoke("Hello")884        assert isinstance(result, AIMessage)885        call_kwargs = model.client.chat.send.call_args[1]886        assert call_kwargs["stream"] is True887888    async def test_ainvoke_basic(self) -> None:889        """Test async invoke returns an AIMessage via mocked SDK."""890        model = _make_model()891        model.client = MagicMock()892        model.client.chat.send_async = AsyncMock(893            return_value=_make_sdk_response(_SIMPLE_RESPONSE_DICT)894        )895896        result = await model.ainvoke("Hello")897        assert isinstance(result, AIMessage)898        assert result.content == "Hello!"899        model.client.chat.send_async.assert_awaited_once()900901    async def test_astream_basic(self) -> None:902        """Test async streaming returns AIMessageChunks via mocked SDK."""903        model = _make_model()904        model.client = MagicMock()905        model.client.chat.send_async = AsyncMock(906            return_value=_MockAsyncStream(_STREAM_CHUNKS)907        )908909        chunks = [c async for c in model.astream("Hello")]910        assert len(chunks) > 0911        assert all(isinstance(c, AIMessageChunk) for c in chunks)912913    def test_stream_response_metadata_fields(self) -> None:914        """Test response-level metadata in streaming response_metadata."""915        model = _make_model()916        model.client = MagicMock()917        stream_chunks: list[dict[str, Any]] = [918            {919                "choices": [920                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}921                ],922                "model": "anthropic/claude-sonnet-4-5",923                "system_fingerprint": "fp_stream123",924                "object": "chat.completion.chunk",925                "created": 1700000000.0,926                "id": "gen-stream-meta",927            },928            {929                "choices": [930                    {931                        "delta": {},932                        "finish_reason": "stop",933                        "native_finish_reason": "end_turn",934                        "index": 0,935                    }936                ],937                "model": "anthropic/claude-sonnet-4-5",938                "system_fingerprint": "fp_stream123",939                "object": "chat.completion.chunk",940                "created": 1700000000.0,941                "id": "gen-stream-meta",942            },943        ]944        model.client.chat.send.return_value = _MockSyncStream(stream_chunks)945946        chunks = list(model.stream("Hello"))947        assert len(chunks) >= 2948949        # Find the chunk with finish_reason (final metadata chunk)950        final = [951            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"952        ]953        assert len(final) == 1954        meta = final[0].response_metadata955        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"956        assert meta["system_fingerprint"] == "fp_stream123"957        assert meta["native_finish_reason"] == "end_turn"958        assert meta["finish_reason"] == "stop"959        assert meta["id"] == "gen-stream-meta"960        assert meta["created"] == 1700000000961        assert meta["object"] == "chat.completion.chunk"962963    async def test_astream_response_metadata_fields(self) -> None:964        """Test response-level metadata in async streaming response_metadata."""965        model = _make_model()966        model.client = MagicMock()967        stream_chunks: list[dict[str, Any]] = [968            {969                "choices": [970                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}971                ],972                "model": "anthropic/claude-sonnet-4-5",973                "system_fingerprint": "fp_async123",974                "object": "chat.completion.chunk",975                "created": 1700000000.0,976                "id": "gen-astream-meta",977            },978            {979                "choices": [980                    {981                        "delta": {},982                        "finish_reason": "stop",983                        "native_finish_reason": "end_turn",984                        "index": 0,985                    }986                ],987                "model": "anthropic/claude-sonnet-4-5",988                "system_fingerprint": "fp_async123",989                "object": "chat.completion.chunk",990                "created": 1700000000.0,991                "id": "gen-astream-meta",992            },993        ]994        model.client.chat.send_async = AsyncMock(995            return_value=_MockAsyncStream(stream_chunks)996        )997998        chunks = [c async for c in model.astream("Hello")]999        assert len(chunks) >= 210001001        # Find the chunk with finish_reason (final metadata chunk)1002        final = [1003            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"1004        ]1005        assert len(final) == 11006        meta = final[0].response_metadata1007        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"1008        assert meta["system_fingerprint"] == "fp_async123"1009        assert meta["native_finish_reason"] == "end_turn"1010        assert meta["id"] == "gen-astream-meta"1011        assert meta["created"] == 17000000001012        assert meta["object"] == "chat.completion.chunk"101310141015# ===========================================================================1016# Request payload verification1017# ===========================================================================101810191020class TestRequestPayload:1021    """Tests verifying the exact dict sent to the SDK."""10221023    @pytest.fixture(autouse=True)1024    def _clear_openrouter_env(self, monkeypatch: pytest.MonkeyPatch) -> None:1025        """Clear env vars that would otherwise leak into tests via `from_env`."""1026        monkeypatch.delenv("OPENROUTER_SESSION_ID", raising=False)10271028    def test_message_format_in_payload(self) -> None:1029        """Test that messages are formatted correctly in the SDK call."""1030        model = _make_model(temperature=0)1031        model.client = MagicMock()1032        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)10331034        model.invoke(1035            [1036                SystemMessage(content="You are helpful."),1037                HumanMessage(content="Hi"),1038            ]1039        )1040        call_kwargs = model.client.chat.send.call_args[1]1041        assert call_kwargs["messages"] == [1042            {"role": "system", "content": "You are helpful."},1043            {"role": "user", "content": "Hi"},1044        ]10451046    def test_model_kwargs_forwarded(self) -> None:1047        """Test that extra model_kwargs are included in the SDK call."""1048        model = _make_model(model_kwargs={"top_k": 50})1049        model.client = MagicMock()1050        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)10511052        model.invoke("Hi")1053        call_kwargs = model.client.chat.send.call_args[1]1054        assert call_kwargs["top_k"] == 5010551056    def test_stop_sequences_in_payload(self) -> None:1057        """Test that stop sequences are passed to the SDK."""1058        model = _make_model()1059        model.client = MagicMock()1060        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)10611062        model.invoke("Hi", stop=["END"])1063        call_kwargs = model.client.chat.send.call_args[1]1064        assert call_kwargs["stop"] == ["END"]10651066    def test_tool_format_in_payload(self) -> None:1067        """Test that tools are formatted in OpenAI-compatible structure."""1068        model = _make_model()1069        model.client = MagicMock()1070        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)10711072        bound = model.bind_tools([GetWeather])1073        bound.invoke("What's the weather?")1074        call_kwargs = model.client.chat.send.call_args[1]1075        tools = call_kwargs["tools"]1076        assert len(tools) == 11077        assert tools[0]["type"] == "function"1078        assert tools[0]["function"]["name"] == "GetWeather"1079        assert "parameters" in tools[0]["function"]10801081    def test_tool_cache_control_preserved_in_payload(self) -> None:1082        """Test that top-level `cache_control` on a tool dict is preserved."""1083        model = _make_model()1084        model.client = MagicMock()1085        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)10861087        tool = {1088            "type": "function",1089            "function": {1090                "name": "GetWeather",1091                "description": "Get the weather.",1092                "parameters": {"type": "object", "properties": {}},1093            },1094            "cache_control": {"type": "ephemeral"},1095        }1096        bound = model.bind_tools([tool])1097        bound.invoke("What's the weather?")1098        call_kwargs = model.client.chat.send.call_args[1]1099        tools = call_kwargs["tools"]1100        assert len(tools) == 11101        assert tools[0]["cache_control"] == {"type": "ephemeral"}11021103    def test_openrouter_params_in_payload(self) -> None:1104        """Test that OpenRouter-specific params appear in the SDK call."""1105        model = _make_model(1106            reasoning={"effort": "high"},1107            openrouter_provider={"order": ["Anthropic"]},1108            route="fallback",1109        )1110        model.client = MagicMock()1111        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11121113        model.invoke("Hi")1114        call_kwargs = model.client.chat.send.call_args[1]1115        assert call_kwargs["reasoning"] == {"effort": "high"}1116        assert call_kwargs["provider"] == {"order": ["Anthropic"]}1117        assert call_kwargs["route"] == "fallback"11181119    def test_session_id_and_trace_in_payload(self) -> None:1120        """Test that session_id and trace are forwarded to the SDK."""1121        model = _make_model(1122            session_id="session-abc",1123            trace={"trace_id": "trace-1", "span_name": "summarize"},1124        )1125        model.client = MagicMock()1126        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11271128        model.invoke("Hi")1129        call_kwargs = model.client.chat.send.call_args[1]1130        assert call_kwargs["session_id"] == "session-abc"1131        assert call_kwargs["trace"] == {1132            "trace_id": "trace-1",1133            "span_name": "summarize",1134        }11351136    def test_session_id_and_trace_omitted_when_unset(self) -> None:1137        """Test that session_id and trace are omitted when not configured."""1138        model = _make_model()1139        model.client = MagicMock()1140        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11411142        model.invoke("Hi")1143        call_kwargs = model.client.chat.send.call_args[1]1144        assert "session_id" not in call_kwargs1145        assert "trace" not in call_kwargs11461147    def test_session_id_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:1148        """Test that session_id falls back to OPENROUTER_SESSION_ID env var."""1149        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session-xyz")1150        model = _make_model()1151        assert model.session_id == "env-session-xyz"11521153        model.client = MagicMock()1154        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)1155        model.invoke("Hi")1156        call_kwargs = model.client.chat.send.call_args[1]1157        assert call_kwargs["session_id"] == "env-session-xyz"11581159    def test_session_id_constructor_overrides_env(1160        self, monkeypatch: pytest.MonkeyPatch1161    ) -> None:1162        """Test that an explicit session_id wins over the env var."""1163        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session")1164        model = _make_model(session_id="explicit-session")1165        assert model.session_id == "explicit-session"11661167        model.client = MagicMock()1168        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)1169        model.invoke("Hi")1170        call_kwargs = model.client.chat.send.call_args[1]1171        assert call_kwargs["session_id"] == "explicit-session"11721173    def test_session_id_per_call_override(self) -> None:1174        """Test that a per-call session_id kwarg overrides the constructor value."""1175        model = _make_model(session_id="constructor-session")1176        model.client = MagicMock()1177        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11781179        model.invoke("Hi", session_id="call-session")1180        first_call_kwargs = model.client.chat.send.call_args[1]1181        assert first_call_kwargs["session_id"] == "call-session"11821183        # Per-call override must not mutate the constructor value, and the next1184        # call without the kwarg should fall back to the constructor's value.1185        assert model.session_id == "constructor-session"1186        model.invoke("Hi")1187        second_call_kwargs = model.client.chat.send.call_args[1]1188        assert second_call_kwargs["session_id"] == "constructor-session"11891190    def test_trace_per_call_override(self) -> None:1191        """Test that a per-call trace kwarg overrides the constructor value."""1192        constructor_trace = {"trace_id": "constructor-trace"}1193        call_trace = {"trace_id": "call-trace", "span_name": "summarize"}1194        model = _make_model(trace=constructor_trace)1195        model.client = MagicMock()1196        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11971198        model.invoke("Hi", trace=call_trace)1199        first_call_kwargs = model.client.chat.send.call_args[1]1200        assert first_call_kwargs["trace"] == call_trace12011202        assert model.trace == constructor_trace1203        model.invoke("Hi")1204        second_call_kwargs = model.client.chat.send.call_args[1]1205        assert second_call_kwargs["trace"] == constructor_trace12061207    def test_empty_session_id_treated_as_unset(1208        self, monkeypatch: pytest.MonkeyPatch1209    ) -> None:1210        """Test that empty `session_id` (constructor or env) is not forwarded."""1211        # Explicit empty string on the constructor.1212        model = _make_model(session_id="")1213        model.client = MagicMock()1214        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)1215        model.invoke("Hi")1216        assert "session_id" not in model.client.chat.send.call_args[1]12171218        # Empty string sourced from the env var.1219        monkeypatch.setenv("OPENROUTER_SESSION_ID", "")1220        env_model = _make_model()1221        env_model.client = MagicMock()1222        env_model.client.chat.send.return_value = _make_sdk_response(1223            _SIMPLE_RESPONSE_DICT1224        )1225        env_model.invoke("Hi")1226        assert "session_id" not in env_model.client.chat.send.call_args[1]122712281229# ===========================================================================1230# bind_tools tests1231# ===========================================================================123212331234class TestBindTools:1235    """Tests for the bind_tools public method."""12361237    @pytest.mark.parametrize(1238        "tool_choice",1239        [1240            "auto",1241            "none",1242            "required",1243            "GetWeather",1244            {"type": "function", "function": {"name": "GetWeather"}},1245            None,1246        ],1247    )1248    def test_bind_tools_tool_choice(self, tool_choice: Any) -> None:1249        """Test bind_tools accepts various tool_choice values."""1250        model = _make_model()1251        bound = model.bind_tools(1252            [GetWeather, GenerateUsername], tool_choice=tool_choice1253        )1254        assert isinstance(bound, RunnableBinding)12551256    def test_bind_tools_bool_true_single_tool(self) -> None:1257        """Test bind_tools with tool_choice=True and a single tool."""1258        model = _make_model()1259        bound = model.bind_tools([GetWeather], tool_choice=True)1260        assert isinstance(bound, RunnableBinding)1261        kwargs = bound.kwargs1262        assert kwargs["tool_choice"] == {1263            "type": "function",1264            "function": {"name": "GetWeather"},1265        }12661267    def test_bind_tools_bool_true_multiple_tools_raises(self) -> None:1268        """Test bind_tools with tool_choice=True and multiple tools raises."""1269        model = _make_model()1270        with pytest.raises(ValueError, match="tool_choice can only be True"):1271            model.bind_tools([GetWeather, GenerateUsername], tool_choice=True)12721273    def test_bind_tools_any_maps_to_required(self) -> None:1274        """Test that tool_choice='any' is mapped to 'required'."""1275        model = _make_model()1276        bound = model.bind_tools([GetWeather], tool_choice="any")1277        assert isinstance(bound, RunnableBinding)1278        assert bound.kwargs["tool_choice"] == "required"12791280    def test_bind_tools_string_name_becomes_dict(self) -> None:1281        """Test that a specific tool name string is converted to a dict."""1282        model = _make_model()1283        bound = model.bind_tools([GetWeather], tool_choice="GetWeather")1284        assert isinstance(bound, RunnableBinding)1285        assert bound.kwargs["tool_choice"] == {1286            "type": "function",1287            "function": {"name": "GetWeather"},1288        }12891290    def test_bind_tools_formats_tools_correctly(self) -> None:1291        """Test that tools are converted to OpenAI format."""1292        model = _make_model()1293        bound = model.bind_tools([GetWeather])1294        assert isinstance(bound, RunnableBinding)1295        tools = bound.kwargs["tools"]1296        assert len(tools) == 11297        assert tools[0]["type"] == "function"1298        assert tools[0]["function"]["name"] == "GetWeather"12991300    def test_bind_tools_no_choice_omits_key(self) -> None:1301        """Test that tool_choice=None does not set tool_choice in kwargs."""1302        model = _make_model()1303        bound = model.bind_tools([GetWeather], tool_choice=None)1304        assert isinstance(bound, RunnableBinding)1305        assert "tool_choice" not in bound.kwargs13061307    def test_bind_tools_strict_forwarded(self) -> None:1308        """Test that strict param is forwarded to tool definitions."""1309        model = _make_model()1310        bound = model.bind_tools([GetWeather], strict=True)1311        assert isinstance(bound, RunnableBinding)1312        tools = bound.kwargs["tools"]1313        assert tools[0]["function"]["strict"] is True13141315    def test_bind_tools_strict_none_by_default(self) -> None:1316        """Test that strict is not set when not provided."""1317        model = _make_model()1318        bound = model.bind_tools([GetWeather])1319        assert isinstance(bound, RunnableBinding)1320        tools = bound.kwargs["tools"]1321        assert "strict" not in tools[0]["function"]13221323    def test_bind_tools_parallel_tool_calls_forwarded(self) -> None:1324        """Test that parallel_tool_calls is forwarded to the request kwargs."""1325        model = _make_model()1326        bound = model.bind_tools([GetWeather], parallel_tool_calls=False)1327        assert isinstance(bound, RunnableBinding)1328        assert bound.kwargs["parallel_tool_calls"] is False13291330    def test_bind_tools_parallel_tool_calls_none_omits_key(self) -> None:1331        """Test that parallel_tool_calls=None does not set the key in kwargs."""1332        model = _make_model()1333        bound = model.bind_tools([GetWeather])1334        assert isinstance(bound, RunnableBinding)1335        assert "parallel_tool_calls" not in bound.kwargs133613371338# ===========================================================================1339# with_structured_output tests1340# ===========================================================================134113421343class TestWithStructuredOutput:1344    """Tests for the with_structured_output public method."""13451346    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1347    @pytest.mark.parametrize("include_raw", ["yes", "no"])1348    def test_with_structured_output_pydantic(1349        self,1350        method: Literal["function_calling", "json_schema"],1351        include_raw: str,1352    ) -> None:1353        """Test with_structured_output using a Pydantic schema."""1354        model = _make_model()1355        structured = model.with_structured_output(1356            GenerateUsername, method=method, include_raw=(include_raw == "yes")1357        )1358        assert structured is not None13591360    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1361    def test_with_structured_output_dict_schema(1362        self,1363        method: Literal["function_calling", "json_schema"],1364    ) -> None:1365        """Test with_structured_output using a JSON schema dict."""1366        schema = GenerateUsername.model_json_schema()1367        model = _make_model()1368        structured = model.with_structured_output(schema, method=method)1369        assert structured is not None13701371    def test_with_structured_output_none_schema_function_calling_raises(self) -> None:1372        """Test that schema=None with function_calling raises ValueError."""1373        model = _make_model()1374        with pytest.raises(ValueError, match="schema must be specified"):1375            model.with_structured_output(None, method="function_calling")13761377    def test_with_structured_output_none_schema_json_schema_raises(self) -> None:1378        """Test that schema=None with json_schema raises ValueError."""1379        model = _make_model()1380        with pytest.raises(ValueError, match="schema must be specified"):1381            model.with_structured_output(None, method="json_schema")13821383    def test_with_structured_output_invalid_method_raises(self) -> None:1384        """Test that an unrecognized method raises ValueError."""1385        model = _make_model()1386        with pytest.raises(ValueError, match="Unrecognized method"):1387            model.with_structured_output(1388                GenerateUsername,1389                method="invalid",  # type: ignore[arg-type]1390            )13911392    def test_with_structured_output_json_schema_sets_response_format(self) -> None:1393        """Test that json_schema method sets response_format correctly."""1394        model = _make_model()1395        structured = model.with_structured_output(1396            GenerateUsername, method="json_schema"1397        )1398        # The first step in the chain should be the bound model1399        bound = structured.first  # type: ignore[attr-defined]1400        assert isinstance(bound, RunnableBinding)1401        rf = bound.kwargs["response_format"]1402        assert rf["type"] == "json_schema"1403        assert rf["json_schema"]["name"] == "GenerateUsername"14041405    def test_with_structured_output_json_mode_warns_and_falls_back(self) -> None:1406        """Test that json_mode warns and falls back to json_schema."""1407        model = _make_model()1408        with pytest.warns(match="Defaulting to 'json_schema'"):1409            structured = model.with_structured_output(1410                GenerateUsername,1411                method="json_mode",  # type: ignore[arg-type]1412            )1413        bound = structured.first  # type: ignore[attr-defined]1414        assert isinstance(bound, RunnableBinding)1415        rf = bound.kwargs["response_format"]1416        assert rf["type"] == "json_schema"14171418    def test_with_structured_output_strict_function_calling(self) -> None:1419        """Test that strict is forwarded for function_calling method."""1420        model = _make_model()1421        structured = model.with_structured_output(1422            GenerateUsername, method="function_calling", strict=True1423        )1424        bound = structured.first  # type: ignore[attr-defined]1425        assert isinstance(bound, RunnableBinding)1426        tools = bound.kwargs["tools"]1427        assert tools[0]["function"]["strict"] is True14281429    def test_with_structured_output_strict_json_schema(self) -> None:1430        """Test that strict is forwarded for json_schema method."""1431        model = _make_model()1432        structured = model.with_structured_output(1433            GenerateUsername, method="json_schema", strict=True1434        )1435        bound = structured.first  # type: ignore[attr-defined]1436        assert isinstance(bound, RunnableBinding)1437        rf = bound.kwargs["response_format"]1438        assert rf["json_schema"]["strict"] is True14391440    def test_with_structured_output_json_mode_with_strict_warns_and_forwards(1441        self,1442    ) -> None:1443        """Test json_mode with strict warns and falls back to json_schema."""1444        model = _make_model()1445        with pytest.warns(match="Defaulting to 'json_schema'"):1446            structured = model.with_structured_output(1447                GenerateUsername,1448                method="json_mode",  # type: ignore[arg-type]1449                strict=True,1450            )1451        bound = structured.first  # type: ignore[attr-defined]1452        assert isinstance(bound, RunnableBinding)1453        rf = bound.kwargs["response_format"]1454        assert rf["type"] == "json_schema"1455        assert rf["json_schema"]["strict"] is True145614571458# ===========================================================================1459# Message conversion tests1460# ===========================================================================146114621463class TestMessageConversion:1464    """Tests for message conversion functions."""14651466    def test_human_message_to_dict(self) -> None:1467        """Test converting HumanMessage to dict."""1468        msg = HumanMessage(content="Hello")1469        result = _convert_message_to_dict(msg)1470        assert result == {"role": "user", "content": "Hello"}14711472    def test_system_message_to_dict(self) -> None:1473        """Test converting SystemMessage to dict."""1474        msg = SystemMessage(content="You are helpful.")1475        result = _convert_message_to_dict(msg)1476        assert result == {"role": "system", "content": "You are helpful."}14771478    def test_ai_message_to_dict(self) -> None:1479        """Test converting AIMessage to dict."""1480        msg = AIMessage(content="Hi there!")1481        result = _convert_message_to_dict(msg)1482        assert result == {"role": "assistant", "content": "Hi there!"}14831484    def test_ai_message_with_reasoning_content_to_dict(self) -> None:1485        """Test that reasoning_content is preserved when converting back to dict."""1486        msg = AIMessage(1487            content="The answer is 42.",1488            additional_kwargs={"reasoning_content": "Let me think about this..."},1489        )1490        result = _convert_message_to_dict(msg)1491        assert result["role"] == "assistant"1492        assert result["content"] == "The answer is 42."1493        assert result["reasoning"] == "Let me think about this..."14941495    def test_ai_message_with_fragmented_reasoning_details_merged(self) -> None:1496        """Fragmented `reasoning_details` are merged before serialization.14971498        Float `index` values mirror what `ChatOpenRouter.stream()` produces1499        (the OpenRouter SDK coerces `index` via Pydantic). With float1500        `index`, `langchain_core.utils._merge.merge_lists` does not auto-merge1501        list entries (its index-match path requires `int`), so fragments1502        accumulate as separate list items and require this helper to merge1503        them before the next API turn.1504        """1505        details = [1506            {1507                "type": "reasoning.text",1508                "text": "The",1509                "format": "anthropic-claude-v1",1510                "index": 0.0,1511            },1512            {1513                "type": "reasoning.text",1514                "text": " user wants",1515                "format": "anthropic-claude-v1",1516                "index": 0.0,1517            },1518            {1519                "type": "reasoning.text",1520                "signature": "sig_abc123",1521                "format": "anthropic-claude-v1",1522                "index": 0.0,1523            },1524        ]1525        msg = AIMessage(1526            content="Answer",1527            additional_kwargs={"reasoning_details": details},1528        )1529        result = _convert_message_to_dict(msg)1530        assert result["reasoning_details"] == [1531            {1532                "type": "reasoning.text",1533                "text": "The user wants",1534                "format": "anthropic-claude-v1",1535                "signature": "sig_abc123",1536                "index": 0.0,1537            }1538        ]1539        assert "reasoning" not in result15401541    def test_ai_message_distinct_reasoning_details_preserved(self) -> None:1542        """Distinct entries (different `index`) are not merged."""1543        details = [1544            {"type": "reasoning.text", "text": "First thought", "index": 0},1545            {"type": "reasoning.text", "text": "Second thought", "index": 1},1546        ]1547        msg = AIMessage(1548            content="Answer",1549            additional_kwargs={"reasoning_details": details},1550        )1551        result = _convert_message_to_dict(msg)1552        assert result["reasoning_details"] == details15531554    def test_ai_message_reasoning_details_strips_responses_ids(self) -> None:1555        """OpenAI Responses `rs_*` item IDs are stripped before replay."""1556        response_id = "rs_053a05e24b0da75e0169fa358ea9fc81908b18aff8157798c1"1557        details = [1558            {1559                "type": "reasoning.text",1560                "id": response_id,1561                "text": "step-by-step",1562                "index": 0,1563            }1564        ]1565        msg = AIMessage(1566            content="Answer",1567            additional_kwargs={"reasoning_details": details},1568        )1569        result = _convert_message_to_dict(msg)1570        assert result["reasoning_details"] == [1571            {"type": "reasoning.text", "text": "step-by-step", "index": 0}1572        ]1573        assert response_id.startswith("rs_")1574        assert details[0]["id"] == response_id15751576    def test_ai_message_reasoning_details_preserves_non_responses_ids(self) -> None:1577        """Non-Responses IDs are preserved in reasoning details."""1578        details = [1579            {1580                "type": "reasoning.text",1581                "id": "reasoning_abc123",1582                "text": "step-by-step",1583            }1584        ]1585        msg = AIMessage(1586            content="Answer",1587            additional_kwargs={"reasoning_details": details},1588        )1589        result = _convert_message_to_dict(msg)1590        assert result["reasoning_details"] == details15911592    def test_ai_message_unindexed_reasoning_details_not_merged(self) -> None:1593        """Entries without an `index` are passed through unchanged."""1594        details = [1595            {"type": "reasoning.text", "text": "First"},1596            {"type": "reasoning.text", "text": "Second"},1597        ]1598        msg = AIMessage(1599            content="Answer",1600            additional_kwargs={"reasoning_details": details},1601        )1602        result = _convert_message_to_dict(msg)1603        assert result["reasoning_details"] == details16041605    def test_ai_message_interleaved_index_fragments_preserved(self) -> None:1606        """Only consecutive same-`index` runs merge; interleaved runs stay split."""1607        details = [1608            {"type": "reasoning.text", "text": "A", "index": 0},1609            {"type": "reasoning.text", "text": "B", "index": 1},1610            {"type": "reasoning.text", "text": "C", "index": 0},1611            {"type": "reasoning.text", "text": "D", "index": 1},1612        ]1613        msg = AIMessage(1614            content="Answer",1615            additional_kwargs={"reasoning_details": details},1616        )1617        result = _convert_message_to_dict(msg)1618        assert result["reasoning_details"] == details16191620    def test_ai_message_fragment_metadata_preserved(self) -> None:1621        """Test that metadata from later fragments is preserved after merge."""1622        details = [1623            {"type": "reasoning.text", "text": "thinking...", "index": 0},1624            {1625                "type": "reasoning.text",1626                "text": " done",1627                "index": 0,1628                "signature": "sig_abc123",1629            },1630        ]1631        msg = AIMessage(1632            content="Answer",1633            additional_kwargs={"reasoning_details": details},1634        )1635        result = _convert_message_to_dict(msg)1636        assert len(result["reasoning_details"]) == 11637        assert result["reasoning_details"][0]["text"] == "thinking... done"1638        assert result["reasoning_details"][0]["signature"] == "sig_abc123"16391640    def test_streamed_reasoning_details_roundtrip_to_next_turn_payload(self) -> None:1641        """Test the chunk-merge-to-next-turn serialization path from issue #36400."""1642        chunk_dicts = [1643            {"choices": [{"delta": {"role": "assistant", "content": ""}, "index": 0}]},1644            {1645                "choices": [1646                    {1647                        "delta": {1648                            "reasoning_details": [1649                                {1650                                    "type": "reasoning.text",1651                                    "text": "The",1652                                    "format": "anthropic-claude-v1",1653                                    "index": 0.0,1654                                }1655                            ]1656                        },1657                        "index": 0,1658                    }1659                ]1660            },1661            {1662                "choices": [1663                    {1664                        "delta": {1665                            "reasoning_details": [1666                                {1667                                    "type": "reasoning.text",1668                                    "text": " user wants",1669                                    "format": "anthropic-claude-v1",1670                                    "index": 0.0,1671                                }1672                            ]1673                        },1674                        "index": 0,1675                    }1676                ]1677            },1678            {1679                "choices": [1680                    {1681                        "delta": {1682                            "reasoning_details": [1683                                {1684                                    "type": "reasoning.text",1685                                    "signature": "sig_abc123",1686                                    "format": "anthropic-claude-v1",1687                                    "index": 0.0,1688                                }1689                            ]1690                        },1691                        "index": 0,1692                    }1693                ]1694            },1695            {"choices": [{"delta": {"content": "Answer"}, "index": 0}]},1696        ]1697        chunks = [1698            _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1699            for chunk in chunk_dicts1700        ]1701        merged_chunk = chunks[0]1702        for chunk in chunks[1:]:1703            merged_chunk = merged_chunk + chunk17041705        assert len(merged_chunk.additional_kwargs["reasoning_details"]) == 317061707        msg = AIMessage(1708            content=merged_chunk.content,1709            additional_kwargs=merged_chunk.additional_kwargs,1710            response_metadata=merged_chunk.response_metadata,1711        )17121713        result = _convert_message_to_dict(msg)1714        assert result["reasoning_details"] == [1715            {1716                "type": "reasoning.text",1717                "text": "The user wants",1718                "format": "anthropic-claude-v1",1719                "signature": "sig_abc123",1720                "index": 0.0,1721            }1722        ]17231724    def test_ai_message_with_both_reasoning_fields_to_dict(self) -> None:1725        """Test that both reasoning_content and reasoning_details are preserved."""1726        details = [{"type": "reasoning.text", "text": "detailed thinking"}]1727        msg = AIMessage(1728            content="Answer",1729            additional_kwargs={1730                "reasoning_content": "I thought about it",1731                "reasoning_details": details,1732            },1733        )1734        result = _convert_message_to_dict(msg)1735        assert result["reasoning"] == "I thought about it"1736        assert result["reasoning_details"] == details17371738    def test_reasoning_roundtrip_through_dict(self) -> None:1739        """Test that reasoning survives dict -> message -> dict roundtrip."""1740        original_dict = {1741            "role": "assistant",1742            "content": "The answer",1743            "reasoning": "My thinking process",1744            "reasoning_details": [{"type": "reasoning.text", "text": "step-by-step"}],1745        }1746        msg = _convert_dict_to_message(original_dict)1747        result = _convert_message_to_dict(msg)1748        assert result["reasoning"] == "My thinking process"1749        assert result["reasoning_details"] == original_dict["reasoning_details"]17501751    def test_tool_message_to_dict(self) -> None:1752        """Test converting ToolMessage to dict."""1753        msg = ToolMessage(content="result", tool_call_id="call_123")1754        result = _convert_message_to_dict(msg)1755        assert result == {1756            "role": "tool",1757            "content": "result",1758            "tool_call_id": "call_123",1759        }17601761    def test_chat_message_to_dict(self) -> None:1762        """Test converting ChatMessage to dict."""1763        msg = ChatMessage(content="Hello", role="developer")1764        result = _convert_message_to_dict(msg)1765        assert result == {"role": "developer", "content": "Hello"}17661767    def test_ai_message_with_tool_calls_to_dict(self) -> None:1768        """Test converting AIMessage with tool calls to dict."""1769        msg = AIMessage(1770            content="",1771            tool_calls=[1772                {1773                    "name": "get_weather",1774                    "args": {"location": "SF"},1775                    "id": "call_1",1776                    "type": "tool_call",1777                }1778            ],1779        )1780        result = _convert_message_to_dict(msg)1781        assert result["role"] == "assistant"1782        assert result["content"] is None1783        assert len(result["tool_calls"]) == 11784        assert result["tool_calls"][0]["function"]["name"] == "get_weather"17851786    def test_dict_to_ai_message(self) -> None:1787        """Test converting dict to AIMessage."""1788        d = {"role": "assistant", "content": "Hello!"}1789        msg = _convert_dict_to_message(d)1790        assert isinstance(msg, AIMessage)1791        assert msg.content == "Hello!"17921793    def test_dict_to_ai_message_with_reasoning(self) -> None:1794        """Test that reasoning is extracted from response dict."""1795        d = {1796            "role": "assistant",1797            "content": "Answer",1798            "reasoning": "Let me think...",1799        }1800        msg = _convert_dict_to_message(d)1801        assert isinstance(msg, AIMessage)1802        assert msg.additional_kwargs["reasoning_content"] == "Let me think..."18031804    def test_dict_to_ai_message_with_tool_calls(self) -> None:1805        """Test converting dict with tool calls to AIMessage."""1806        d = {1807            "role": "assistant",1808            "content": "",1809            "tool_calls": [1810                {1811                    "id": "call_1",1812                    "type": "function",1813                    "function": {1814                        "name": "get_weather",1815                        "arguments": '{"location": "SF"}',1816                    },1817                }1818            ],1819        }1820        msg = _convert_dict_to_message(d)1821        assert isinstance(msg, AIMessage)1822        assert len(msg.tool_calls) == 11823        assert msg.tool_calls[0]["name"] == "get_weather"18241825    def test_dict_to_ai_message_with_invalid_tool_calls(self) -> None:1826        """Test that malformed tool calls produce invalid_tool_calls."""1827        d = {1828            "role": "assistant",1829            "content": "",1830            "tool_calls": [1831                {1832                    "id": "call_bad",1833                    "type": "function",1834                    "function": {1835                        "name": "get_weather",1836                        "arguments": "not-valid-json{{{",1837                    },1838                }1839            ],1840        }1841        msg = _convert_dict_to_message(d)1842        assert isinstance(msg, AIMessage)1843        assert len(msg.invalid_tool_calls) == 11844        assert len(msg.tool_calls) == 01845        assert msg.invalid_tool_calls[0]["name"] == "get_weather"18461847    def test_dict_to_human_message(self) -> None:1848        """Test converting dict to HumanMessage."""1849        d = {"role": "user", "content": "Hi"}1850        msg = _convert_dict_to_message(d)1851        assert isinstance(msg, HumanMessage)18521853    def test_dict_to_system_message(self) -> None:1854        """Test converting dict to SystemMessage."""1855        d = {"role": "system", "content": "Be helpful"}1856        msg = _convert_dict_to_message(d)1857        assert isinstance(msg, SystemMessage)18581859    def test_dict_to_tool_message(self) -> None:1860        """Test converting dict with role=tool to ToolMessage."""1861        d = {1862            "role": "tool",1863            "content": "result data",1864            "tool_call_id": "call_42",1865            "name": "get_weather",1866        }1867        msg = _convert_dict_to_message(d)1868        assert isinstance(msg, ToolMessage)1869        assert msg.content == "result data"1870        assert msg.tool_call_id == "call_42"1871        assert msg.additional_kwargs["name"] == "get_weather"18721873    def test_dict_to_chat_message_unknown_role(self) -> None:1874        """Test that unrecognized roles fall back to ChatMessage."""1875        d = {"role": "developer", "content": "Some content"}1876        with pytest.warns(UserWarning, match="Unrecognized message role"):1877            msg = _convert_dict_to_message(d)1878        assert isinstance(msg, ChatMessage)1879        assert msg.role == "developer"1880        assert msg.content == "Some content"18811882    def test_ai_message_with_list_content_filters_non_text(self) -> None:1883        """Test that non-text blocks are filtered from AIMessage list content."""1884        msg = AIMessage(1885            content=[1886                {"type": "text", "text": "Hello"},1887                {"type": "image_url", "image_url": {"url": "http://example.com"}},1888            ]1889        )1890        result = _convert_message_to_dict(msg)1891        assert result["content"] == [{"type": "text", "text": "Hello"}]189218931894# ===========================================================================1895# _create_chat_result tests1896# ===========================================================================189718981899class TestCreateChatResult:1900    """Tests for _create_chat_result."""19011902    def test_model_provider_in_response_metadata(self) -> None:1903        """Test that model_provider is set in response metadata."""1904        model = _make_model()1905        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1906        assert (1907            result.generations[0].message.response_metadata.get("model_provider")1908            == "openrouter"1909        )19101911    def test_reasoning_from_response(self) -> None:1912        """Test that reasoning content is extracted from response."""1913        model = _make_model()1914        response_dict: dict[str, Any] = {1915            "choices": [1916                {1917                    "message": {1918                        "role": "assistant",1919                        "content": "Answer",1920                        "reasoning": "Let me think...",1921                    },1922                    "finish_reason": "stop",1923                }1924            ],1925        }1926        result = model._create_chat_result(response_dict)1927        assert (1928            result.generations[0].message.additional_kwargs.get("reasoning_content")1929            == "Let me think..."1930        )19311932    def test_usage_metadata_created(self) -> None:1933        """Test that usage metadata is created from token usage."""1934        model = _make_model()1935        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1936        msg = result.generations[0].message1937        assert isinstance(msg, AIMessage)1938        usage = msg.usage_metadata1939        assert usage is not None1940        assert usage["input_tokens"] == 101941        assert usage["output_tokens"] == 51942        assert usage["total_tokens"] == 1519431944    def test_tool_calls_in_response(self) -> None:1945        """Test that tool calls are extracted from response."""1946        model = _make_model()1947        result = model._create_chat_result(_TOOL_RESPONSE_DICT)1948        msg = result.generations[0].message1949        assert isinstance(msg, AIMessage)1950        assert len(msg.tool_calls) == 11951        assert msg.tool_calls[0]["name"] == "GetWeather"19521953    def test_response_model_in_llm_output(self) -> None:1954        """Test that the response model is included in llm_output."""1955        model = _make_model()1956        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1957        assert result.llm_output is not None1958        assert result.llm_output["model_name"] == MODEL_NAME19591960    def test_response_model_propagated_to_llm_output(self) -> None:1961        """Test that llm_output uses response model when available."""1962        model = _make_model()1963        response = {1964            **_SIMPLE_RESPONSE_DICT,1965            "model": MODEL_NAME,1966        }1967        result = model._create_chat_result(response)1968        assert result.llm_output is not None1969        assert result.llm_output["model_name"] == MODEL_NAME19701971    def test_system_fingerprint_in_metadata(self) -> None:1972        """Test that system_fingerprint is included in response_metadata."""1973        model = _make_model()1974        response = {1975            **_SIMPLE_RESPONSE_DICT,1976            "system_fingerprint": "fp_abc123",1977        }1978        result = model._create_chat_result(response)1979        msg = result.generations[0].message1980        assert isinstance(msg, AIMessage)1981        assert msg.response_metadata["system_fingerprint"] == "fp_abc123"19821983    def test_native_finish_reason_in_metadata(self) -> None:1984        """Test that native_finish_reason is included in response_metadata."""1985        model = _make_model()1986        response: dict[str, Any] = {1987            **_SIMPLE_RESPONSE_DICT,1988            "choices": [1989                {1990                    "message": {"role": "assistant", "content": "Hello!"},1991                    "finish_reason": "stop",1992                    "native_finish_reason": "end_turn",1993                    "index": 0,1994                }1995            ],1996        }1997        result = model._create_chat_result(response)1998        msg = result.generations[0].message1999        assert isinstance(msg, AIMessage)2000        assert msg.response_metadata["native_finish_reason"] == "end_turn"

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.