libs/partners/openrouter/tests/unit_tests/test_chat_models.py PYTHON 3,782 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 3,782.
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_stream_generation_info,33    _create_usage_metadata,34    _format_message_content,35)3637MODEL_NAME = "openai/gpt-5.5"383940def _make_model(**kwargs: Any) -> ChatOpenRouter:41    """Create a `ChatOpenRouter` with sane defaults for unit tests."""42    defaults: dict[str, Any] = {"model": MODEL_NAME, "api_key": SecretStr("test-key")}43    defaults.update(kwargs)44    return ChatOpenRouter(**defaults)454647# ---------------------------------------------------------------------------48# Pydantic schemas used across multiple test classes49# ---------------------------------------------------------------------------505152class GetWeather(BaseModel):53    """Get the current weather in a given location."""5455    location: str = Field(description="The city and state")565758class GenerateUsername(BaseModel):59    """Generate a username from a full name."""6061    name: str = Field(description="The full name")62    hair_color: str = Field(description="The hair color")636465# ---------------------------------------------------------------------------66# Mock helpers for SDK responses67# ---------------------------------------------------------------------------6869_SIMPLE_RESPONSE_DICT: dict[str, Any] = {70    "id": "gen-abc123",71    "choices": [72        {73            "message": {"role": "assistant", "content": "Hello!"},74            "finish_reason": "stop",75            "index": 0,76        }77    ],78    "usage": {79        "prompt_tokens": 10,80        "completion_tokens": 5,81        "total_tokens": 15,82    },83    "model": MODEL_NAME,84    "object": "chat.completion",85    "created": 1700000000.0,86    "provider": "Anthropic",87}8889_TOOL_RESPONSE_DICT: dict[str, Any] = {90    "id": "gen-tool123",91    "choices": [92        {93            "message": {94                "role": "assistant",95                "content": None,96                "tool_calls": [97                    {98                        "id": "call_1",99                        "type": "function",100                        "function": {101                            "name": "GetWeather",102                            "arguments": '{"location": "San Francisco"}',103                        },104                    }105                ],106            },107            "finish_reason": "tool_calls",108            "index": 0,109        }110    ],111    "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},112    "model": MODEL_NAME,113    "object": "chat.completion",114    "created": 1700000000.0,115}116117_STREAM_CHUNKS: list[dict[str, Any]] = [118    {119        "choices": [{"delta": {"role": "assistant", "content": ""}, "index": 0}],120        "model": MODEL_NAME,121        "object": "chat.completion.chunk",122        "created": 1700000000.0,123        "id": "gen-stream1",124    },125    {126        "choices": [{"delta": {"content": "Hello"}, "index": 0}],127        "model": MODEL_NAME,128        "object": "chat.completion.chunk",129        "created": 1700000000.0,130        "id": "gen-stream1",131    },132    {133        "choices": [{"delta": {"content": " world"}, "index": 0}],134        "model": MODEL_NAME,135        "object": "chat.completion.chunk",136        "created": 1700000000.0,137        "id": "gen-stream1",138    },139    {140        "choices": [{"delta": {}, "finish_reason": "stop", "index": 0}],141        "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},142        "model": MODEL_NAME,143        "object": "chat.completion.chunk",144        "created": 1700000000.0,145        "id": "gen-stream1",146    },147]148149_DUPLICATE_FINISH_STREAM_CHUNKS: list[dict[str, Any]] = [150    {151        "choices": [{"delta": {"role": "assistant", "content": "Hello"}, "index": 0}],152        "model": MODEL_NAME,153        "object": "chat.completion.chunk",154        "created": 1700000000.0,155        "id": "gen-stream1",156    },157    {158        "choices": [{"delta": {}, "finish_reason": "stop", "index": 0}],159        "model": MODEL_NAME,160        "object": "chat.completion.chunk",161        "created": 1700000000.0,162        "id": "gen-stream1",163    },164    {165        "choices": [166            {167                "delta": {},168                "finish_reason": "stop",169                "native_finish_reason": "end_turn",170                "index": 0,171            }172        ],173        "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},174        "model": MODEL_NAME,175        "object": "chat.completion.chunk",176        "created": 1700000000.0,177        "id": "gen-stream1",178        "system_fingerprint": "fp_duplicate",179    },180]181182183def _make_sdk_response(response_dict: dict[str, Any]) -> MagicMock:184    """Build a MagicMock that behaves like an SDK ChatResponse."""185    mock = MagicMock()186    mock.model_dump.return_value = response_dict187    return mock188189190def _assert_duplicate_finish_result(result: Any) -> None:191    generation = result.generations[0][0]192    assert generation.text == "Hello"193    assert generation.generation_info == {194        "finish_reason": "stop",195        "model_name": MODEL_NAME,196        "id": "gen-stream1",197        "created": 1700000000,198        "object": "chat.completion.chunk",199        "model_provider": "openrouter",200        "system_fingerprint": "fp_duplicate",201        "native_finish_reason": "end_turn",202    }203    assert generation.message.response_metadata == generation.generation_info204    assert generation.message.usage_metadata == {205        "input_tokens": 5,206        "output_tokens": 2,207        "total_tokens": 7,208    }209210211class _MockSyncStream:212    """Synchronous iterator that mimics the SDK EventStream."""213214    def __init__(self, chunks: list[dict[str, Any]]) -> None:215        # Copy so `__next__`'s `pop(0)` never drains a caller-supplied list216        # (e.g. a shared module-level fixture), mirroring `_MockAsyncStream`.217        self._chunks = list(chunks)218219    def __iter__(self) -> _MockSyncStream:220        return self221222    def __next__(self) -> MagicMock:223        if not self._chunks:224            raise StopIteration225        chunk = self._chunks.pop(0)226        mock = MagicMock()227        mock.model_dump.return_value = chunk228        return mock229230231class _MockAsyncStream:232    """Async iterator that mimics the SDK EventStreamAsync."""233234    def __init__(self, chunks: list[dict[str, Any]]) -> None:235        self._chunks = list(chunks)236237    def __aiter__(self) -> _MockAsyncStream:238        return self239240    async def __anext__(self) -> MagicMock:241        if not self._chunks:242            raise StopAsyncIteration243        chunk = self._chunks.pop(0)244        mock = MagicMock()245        mock.model_dump.return_value = chunk246        return mock247248249# ===========================================================================250# Instantiation tests251# ===========================================================================252253254class TestChatOpenRouterInstantiation:255    """Tests for `ChatOpenRouter` instantiation."""256257    def test_basic_instantiation(self) -> None:258        """Test basic model instantiation with required params."""259        model = _make_model()260        assert model.model_name == MODEL_NAME261        assert model.model == MODEL_NAME262        assert model.openrouter_api_base is None263264    def test_api_key_from_field(self) -> None:265        """Test that API key is properly set."""266        model = _make_model()267        assert model.openrouter_api_key is not None268        assert model.openrouter_api_key.get_secret_value() == "test-key"269270    def test_api_key_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:271        """Test that API key is read from OPENROUTER_API_KEY env var."""272        monkeypatch.setenv("OPENROUTER_API_KEY", "env-key-123")273        model = ChatOpenRouter(model=MODEL_NAME)274        assert model.openrouter_api_key is not None275        assert model.openrouter_api_key.get_secret_value() == "env-key-123"276277    def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:278        """Test that missing API key raises ValueError."""279        monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)280        with pytest.raises(ValueError, match="OPENROUTER_API_KEY must be set"):281            ChatOpenRouter(model=MODEL_NAME)282283    def test_model_required(self) -> None:284        """Test that model name is required."""285        with pytest.raises((ValueError, TypeError)):286            ChatOpenRouter(api_key=SecretStr("test-key"))  # type: ignore[call-arg]287288    def test_secret_masking(self) -> None:289        """Test that API key is not exposed in string representation."""290        model = _make_model(api_key=SecretStr("super-secret"))291        model_str = str(model)292        assert "super-secret" not in model_str293294    def test_secret_masking_repr(self) -> None:295        """Test that API key is masked in repr too."""296        model = _make_model(api_key=SecretStr("super-secret"))297        assert "super-secret" not in repr(model)298299    def test_api_key_is_secret_str(self) -> None:300        """Test that openrouter_api_key is a SecretStr instance."""301        model = _make_model()302        assert isinstance(model.openrouter_api_key, SecretStr)303304    def test_llm_type(self) -> None:305        """Test _llm_type property."""306        model = _make_model()307        assert model._llm_type == "openrouter-chat"308309    def test_ls_params(self) -> None:310        """Test LangSmith params include openrouter provider."""311        model = _make_model()312        ls_params = model._get_ls_params()313        assert ls_params["ls_provider"] == "openrouter"314315    def test_ls_params_includes_max_tokens(self) -> None:316        """Test that ls_max_tokens is set when max_tokens is configured."""317        model = _make_model(max_tokens=512)318        ls_params = model._get_ls_params()319        assert ls_params["ls_max_tokens"] == 512320321    def test_ls_params_stop_string_wrapped_in_list(self) -> None:322        """Test that a string stop value is wrapped in a list for ls_stop."""323        model = _make_model(stop_sequences="END")324        ls_params = model._get_ls_params()325        assert ls_params["ls_stop"] == ["END"]326327    def test_ls_params_stop_list_passthrough(self) -> None:328        """Test that a list stop value is passed through directly."""329        model = _make_model(stop_sequences=["END", "STOP"])330        ls_params = model._get_ls_params()331        assert ls_params["ls_stop"] == ["END", "STOP"]332333    def test_metadata_versions(self) -> None:334        """Test that metadata reports the correct version info."""335        model = _make_model()336        assert model.metadata is not None337        versions = model.metadata["lc_versions"]338        assert "langchain-core" in versions339        assert "langchain-openrouter" in versions340341    def test_client_created(self) -> None:342        """Test that OpenRouter SDK client is created."""343        model = _make_model()344        assert model.client is not None345346    def test_client_reused_for_same_params(self) -> None:347        """Test that the SDK client is reused when model is re-validated."""348        model = _make_model()349        client_1 = model.client350        # Re-validate does not replace the existing client351        model.validate_environment()  # type: ignore[operator]352        assert model.client is client_1353354    def test_app_url_passed_to_client(self) -> None:355        """Test that app_url is passed as HTTP-Referer header via httpx clients."""356        with patch("openrouter.OpenRouter") as mock_cls:357            mock_cls.return_value = MagicMock()358            ChatOpenRouter(359                model=MODEL_NAME,360                api_key=SecretStr("test-key"),361                app_url="https://myapp.com",362            )363            call_kwargs = mock_cls.call_args[1]364            assert call_kwargs["client"].headers["HTTP-Referer"] == "https://myapp.com"365366    def test_app_title_passed_to_client(self) -> None:367        """Test that app_title is passed as X-Title header via httpx clients."""368        with patch("openrouter.OpenRouter") as mock_cls:369            mock_cls.return_value = MagicMock()370            ChatOpenRouter(371                model=MODEL_NAME,372                api_key=SecretStr("test-key"),373                app_title="My App",374            )375            call_kwargs = mock_cls.call_args[1]376            assert call_kwargs["client"].headers["X-Title"] == "My App"377378    def test_default_attribution_headers(self) -> None:379        """Test that default attribution headers are sent when not overridden."""380        with patch("openrouter.OpenRouter") as mock_cls:381            mock_cls.return_value = MagicMock()382            ChatOpenRouter(383                model=MODEL_NAME,384                api_key=SecretStr("test-key"),385            )386            call_kwargs = mock_cls.call_args[1]387            sync_headers = call_kwargs["client"].headers388            assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"389            assert sync_headers["X-Title"] == "LangChain"390391    def test_user_attribution_overrides_defaults(self) -> None:392        """Test that user-supplied attribution overrides the defaults."""393        with patch("openrouter.OpenRouter") as mock_cls:394            mock_cls.return_value = MagicMock()395            ChatOpenRouter(396                model=MODEL_NAME,397                api_key=SecretStr("test-key"),398                app_url="https://my-custom-app.com",399                app_title="My Custom App",400            )401            call_kwargs = mock_cls.call_args[1]402            sync_headers = call_kwargs["client"].headers403            assert sync_headers["HTTP-Referer"] == "https://my-custom-app.com"404            assert sync_headers["X-Title"] == "My Custom App"405406    def test_app_categories_passed_to_client(self) -> None:407        """Test that app_categories injects custom httpx clients with header."""408        with patch("openrouter.OpenRouter") as mock_cls:409            mock_cls.return_value = MagicMock()410            ChatOpenRouter(411                model=MODEL_NAME,412                api_key=SecretStr("test-key"),413                app_categories=["cli-agent", "programming-app"],414            )415            call_kwargs = mock_cls.call_args[1]416            # Custom httpx clients should be created417            assert "client" in call_kwargs418            assert "async_client" in call_kwargs419            # Verify the header value is comma-joined420            sync_headers = call_kwargs["client"].headers421            assert sync_headers["X-OpenRouter-Categories"] == (422                "cli-agent,programming-app"423            )424            async_headers = call_kwargs["async_client"].headers425            assert async_headers["X-OpenRouter-Categories"] == (426                "cli-agent,programming-app"427            )428429    def test_app_categories_none_no_categories_header(self) -> None:430        """Test that no X-OpenRouter-Categories header when categories unset."""431        with patch("openrouter.OpenRouter") as mock_cls:432            mock_cls.return_value = MagicMock()433            ChatOpenRouter(434                model=MODEL_NAME,435                api_key=SecretStr("test-key"),436            )437            call_kwargs = mock_cls.call_args[1]438            # httpx clients still created for X-Title default439            sync_headers = call_kwargs["client"].headers440            assert "X-OpenRouter-Categories" not in sync_headers441442    def test_app_categories_empty_list_no_categories_header(self) -> None:443        """Test that an empty list does not inject categories header."""444        with patch("openrouter.OpenRouter") as mock_cls:445            mock_cls.return_value = MagicMock()446            ChatOpenRouter(447                model=MODEL_NAME,448                api_key=SecretStr("test-key"),449                app_categories=[],450            )451            call_kwargs = mock_cls.call_args[1]452            sync_headers = call_kwargs["client"].headers453            assert "X-OpenRouter-Categories" not in sync_headers454455    def test_app_categories_with_other_attribution(self) -> None:456        """Test that app_categories coexists with app_url and app_title."""457        with patch("openrouter.OpenRouter") as mock_cls:458            mock_cls.return_value = MagicMock()459            ChatOpenRouter(460                model=MODEL_NAME,461                api_key=SecretStr("test-key"),462                app_url="https://myapp.com",463                app_title="My App",464                app_categories=["cli-agent"],465            )466            call_kwargs = mock_cls.call_args[1]467            sync_headers = call_kwargs["client"].headers468            assert sync_headers["HTTP-Referer"] == "https://myapp.com"469            assert sync_headers["X-Title"] == "My App"470            assert sync_headers["X-OpenRouter-Categories"] == "cli-agent"471472    def test_app_title_none_no_x_title_header(self) -> None:473        """Test that X-Title header is omitted when app_title is explicitly None."""474        with patch("openrouter.OpenRouter") as mock_cls:475            mock_cls.return_value = MagicMock()476            ChatOpenRouter(477                model=MODEL_NAME,478                api_key=SecretStr("test-key"),479                app_title=None,480            )481            call_kwargs = mock_cls.call_args[1]482            sync_headers = call_kwargs["client"].headers483            assert "X-Title" not in sync_headers484485    def test_app_url_none_no_referer_header(self) -> None:486        """Test that HTTP-Referer header is omitted when app_url is explicitly None."""487        with patch("openrouter.OpenRouter") as mock_cls:488            mock_cls.return_value = MagicMock()489            ChatOpenRouter(490                model=MODEL_NAME,491                api_key=SecretStr("test-key"),492                app_url=None,493            )494            call_kwargs = mock_cls.call_args[1]495            sync_headers = call_kwargs["client"].headers496            assert "HTTP-Referer" not in sync_headers497498    def test_no_attribution_no_custom_clients(self) -> None:499        """Test that no httpx clients are created when all attribution is None."""500        with patch("openrouter.OpenRouter") as mock_cls:501            mock_cls.return_value = MagicMock()502            ChatOpenRouter(503                model=MODEL_NAME,504                api_key=SecretStr("test-key"),505                app_url=None,506                app_title=None,507                app_categories=None,508            )509            call_kwargs = mock_cls.call_args[1]510            assert "client" not in call_kwargs511            assert "async_client" not in call_kwargs512513    def test_default_headers_passed_to_client(self) -> None:514        """Test that `default_headers` are forwarded to the httpx clients.515516        Before this field existed, setting `default_headers` had no effect on517        the HTTP layer: `build_extra` diverted the unrecognized parameter into518        `model_kwargs` (with a "not default parameter" warning), so the header519        never reached the outbound request.520        """521        with patch("openrouter.OpenRouter") as mock_cls:522            mock_cls.return_value = MagicMock()523            ChatOpenRouter(524                model=MODEL_NAME,525                api_key=SecretStr("test-key"),526                default_headers={"x-grok-conv-id": "session-abc-123"},527            )528            call_kwargs = mock_cls.call_args[1]529            # Custom httpx clients are created and the header is set on both.530            assert "client" in call_kwargs531            assert "async_client" in call_kwargs532            sync_headers = call_kwargs["client"].headers533            assert sync_headers["x-grok-conv-id"] == "session-abc-123"534            async_headers = call_kwargs["async_client"].headers535            assert async_headers["x-grok-conv-id"] == "session-abc-123"536537    def test_default_headers_coexist_with_app_attribution(self) -> None:538        """Test that `default_headers` merges with built-in attribution headers."""539        with patch("openrouter.OpenRouter") as mock_cls:540            mock_cls.return_value = MagicMock()541            ChatOpenRouter(542                model=MODEL_NAME,543                api_key=SecretStr("test-key"),544                app_url="https://myapp.com",545                app_title="My App",546                default_headers={547                    "x-grok-conv-id": "session-xyz",548                    "x-custom-trace-id": "trace-001",549                },550            )551            call_kwargs = mock_cls.call_args[1]552            sync_headers = call_kwargs["client"].headers553            # Built-in attribution preserved554            assert sync_headers["HTTP-Referer"] == "https://myapp.com"555            assert sync_headers["X-Title"] == "My App"556            # User-supplied headers also present557            assert sync_headers["x-grok-conv-id"] == "session-xyz"558            assert sync_headers["x-custom-trace-id"] == "trace-001"559560    def test_default_headers_override_app_attribution(self) -> None:561        """Test that `default_headers` takes precedence over colliding built-in keys."""562        with patch("openrouter.OpenRouter") as mock_cls:563            mock_cls.return_value = MagicMock()564            ChatOpenRouter(565                model=MODEL_NAME,566                api_key=SecretStr("test-key"),567                app_title="Default Title",568                default_headers={"X-Title": "Override Title"},569            )570            call_kwargs = mock_cls.call_args[1]571            sync_headers = call_kwargs["client"].headers572            # default_headers wins over the built-in app_title-derived value573            assert sync_headers["X-Title"] == "Override Title"574575    def test_default_headers_none_no_custom_headers(self) -> None:576        """Test that `default_headers=None` doesn't interfere with default behavior."""577        with patch("openrouter.OpenRouter") as mock_cls:578            mock_cls.return_value = MagicMock()579            ChatOpenRouter(580                model=MODEL_NAME,581                api_key=SecretStr("test-key"),582                default_headers=None,583            )584            call_kwargs = mock_cls.call_args[1]585            # Default app-attribution headers still present586            sync_headers = call_kwargs["client"].headers587            assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"588            assert sync_headers["X-Title"] == "LangChain"589            # No spurious extra headers590            assert "x-grok-conv-id" not in sync_headers591592    def test_default_headers_sole_source_creates_client(self) -> None:593        """`default_headers` alone (no app attribution) still creates httpx clients.594595        Guards against a regression where the `if extra_headers:` check runs596        before `default_headers` is merged in  the header would then be597        dropped and no custom client created.598        """599        with patch("openrouter.OpenRouter") as mock_cls:600            mock_cls.return_value = MagicMock()601            ChatOpenRouter(602                model=MODEL_NAME,603                api_key=SecretStr("test-key"),604                app_url=None,605                app_title=None,606                app_categories=None,607                default_headers={"x-grok-conv-id": "session-solo"},608            )609            call_kwargs = mock_cls.call_args[1]610            assert "client" in call_kwargs611            assert "async_client" in call_kwargs612            assert call_kwargs["client"].headers["x-grok-conv-id"] == "session-solo"613            assert (614                call_kwargs["async_client"].headers["x-grok-conv-id"] == "session-solo"615            )616617    def test_default_headers_override_app_categories(self) -> None:618        """`default_headers` can override the built-in `X-OpenRouter-Categories`."""619        with patch("openrouter.OpenRouter") as mock_cls:620            mock_cls.return_value = MagicMock()621            ChatOpenRouter(622                model=MODEL_NAME,623                api_key=SecretStr("test-key"),624                app_categories=["programming", "translation"],625                default_headers={"X-OpenRouter-Categories": "custom-category"},626            )627            call_kwargs = mock_cls.call_args[1]628            sync_headers = call_kwargs["client"].headers629            assert sync_headers["X-OpenRouter-Categories"] == "custom-category"630631    def test_default_headers_empty_dict_no_custom_client(self) -> None:632        """An empty `default_headers` dict behaves like `None` (no custom client)."""633        with patch("openrouter.OpenRouter") as mock_cls:634            mock_cls.return_value = MagicMock()635            ChatOpenRouter(636                model=MODEL_NAME,637                api_key=SecretStr("test-key"),638                app_url=None,639                app_title=None,640                app_categories=None,641                default_headers={},642            )643            call_kwargs = mock_cls.call_args[1]644            assert "client" not in call_kwargs645            assert "async_client" not in call_kwargs646647    def test_default_headers_override_is_case_insensitive(self) -> None:648        """A case-variant user header overrides the built-in, not doubles it.649650        HTTP header names are case-insensitive, so `default_headers` keyed with651        different casing than a built-in attribution header (`http-referer` vs652        `HTTP-Referer`) must replace it rather than send both values.653        """654        with patch("openrouter.OpenRouter") as mock_cls:655            mock_cls.return_value = MagicMock()656            ChatOpenRouter(657                model=MODEL_NAME,658                api_key=SecretStr("test-key"),659                app_url="https://builtin.example",660                default_headers={"http-referer": "https://override.example"},661            )662            sync_headers = mock_cls.call_args[1]["client"].headers663            # httpx headers are case-insensitive: the override value wins under664            # either spelling, with no comma-joined doubling.665            assert sync_headers["HTTP-Referer"] == "https://override.example"666            assert sync_headers["http-referer"] == "https://override.example"667            assert "," not in sync_headers["HTTP-Referer"]668669    def test_default_headers_not_swept_into_model_kwargs(self) -> None:670        """`default_headers` is a first-class field, not extra `model_kwargs`.671672        Guards the motivating regression: `build_extra` must recognize673        `default_headers`, leave it out of `model_kwargs`, and not emit the674        "not default parameter" warning that unrecognized kwargs trigger.675        """676        with warnings.catch_warnings(record=True) as caught:677            warnings.simplefilter("always")678            model = ChatOpenRouter(679                model=MODEL_NAME,680                api_key=SecretStr("test-key"),681                default_headers={"x-grok-conv-id": "session-abc-123"},682            )683        assert model.model_kwargs == {}684        assert not any("not default parameter" in str(w.message) for w in caught)685686    def test_reasoning_in_params(self) -> None:687        """Test that `reasoning` is included in default params."""688        model = _make_model(reasoning={"effort": "high"})689        params = model._default_params690        assert params["reasoning"] == {"effort": "high"}691692    def test_openrouter_provider_in_params(self) -> None:693        """Test that `openrouter_provider` is included in default params."""694        model = _make_model(openrouter_provider={"order": ["Anthropic"]})695        params = model._default_params696        assert params["provider"] == {"order": ["Anthropic"]}697698    def test_route_in_params(self) -> None:699        """Test that `route` is included in default params."""700        model = _make_model(route="fallback")701        params = model._default_params702        assert params["route"] == "fallback"703704    def test_optional_params_excluded_when_none(self) -> None:705        """Test that None optional params are not in default params."""706        model = _make_model()707        params = model._default_params708        assert "temperature" not in params709        assert "max_tokens" not in params710        assert "top_p" not in params711        assert "reasoning" not in params712713    def test_temperature_included_when_set(self) -> None:714        """Test that temperature is included when explicitly set."""715        model = _make_model(temperature=0.5)716        params = model._default_params717        assert params["temperature"] == 0.5718719720# ===========================================================================721# Serialization tests722# ===========================================================================723724725class TestSerialization:726    """Tests for serialization round-trips."""727728    def test_is_lc_serializable(self) -> None:729        """Test that ChatOpenRouter declares itself as serializable."""730        assert ChatOpenRouter.is_lc_serializable() is True731732    @pytest.mark.filterwarnings("ignore:The function `load` is in beta")733    def test_dumpd_load_roundtrip(self) -> None:734        """Test that dumpd/load round-trip preserves model config."""735        model = _make_model(temperature=0.7, max_tokens=100)736        serialized = dumpd(model)737        deserialized = load(738            serialized,739            valid_namespaces=["langchain_openrouter"],740            allowed_objects="all",741            secrets_from_env=False,742            secrets_map={"OPENROUTER_API_KEY": "test-key"},743        )744        assert isinstance(deserialized, ChatOpenRouter)745        assert deserialized.model_name == MODEL_NAME746        assert deserialized.temperature == 0.7747        assert deserialized.max_tokens == 100748749    def test_dumps_does_not_leak_secrets(self) -> None:750        """Test that dumps output does not contain the raw API key."""751        model = _make_model(api_key=SecretStr("super-secret-key"))752        serialized = dumps(model)753        assert "super-secret-key" not in serialized754755    def test_dumpd_excludes_default_headers(self) -> None:756        """Test that default_headers are excluded from serialized kwargs."""757        model = _make_model(758            default_headers={759                "Authorization": "Bearer provider-token",760                "x-grok-conv-id": "session-abc-123",761            }762        )763764        serialized = dumpd(model)765766        assert "default_headers" not in serialized["kwargs"]767768    def test_dumps_does_not_leak_default_headers(self) -> None:769        """Test that dumps output does not contain default header values."""770        model = _make_model(771            default_headers={772                "Authorization": "Bearer provider-token",773                "x-grok-conv-id": "session-abc-123",774            }775        )776777        serialized = dumps(model)778779        assert "provider-token" not in serialized780        assert "session-abc-123" not in serialized781782783# ===========================================================================784# Mocked generate / stream tests785# ===========================================================================786787788class TestMockedGenerate:789    """Tests for _generate / _agenerate with a mocked SDK client."""790791    def test_invoke_basic(self) -> None:792        """Test basic invoke returns an AIMessage via mocked SDK."""793        model = _make_model()794        model.client = MagicMock()795        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)796797        result = model.invoke("Hello")798        assert isinstance(result, AIMessage)799        assert result.content == "Hello!"800        model.client.chat.send.assert_called_once()801802    def test_invoke_with_tool_response(self) -> None:803        """Test invoke that returns tool calls."""804        model = _make_model()805        model.client = MagicMock()806        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)807808        result = model.invoke("What's the weather?")809        assert isinstance(result, AIMessage)810        assert len(result.tool_calls) == 1811        assert result.tool_calls[0]["name"] == "GetWeather"812813    def test_invoke_passes_correct_messages(self) -> None:814        """Test that invoke converts messages and passes them to the SDK."""815        model = _make_model()816        model.client = MagicMock()817        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)818819        model.invoke([HumanMessage(content="Hi")])820        call_kwargs = model.client.chat.send.call_args[1]821        assert call_kwargs["messages"] == [{"role": "user", "content": "Hi"}]822823    def test_invoke_strips_internal_kwargs(self) -> None:824        """Test that LangChain-internal kwargs are stripped before SDK call."""825        model = _make_model()826        model.client = MagicMock()827        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)828829        model._generate(830            [HumanMessage(content="Hi")],831            ls_structured_output_format={"kwargs": {"method": "function_calling"}},832        )833        call_kwargs = model.client.chat.send.call_args[1]834        assert "ls_structured_output_format" not in call_kwargs835836    def test_invoke_usage_metadata(self) -> None:837        """Test that usage metadata is populated on the response."""838        model = _make_model()839        model.client = MagicMock()840        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)841842        result = model.invoke("Hello")843        assert isinstance(result, AIMessage)844        assert result.usage_metadata is not None845        assert result.usage_metadata["input_tokens"] == 10846        assert result.usage_metadata["output_tokens"] == 5847        assert result.usage_metadata["total_tokens"] == 15848849    def test_stream_basic(self) -> None:850        """Test streaming returns AIMessageChunks via mocked SDK."""851        model = _make_model()852        model.client = MagicMock()853        model.client.chat.send.return_value = _MockSyncStream(854            [dict(c) for c in _STREAM_CHUNKS]855        )856857        chunks = list(model.stream("Hello"))858        assert len(chunks) > 0859        assert all(isinstance(c, AIMessageChunk) for c in chunks)860        # Concatenated content should be "Hello world"861        full_content = "".join(c.content for c in chunks if isinstance(c.content, str))862        assert "Hello" in full_content863        assert "world" in full_content864865    def test_stream_passes_stream_true(self) -> None:866        """Test that stream sends stream=True to the SDK."""867        model = _make_model()868        model.client = MagicMock()869        model.client.chat.send.return_value = _MockSyncStream(870            [dict(c) for c in _STREAM_CHUNKS]871        )872873        list(model.stream("Hello"))874        call_kwargs = model.client.chat.send.call_args[1]875        assert call_kwargs["stream"] is True876877    def test_invoke_with_streaming_flag(self) -> None:878        """Test that invoke delegates to stream when streaming=True."""879        model = _make_model(streaming=True)880        model.client = MagicMock()881        model.client.chat.send.return_value = _MockSyncStream(882            [dict(c) for c in _STREAM_CHUNKS]883        )884885        result = model.invoke("Hello")886        assert isinstance(result, AIMessage)887        call_kwargs = model.client.chat.send.call_args[1]888        assert call_kwargs["stream"] is True889890    async def test_ainvoke_basic(self) -> None:891        """Test async invoke returns an AIMessage via mocked SDK."""892        model = _make_model()893        model.client = MagicMock()894        model.client.chat.send_async = AsyncMock(895            return_value=_make_sdk_response(_SIMPLE_RESPONSE_DICT)896        )897898        result = await model.ainvoke("Hello")899        assert isinstance(result, AIMessage)900        assert result.content == "Hello!"901        model.client.chat.send_async.assert_awaited_once()902903    async def test_astream_basic(self) -> None:904        """Test async streaming returns AIMessageChunks via mocked SDK."""905        model = _make_model()906        model.client = MagicMock()907        model.client.chat.send_async = AsyncMock(908            return_value=_MockAsyncStream(_STREAM_CHUNKS)909        )910911        chunks = [c async for c in model.astream("Hello")]912        assert len(chunks) > 0913        assert all(isinstance(c, AIMessageChunk) for c in chunks)914915    def test_stream_response_metadata_fields(self) -> None:916        """Test response-level metadata in streaming response_metadata."""917        model = _make_model()918        model.client = MagicMock()919        stream_chunks: list[dict[str, Any]] = [920            {921                "choices": [922                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}923                ],924                "model": "anthropic/claude-sonnet-4-5",925                "system_fingerprint": "fp_stream123",926                "object": "chat.completion.chunk",927                "created": 1700000000.0,928                "id": "gen-stream-meta",929            },930            {931                "choices": [932                    {933                        "delta": {},934                        "finish_reason": "stop",935                        "native_finish_reason": "end_turn",936                        "index": 0,937                    }938                ],939                "model": "anthropic/claude-sonnet-4-5",940                "system_fingerprint": "fp_stream123",941                "object": "chat.completion.chunk",942                "created": 1700000000.0,943                "id": "gen-stream-meta",944            },945        ]946        model.client.chat.send.return_value = _MockSyncStream(stream_chunks)947948        chunks = list(model.stream("Hello"))949        assert len(chunks) >= 2950951        # Find the chunk with finish_reason (final metadata chunk)952        final = [953            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"954        ]955        assert len(final) == 1956        meta = final[0].response_metadata957        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"958        assert meta["system_fingerprint"] == "fp_stream123"959        assert meta["native_finish_reason"] == "end_turn"960        assert meta["finish_reason"] == "stop"961        assert meta["id"] == "gen-stream-meta"962        assert meta["created"] == 1700000000963        assert meta["object"] == "chat.completion.chunk"964965    async def test_astream_response_metadata_fields(self) -> None:966        """Test response-level metadata in async streaming response_metadata."""967        model = _make_model()968        model.client = MagicMock()969        stream_chunks: list[dict[str, Any]] = [970            {971                "choices": [972                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}973                ],974                "model": "anthropic/claude-sonnet-4-5",975                "system_fingerprint": "fp_async123",976                "object": "chat.completion.chunk",977                "created": 1700000000.0,978                "id": "gen-astream-meta",979            },980            {981                "choices": [982                    {983                        "delta": {},984                        "finish_reason": "stop",985                        "native_finish_reason": "end_turn",986                        "index": 0,987                    }988                ],989                "model": "anthropic/claude-sonnet-4-5",990                "system_fingerprint": "fp_async123",991                "object": "chat.completion.chunk",992                "created": 1700000000.0,993                "id": "gen-astream-meta",994            },995        ]996        model.client.chat.send_async = AsyncMock(997            return_value=_MockAsyncStream(stream_chunks)998        )9991000        chunks = [c async for c in model.astream("Hello")]1001        assert len(chunks) >= 210021003        # Find the chunk with finish_reason (final metadata chunk)1004        final = [1005            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"1006        ]1007        assert len(final) == 11008        meta = final[0].response_metadata1009        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"1010        assert meta["system_fingerprint"] == "fp_async123"1011        assert meta["native_finish_reason"] == "end_turn"1012        assert meta["id"] == "gen-astream-meta"1013        assert meta["created"] == 17000000001014        assert meta["object"] == "chat.completion.chunk"101510161017# ===========================================================================1018# Request payload verification1019# ===========================================================================102010211022class TestRequestPayload:1023    """Tests verifying the exact dict sent to the SDK."""10241025    @pytest.fixture(autouse=True)1026    def _clear_openrouter_env(self, monkeypatch: pytest.MonkeyPatch) -> None:1027        """Clear env vars that would otherwise leak into tests via `from_env`."""1028        monkeypatch.delenv("OPENROUTER_SESSION_ID", raising=False)10291030    def test_message_format_in_payload(self) -> None:1031        """Test that messages are formatted correctly in the SDK call."""1032        model = _make_model(temperature=0)1033        model.client = MagicMock()1034        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)10351036        model.invoke(1037            [1038                SystemMessage(content="You are helpful."),1039                HumanMessage(content="Hi"),1040            ]1041        )1042        call_kwargs = model.client.chat.send.call_args[1]1043        assert call_kwargs["messages"] == [1044            {"role": "system", "content": "You are helpful."},1045            {"role": "user", "content": "Hi"},1046        ]10471048    def test_model_kwargs_forwarded(self) -> None:1049        """Test that extra model_kwargs are included in the SDK call."""1050        model = _make_model(model_kwargs={"top_k": 50})1051        model.client = MagicMock()1052        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)10531054        model.invoke("Hi")1055        call_kwargs = model.client.chat.send.call_args[1]1056        assert call_kwargs["top_k"] == 5010571058    def test_stop_sequences_in_payload(self) -> None:1059        """Test that stop sequences are passed to the SDK."""1060        model = _make_model()1061        model.client = MagicMock()1062        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)10631064        model.invoke("Hi", stop=["END"])1065        call_kwargs = model.client.chat.send.call_args[1]1066        assert call_kwargs["stop"] == ["END"]10671068    def test_tool_format_in_payload(self) -> None:1069        """Test that tools are formatted in OpenAI-compatible structure."""1070        model = _make_model()1071        model.client = MagicMock()1072        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)10731074        bound = model.bind_tools([GetWeather])1075        bound.invoke("What's the weather?")1076        call_kwargs = model.client.chat.send.call_args[1]1077        tools = call_kwargs["tools"]1078        assert len(tools) == 11079        assert tools[0]["type"] == "function"1080        assert tools[0]["function"]["name"] == "GetWeather"1081        assert "parameters" in tools[0]["function"]10821083    def test_tool_cache_control_preserved_in_payload(self) -> None:1084        """Test that top-level `cache_control` on a tool dict is preserved."""1085        model = _make_model()1086        model.client = MagicMock()1087        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)10881089        tool = {1090            "type": "function",1091            "function": {1092                "name": "GetWeather",1093                "description": "Get the weather.",1094                "parameters": {"type": "object", "properties": {}},1095            },1096            "cache_control": {"type": "ephemeral"},1097        }1098        bound = model.bind_tools([tool])1099        bound.invoke("What's the weather?")1100        call_kwargs = model.client.chat.send.call_args[1]1101        tools = call_kwargs["tools"]1102        assert len(tools) == 11103        assert tools[0]["cache_control"] == {"type": "ephemeral"}11041105    def test_openrouter_params_in_payload(self) -> None:1106        """Test that OpenRouter-specific params appear in the SDK call."""1107        model = _make_model(1108            reasoning={"effort": "high"},1109            openrouter_provider={"order": ["Anthropic"]},1110            route="fallback",1111        )1112        model.client = MagicMock()1113        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11141115        model.invoke("Hi")1116        call_kwargs = model.client.chat.send.call_args[1]1117        assert call_kwargs["reasoning"] == {"effort": "high"}1118        assert call_kwargs["provider"] == {"order": ["Anthropic"]}1119        assert call_kwargs["route"] == "fallback"11201121    def test_session_id_and_trace_in_payload(self) -> None:1122        """Test that session_id and trace are forwarded to the SDK."""1123        model = _make_model(1124            session_id="session-abc",1125            trace={"trace_id": "trace-1", "span_name": "summarize"},1126        )1127        model.client = MagicMock()1128        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11291130        model.invoke("Hi")1131        call_kwargs = model.client.chat.send.call_args[1]1132        assert call_kwargs["session_id"] == "session-abc"1133        assert call_kwargs["trace"] == {1134            "trace_id": "trace-1",1135            "span_name": "summarize",1136        }11371138    def test_session_id_and_trace_omitted_when_unset(self) -> None:1139        """Test that session_id and trace are omitted when not configured."""1140        model = _make_model()1141        model.client = MagicMock()1142        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11431144        model.invoke("Hi")1145        call_kwargs = model.client.chat.send.call_args[1]1146        assert "session_id" not in call_kwargs1147        assert "trace" not in call_kwargs11481149    def test_session_id_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:1150        """Test that session_id falls back to OPENROUTER_SESSION_ID env var."""1151        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session-xyz")1152        model = _make_model()1153        assert model.session_id == "env-session-xyz"11541155        model.client = MagicMock()1156        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)1157        model.invoke("Hi")1158        call_kwargs = model.client.chat.send.call_args[1]1159        assert call_kwargs["session_id"] == "env-session-xyz"11601161    def test_session_id_constructor_overrides_env(1162        self, monkeypatch: pytest.MonkeyPatch1163    ) -> None:1164        """Test that an explicit session_id wins over the env var."""1165        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session")1166        model = _make_model(session_id="explicit-session")1167        assert model.session_id == "explicit-session"11681169        model.client = MagicMock()1170        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)1171        model.invoke("Hi")1172        call_kwargs = model.client.chat.send.call_args[1]1173        assert call_kwargs["session_id"] == "explicit-session"11741175    def test_session_id_per_call_override(self) -> None:1176        """Test that a per-call session_id kwarg overrides the constructor value."""1177        model = _make_model(session_id="constructor-session")1178        model.client = MagicMock()1179        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11801181        model.invoke("Hi", session_id="call-session")1182        first_call_kwargs = model.client.chat.send.call_args[1]1183        assert first_call_kwargs["session_id"] == "call-session"11841185        # Per-call override must not mutate the constructor value, and the next1186        # call without the kwarg should fall back to the constructor's value.1187        assert model.session_id == "constructor-session"1188        model.invoke("Hi")1189        second_call_kwargs = model.client.chat.send.call_args[1]1190        assert second_call_kwargs["session_id"] == "constructor-session"11911192    def test_trace_per_call_override(self) -> None:1193        """Test that a per-call trace kwarg overrides the constructor value."""1194        constructor_trace = {"trace_id": "constructor-trace"}1195        call_trace = {"trace_id": "call-trace", "span_name": "summarize"}1196        model = _make_model(trace=constructor_trace)1197        model.client = MagicMock()1198        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)11991200        model.invoke("Hi", trace=call_trace)1201        first_call_kwargs = model.client.chat.send.call_args[1]1202        assert first_call_kwargs["trace"] == call_trace12031204        assert model.trace == constructor_trace1205        model.invoke("Hi")1206        second_call_kwargs = model.client.chat.send.call_args[1]1207        assert second_call_kwargs["trace"] == constructor_trace12081209    def test_empty_session_id_treated_as_unset(1210        self, monkeypatch: pytest.MonkeyPatch1211    ) -> None:1212        """Test that empty `session_id` (constructor or env) is not forwarded."""1213        # Explicit empty string on the constructor.1214        model = _make_model(session_id="")1215        model.client = MagicMock()1216        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)1217        model.invoke("Hi")1218        assert "session_id" not in model.client.chat.send.call_args[1]12191220        # Empty string sourced from the env var.1221        monkeypatch.setenv("OPENROUTER_SESSION_ID", "")1222        env_model = _make_model()1223        env_model.client = MagicMock()1224        env_model.client.chat.send.return_value = _make_sdk_response(1225            _SIMPLE_RESPONSE_DICT1226        )1227        env_model.invoke("Hi")1228        assert "session_id" not in env_model.client.chat.send.call_args[1]122912301231# ===========================================================================1232# bind_tools tests1233# ===========================================================================123412351236class TestBindTools:1237    """Tests for the bind_tools public method."""12381239    @pytest.mark.parametrize(1240        "tool_choice",1241        [1242            "auto",1243            "none",1244            "required",1245            "GetWeather",1246            {"type": "function", "function": {"name": "GetWeather"}},1247            None,1248        ],1249    )1250    def test_bind_tools_tool_choice(self, tool_choice: Any) -> None:1251        """Test bind_tools accepts various tool_choice values."""1252        model = _make_model()1253        bound = model.bind_tools(1254            [GetWeather, GenerateUsername], tool_choice=tool_choice1255        )1256        assert isinstance(bound, RunnableBinding)12571258    def test_bind_tools_bool_true_single_tool(self) -> None:1259        """Test bind_tools with tool_choice=True and a single tool."""1260        model = _make_model()1261        bound = model.bind_tools([GetWeather], tool_choice=True)1262        assert isinstance(bound, RunnableBinding)1263        kwargs = bound.kwargs1264        assert kwargs["tool_choice"] == {1265            "type": "function",1266            "function": {"name": "GetWeather"},1267        }12681269    def test_bind_tools_bool_true_multiple_tools_raises(self) -> None:1270        """Test bind_tools with tool_choice=True and multiple tools raises."""1271        model = _make_model()1272        with pytest.raises(ValueError, match="tool_choice can only be True"):1273            model.bind_tools([GetWeather, GenerateUsername], tool_choice=True)12741275    def test_bind_tools_any_maps_to_required(self) -> None:1276        """Test that tool_choice='any' is mapped to 'required'."""1277        model = _make_model()1278        bound = model.bind_tools([GetWeather], tool_choice="any")1279        assert isinstance(bound, RunnableBinding)1280        assert bound.kwargs["tool_choice"] == "required"12811282    def test_bind_tools_string_name_becomes_dict(self) -> None:1283        """Test that a specific tool name string is converted to a dict."""1284        model = _make_model()1285        bound = model.bind_tools([GetWeather], tool_choice="GetWeather")1286        assert isinstance(bound, RunnableBinding)1287        assert bound.kwargs["tool_choice"] == {1288            "type": "function",1289            "function": {"name": "GetWeather"},1290        }12911292    def test_bind_tools_formats_tools_correctly(self) -> None:1293        """Test that tools are converted to OpenAI format."""1294        model = _make_model()1295        bound = model.bind_tools([GetWeather])1296        assert isinstance(bound, RunnableBinding)1297        tools = bound.kwargs["tools"]1298        assert len(tools) == 11299        assert tools[0]["type"] == "function"1300        assert tools[0]["function"]["name"] == "GetWeather"13011302    def test_bind_tools_no_choice_omits_key(self) -> None:1303        """Test that tool_choice=None does not set tool_choice in kwargs."""1304        model = _make_model()1305        bound = model.bind_tools([GetWeather], tool_choice=None)1306        assert isinstance(bound, RunnableBinding)1307        assert "tool_choice" not in bound.kwargs13081309    def test_bind_tools_strict_forwarded(self) -> None:1310        """Test that strict param is forwarded to tool definitions."""1311        model = _make_model()1312        bound = model.bind_tools([GetWeather], strict=True)1313        assert isinstance(bound, RunnableBinding)1314        tools = bound.kwargs["tools"]1315        assert tools[0]["function"]["strict"] is True13161317    def test_bind_tools_strict_none_by_default(self) -> None:1318        """Test that strict is not set when not provided."""1319        model = _make_model()1320        bound = model.bind_tools([GetWeather])1321        assert isinstance(bound, RunnableBinding)1322        tools = bound.kwargs["tools"]1323        assert "strict" not in tools[0]["function"]13241325    def test_bind_tools_parallel_tool_calls_forwarded(self) -> None:1326        """Test that parallel_tool_calls is forwarded to the request kwargs."""1327        model = _make_model()1328        bound = model.bind_tools([GetWeather], parallel_tool_calls=False)1329        assert isinstance(bound, RunnableBinding)1330        assert bound.kwargs["parallel_tool_calls"] is False13311332    def test_bind_tools_parallel_tool_calls_none_omits_key(self) -> None:1333        """Test that parallel_tool_calls=None does not set the key in kwargs."""1334        model = _make_model()1335        bound = model.bind_tools([GetWeather])1336        assert isinstance(bound, RunnableBinding)1337        assert "parallel_tool_calls" not in bound.kwargs133813391340# ===========================================================================1341# with_structured_output tests1342# ===========================================================================134313441345class TestWithStructuredOutput:1346    """Tests for the with_structured_output public method."""13471348    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1349    @pytest.mark.parametrize("include_raw", ["yes", "no"])1350    def test_with_structured_output_pydantic(1351        self,1352        method: Literal["function_calling", "json_schema"],1353        include_raw: str,1354    ) -> None:1355        """Test with_structured_output using a Pydantic schema."""1356        model = _make_model()1357        structured = model.with_structured_output(1358            GenerateUsername, method=method, include_raw=(include_raw == "yes")1359        )1360        assert structured is not None13611362    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1363    def test_with_structured_output_dict_schema(1364        self,1365        method: Literal["function_calling", "json_schema"],1366    ) -> None:1367        """Test with_structured_output using a JSON schema dict."""1368        schema = GenerateUsername.model_json_schema()1369        model = _make_model()1370        structured = model.with_structured_output(schema, method=method)1371        assert structured is not None13721373    def test_with_structured_output_none_schema_function_calling_raises(self) -> None:1374        """Test that schema=None with function_calling raises ValueError."""1375        model = _make_model()1376        with pytest.raises(ValueError, match="schema must be specified"):1377            model.with_structured_output(None, method="function_calling")13781379    def test_with_structured_output_none_schema_json_schema_raises(self) -> None:1380        """Test that schema=None with json_schema raises ValueError."""1381        model = _make_model()1382        with pytest.raises(ValueError, match="schema must be specified"):1383            model.with_structured_output(None, method="json_schema")13841385    def test_with_structured_output_invalid_method_raises(self) -> None:1386        """Test that an unrecognized method raises ValueError."""1387        model = _make_model()1388        with pytest.raises(ValueError, match="Unrecognized method"):1389            model.with_structured_output(1390                GenerateUsername,1391                method="invalid",  # type: ignore[arg-type]1392            )13931394    def test_with_structured_output_json_schema_sets_response_format(self) -> None:1395        """Test that json_schema method sets response_format correctly."""1396        model = _make_model()1397        structured = model.with_structured_output(1398            GenerateUsername, method="json_schema"1399        )1400        # The first step in the chain should be the bound model1401        bound = structured.first  # type: ignore[attr-defined]1402        assert isinstance(bound, RunnableBinding)1403        rf = bound.kwargs["response_format"]1404        assert rf["type"] == "json_schema"1405        assert rf["json_schema"]["name"] == "GenerateUsername"14061407    def test_with_structured_output_json_mode_warns_and_falls_back(self) -> None:1408        """Test that json_mode warns and falls back to json_schema."""1409        model = _make_model()1410        with pytest.warns(match="Defaulting to 'json_schema'"):1411            structured = model.with_structured_output(1412                GenerateUsername,1413                method="json_mode",  # type: ignore[arg-type]1414            )1415        bound = structured.first  # type: ignore[attr-defined]1416        assert isinstance(bound, RunnableBinding)1417        rf = bound.kwargs["response_format"]1418        assert rf["type"] == "json_schema"14191420    def test_with_structured_output_strict_function_calling(self) -> None:1421        """Test that strict is forwarded for function_calling method."""1422        model = _make_model()1423        structured = model.with_structured_output(1424            GenerateUsername, method="function_calling", strict=True1425        )1426        bound = structured.first  # type: ignore[attr-defined]1427        assert isinstance(bound, RunnableBinding)1428        tools = bound.kwargs["tools"]1429        assert tools[0]["function"]["strict"] is True14301431    def test_with_structured_output_strict_json_schema(self) -> None:1432        """Test that strict is forwarded for json_schema method."""1433        model = _make_model()1434        structured = model.with_structured_output(1435            GenerateUsername, method="json_schema", strict=True1436        )1437        bound = structured.first  # type: ignore[attr-defined]1438        assert isinstance(bound, RunnableBinding)1439        rf = bound.kwargs["response_format"]1440        assert rf["json_schema"]["strict"] is True14411442    def test_with_structured_output_json_mode_with_strict_warns_and_forwards(1443        self,1444    ) -> None:1445        """Test json_mode with strict warns and falls back to json_schema."""1446        model = _make_model()1447        with pytest.warns(match="Defaulting to 'json_schema'"):1448            structured = model.with_structured_output(1449                GenerateUsername,1450                method="json_mode",  # type: ignore[arg-type]1451                strict=True,1452            )1453        bound = structured.first  # type: ignore[attr-defined]1454        assert isinstance(bound, RunnableBinding)1455        rf = bound.kwargs["response_format"]1456        assert rf["type"] == "json_schema"1457        assert rf["json_schema"]["strict"] is True145814591460# ===========================================================================1461# Message conversion tests1462# ===========================================================================146314641465class TestMessageConversion:1466    """Tests for message conversion functions."""14671468    def test_human_message_to_dict(self) -> None:1469        """Test converting HumanMessage to dict."""1470        msg = HumanMessage(content="Hello")1471        result = _convert_message_to_dict(msg)1472        assert result == {"role": "user", "content": "Hello"}14731474    def test_system_message_to_dict(self) -> None:1475        """Test converting SystemMessage to dict."""1476        msg = SystemMessage(content="You are helpful.")1477        result = _convert_message_to_dict(msg)1478        assert result == {"role": "system", "content": "You are helpful."}14791480    def test_ai_message_to_dict(self) -> None:1481        """Test converting AIMessage to dict."""1482        msg = AIMessage(content="Hi there!")1483        result = _convert_message_to_dict(msg)1484        assert result == {"role": "assistant", "content": "Hi there!"}14851486    def test_ai_message_with_reasoning_content_to_dict(self) -> None:1487        """Test that reasoning_content is preserved when converting back to dict."""1488        msg = AIMessage(1489            content="The answer is 42.",1490            additional_kwargs={"reasoning_content": "Let me think about this..."},1491        )1492        result = _convert_message_to_dict(msg)1493        assert result["role"] == "assistant"1494        assert result["content"] == "The answer is 42."1495        assert result["reasoning"] == "Let me think about this..."14961497    def test_ai_message_with_fragmented_reasoning_details_merged(self) -> None:1498        """Fragmented `reasoning_details` are merged before serialization.14991500        Float `index` values mirror what `ChatOpenRouter.stream()` produces1501        (the OpenRouter SDK coerces `index` via Pydantic). With float1502        `index`, `langchain_core.utils._merge.merge_lists` does not auto-merge1503        list entries (its index-match path requires `int`), so fragments1504        accumulate as separate list items and require this helper to merge1505        them before the next API turn.1506        """1507        details = [1508            {1509                "type": "reasoning.text",1510                "text": "The",1511                "format": "anthropic-claude-v1",1512                "index": 0.0,1513            },1514            {1515                "type": "reasoning.text",1516                "text": " user wants",1517                "format": "anthropic-claude-v1",1518                "index": 0.0,1519            },1520            {1521                "type": "reasoning.text",1522                "signature": "sig_abc123",1523                "format": "anthropic-claude-v1",1524                "index": 0.0,1525            },1526        ]1527        msg = AIMessage(1528            content="Answer",1529            additional_kwargs={"reasoning_details": details},1530        )1531        result = _convert_message_to_dict(msg)1532        assert result["reasoning_details"] == [1533            {1534                "type": "reasoning.text",1535                "text": "The user wants",1536                "format": "anthropic-claude-v1",1537                "signature": "sig_abc123",1538                "index": 0.0,1539            }1540        ]1541        assert "reasoning" not in result15421543    def test_ai_message_distinct_reasoning_details_preserved(self) -> None:1544        """Distinct entries (different `index`) are not merged."""1545        details = [1546            {"type": "reasoning.text", "text": "First thought", "index": 0},1547            {"type": "reasoning.text", "text": "Second thought", "index": 1},1548        ]1549        msg = AIMessage(1550            content="Answer",1551            additional_kwargs={"reasoning_details": details},1552        )1553        result = _convert_message_to_dict(msg)1554        assert result["reasoning_details"] == details15551556    def test_ai_message_reasoning_details_strips_responses_ids(self) -> None:1557        """OpenAI Responses `rs_*` item IDs are stripped before replay."""1558        response_id = "rs_053a05e24b0da75e0169fa358ea9fc81908b18aff8157798c1"1559        details = [1560            {1561                "type": "reasoning.text",1562                "id": response_id,1563                "text": "step-by-step",1564                "index": 0,1565            }1566        ]1567        msg = AIMessage(1568            content="Answer",1569            additional_kwargs={"reasoning_details": details},1570        )1571        result = _convert_message_to_dict(msg)1572        assert result["reasoning_details"] == [1573            {"type": "reasoning.text", "text": "step-by-step", "index": 0}1574        ]1575        assert response_id.startswith("rs_")1576        assert details[0]["id"] == response_id15771578    def test_ai_message_reasoning_details_preserves_non_responses_ids(self) -> None:1579        """Non-Responses IDs are preserved in reasoning details."""1580        details = [1581            {1582                "type": "reasoning.text",1583                "id": "reasoning_abc123",1584                "text": "step-by-step",1585            }1586        ]1587        msg = AIMessage(1588            content="Answer",1589            additional_kwargs={"reasoning_details": details},1590        )1591        result = _convert_message_to_dict(msg)1592        assert result["reasoning_details"] == details15931594    def test_ai_message_unindexed_reasoning_details_not_merged(self) -> None:1595        """Entries without an `index` are passed through unchanged."""1596        details = [1597            {"type": "reasoning.text", "text": "First"},1598            {"type": "reasoning.text", "text": "Second"},1599        ]1600        msg = AIMessage(1601            content="Answer",1602            additional_kwargs={"reasoning_details": details},1603        )1604        result = _convert_message_to_dict(msg)1605        assert result["reasoning_details"] == details16061607    def test_ai_message_interleaved_index_fragments_preserved(self) -> None:1608        """Only consecutive same-`index` runs merge; interleaved runs stay split."""1609        details = [1610            {"type": "reasoning.text", "text": "A", "index": 0},1611            {"type": "reasoning.text", "text": "B", "index": 1},1612            {"type": "reasoning.text", "text": "C", "index": 0},1613            {"type": "reasoning.text", "text": "D", "index": 1},1614        ]1615        msg = AIMessage(1616            content="Answer",1617            additional_kwargs={"reasoning_details": details},1618        )1619        result = _convert_message_to_dict(msg)1620        assert result["reasoning_details"] == details16211622    def test_ai_message_fragment_metadata_preserved(self) -> None:1623        """Test that metadata from later fragments is preserved after merge."""1624        details = [1625            {"type": "reasoning.text", "text": "thinking...", "index": 0},1626            {1627                "type": "reasoning.text",1628                "text": " done",1629                "index": 0,1630                "signature": "sig_abc123",1631            },1632        ]1633        msg = AIMessage(1634            content="Answer",1635            additional_kwargs={"reasoning_details": details},1636        )1637        result = _convert_message_to_dict(msg)1638        assert len(result["reasoning_details"]) == 11639        assert result["reasoning_details"][0]["text"] == "thinking... done"1640        assert result["reasoning_details"][0]["signature"] == "sig_abc123"16411642    def test_streamed_reasoning_details_roundtrip_to_next_turn_payload(self) -> None:1643        """Test the chunk-merge-to-next-turn serialization path from issue #36400."""1644        chunk_dicts = [1645            {"choices": [{"delta": {"role": "assistant", "content": ""}, "index": 0}]},1646            {1647                "choices": [1648                    {1649                        "delta": {1650                            "reasoning_details": [1651                                {1652                                    "type": "reasoning.text",1653                                    "text": "The",1654                                    "format": "anthropic-claude-v1",1655                                    "index": 0.0,1656                                }1657                            ]1658                        },1659                        "index": 0,1660                    }1661                ]1662            },1663            {1664                "choices": [1665                    {1666                        "delta": {1667                            "reasoning_details": [1668                                {1669                                    "type": "reasoning.text",1670                                    "text": " user wants",1671                                    "format": "anthropic-claude-v1",1672                                    "index": 0.0,1673                                }1674                            ]1675                        },1676                        "index": 0,1677                    }1678                ]1679            },1680            {1681                "choices": [1682                    {1683                        "delta": {1684                            "reasoning_details": [1685                                {1686                                    "type": "reasoning.text",1687                                    "signature": "sig_abc123",1688                                    "format": "anthropic-claude-v1",1689                                    "index": 0.0,1690                                }1691                            ]1692                        },1693                        "index": 0,1694                    }1695                ]1696            },1697            {"choices": [{"delta": {"content": "Answer"}, "index": 0}]},1698        ]1699        chunks = [1700            _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1701            for chunk in chunk_dicts1702        ]1703        merged_chunk = chunks[0]1704        for chunk in chunks[1:]:1705            merged_chunk = merged_chunk + chunk17061707        assert len(merged_chunk.additional_kwargs["reasoning_details"]) == 317081709        msg = AIMessage(1710            content=merged_chunk.content,1711            additional_kwargs=merged_chunk.additional_kwargs,1712            response_metadata=merged_chunk.response_metadata,1713        )17141715        result = _convert_message_to_dict(msg)1716        assert result["reasoning_details"] == [1717            {1718                "type": "reasoning.text",1719                "text": "The user wants",1720                "format": "anthropic-claude-v1",1721                "signature": "sig_abc123",1722                "index": 0.0,1723            }1724        ]17251726    def test_ai_message_with_both_reasoning_fields_to_dict(self) -> None:1727        """Test that both reasoning_content and reasoning_details are preserved."""1728        details = [{"type": "reasoning.text", "text": "detailed thinking"}]1729        msg = AIMessage(1730            content="Answer",1731            additional_kwargs={1732                "reasoning_content": "I thought about it",1733                "reasoning_details": details,1734            },1735        )1736        result = _convert_message_to_dict(msg)1737        assert result["reasoning"] == "I thought about it"1738        assert result["reasoning_details"] == details17391740    def test_reasoning_roundtrip_through_dict(self) -> None:1741        """Test that reasoning survives dict -> message -> dict roundtrip."""1742        original_dict = {1743            "role": "assistant",1744            "content": "The answer",1745            "reasoning": "My thinking process",1746            "reasoning_details": [{"type": "reasoning.text", "text": "step-by-step"}],1747        }1748        msg = _convert_dict_to_message(original_dict)1749        result = _convert_message_to_dict(msg)1750        assert result["reasoning"] == "My thinking process"1751        assert result["reasoning_details"] == original_dict["reasoning_details"]17521753    def test_tool_message_to_dict(self) -> None:1754        """Test converting ToolMessage to dict."""1755        msg = ToolMessage(content="result", tool_call_id="call_123")1756        result = _convert_message_to_dict(msg)1757        assert result == {1758            "role": "tool",1759            "content": "result",1760            "tool_call_id": "call_123",1761        }17621763    def test_chat_message_to_dict(self) -> None:1764        """Test converting ChatMessage to dict."""1765        msg = ChatMessage(content="Hello", role="developer")1766        result = _convert_message_to_dict(msg)1767        assert result == {"role": "developer", "content": "Hello"}17681769    def test_ai_message_with_tool_calls_to_dict(self) -> None:1770        """Test converting AIMessage with tool calls to dict."""1771        msg = AIMessage(1772            content="",1773            tool_calls=[1774                {1775                    "name": "get_weather",1776                    "args": {"location": "SF"},1777                    "id": "call_1",1778                    "type": "tool_call",1779                }1780            ],1781        )1782        result = _convert_message_to_dict(msg)1783        assert result["role"] == "assistant"1784        assert result["content"] is None1785        assert len(result["tool_calls"]) == 11786        assert result["tool_calls"][0]["function"]["name"] == "get_weather"17871788    def test_dict_to_ai_message(self) -> None:1789        """Test converting dict to AIMessage."""1790        d = {"role": "assistant", "content": "Hello!"}1791        msg = _convert_dict_to_message(d)1792        assert isinstance(msg, AIMessage)1793        assert msg.content == "Hello!"17941795    def test_dict_to_ai_message_with_reasoning(self) -> None:1796        """Test that reasoning is extracted from response dict."""1797        d = {1798            "role": "assistant",1799            "content": "Answer",1800            "reasoning": "Let me think...",1801        }1802        msg = _convert_dict_to_message(d)1803        assert isinstance(msg, AIMessage)1804        assert msg.additional_kwargs["reasoning_content"] == "Let me think..."18051806    def test_dict_to_ai_message_with_tool_calls(self) -> None:1807        """Test converting dict with tool calls to AIMessage."""1808        d = {1809            "role": "assistant",1810            "content": "",1811            "tool_calls": [1812                {1813                    "id": "call_1",1814                    "type": "function",1815                    "function": {1816                        "name": "get_weather",1817                        "arguments": '{"location": "SF"}',1818                    },1819                }1820            ],1821        }1822        msg = _convert_dict_to_message(d)1823        assert isinstance(msg, AIMessage)1824        assert len(msg.tool_calls) == 11825        assert msg.tool_calls[0]["name"] == "get_weather"18261827    def test_dict_to_ai_message_with_invalid_tool_calls(self) -> None:1828        """Test that malformed tool calls produce invalid_tool_calls."""1829        d = {1830            "role": "assistant",1831            "content": "",1832            "tool_calls": [1833                {1834                    "id": "call_bad",1835                    "type": "function",1836                    "function": {1837                        "name": "get_weather",1838                        "arguments": "not-valid-json{{{",1839                    },1840                }1841            ],1842        }1843        msg = _convert_dict_to_message(d)1844        assert isinstance(msg, AIMessage)1845        assert len(msg.invalid_tool_calls) == 11846        assert len(msg.tool_calls) == 01847        assert msg.invalid_tool_calls[0]["name"] == "get_weather"18481849    def test_dict_to_human_message(self) -> None:1850        """Test converting dict to HumanMessage."""1851        d = {"role": "user", "content": "Hi"}1852        msg = _convert_dict_to_message(d)1853        assert isinstance(msg, HumanMessage)18541855    def test_dict_to_system_message(self) -> None:1856        """Test converting dict to SystemMessage."""1857        d = {"role": "system", "content": "Be helpful"}1858        msg = _convert_dict_to_message(d)1859        assert isinstance(msg, SystemMessage)18601861    def test_dict_to_tool_message(self) -> None:1862        """Test converting dict with role=tool to ToolMessage."""1863        d = {1864            "role": "tool",1865            "content": "result data",1866            "tool_call_id": "call_42",1867            "name": "get_weather",1868        }1869        msg = _convert_dict_to_message(d)1870        assert isinstance(msg, ToolMessage)1871        assert msg.content == "result data"1872        assert msg.tool_call_id == "call_42"1873        assert msg.additional_kwargs["name"] == "get_weather"18741875    def test_dict_to_chat_message_unknown_role(self) -> None:1876        """Test that unrecognized roles fall back to ChatMessage."""1877        d = {"role": "developer", "content": "Some content"}1878        with pytest.warns(UserWarning, match="Unrecognized message role"):1879            msg = _convert_dict_to_message(d)1880        assert isinstance(msg, ChatMessage)1881        assert msg.role == "developer"1882        assert msg.content == "Some content"18831884    def test_ai_message_with_list_content_filters_non_text(self) -> None:1885        """Test that non-text blocks are filtered from AIMessage list content."""1886        msg = AIMessage(1887            content=[1888                {"type": "text", "text": "Hello"},1889                {"type": "image_url", "image_url": {"url": "http://example.com"}},1890            ]1891        )1892        result = _convert_message_to_dict(msg)1893        assert result["content"] == [{"type": "text", "text": "Hello"}]189418951896# ===========================================================================1897# _create_chat_result tests1898# ===========================================================================189919001901class TestCreateChatResult:1902    """Tests for _create_chat_result."""19031904    def test_model_provider_in_response_metadata(self) -> None:1905        """Test that model_provider is set in response metadata."""1906        model = _make_model()1907        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1908        assert (1909            result.generations[0].message.response_metadata.get("model_provider")1910            == "openrouter"1911        )19121913    def test_provider_in_response_metadata(self) -> None:1914        """Test that upstream provider is surfaced in response_metadata."""1915        model = _make_model()1916        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1917        msg = result.generations[0].message1918        assert isinstance(msg, AIMessage)1919        assert msg.response_metadata["provider"] == "Anthropic"19201921    def test_provider_absent_when_not_returned(self) -> None:1922        """Test that provider is not in response_metadata when API omits it."""1923        model = _make_model()1924        response: dict[str, Any] = {1925            "choices": [1926                {1927                    "message": {"role": "assistant", "content": "Hello!"},1928                    "finish_reason": "stop",1929                }1930            ],1931        }1932        result = model._create_chat_result(response)1933        msg = result.generations[0].message1934        assert isinstance(msg, AIMessage)1935        assert "provider" not in msg.response_metadata19361937    def test_reasoning_from_response(self) -> None:1938        """Test that reasoning content is extracted from response."""1939        model = _make_model()1940        response_dict: dict[str, Any] = {1941            "choices": [1942                {1943                    "message": {1944                        "role": "assistant",1945                        "content": "Answer",1946                        "reasoning": "Let me think...",1947                    },1948                    "finish_reason": "stop",1949                }1950            ],1951        }1952        result = model._create_chat_result(response_dict)1953        assert (1954            result.generations[0].message.additional_kwargs.get("reasoning_content")1955            == "Let me think..."1956        )19571958    def test_usage_metadata_created(self) -> None:1959        """Test that usage metadata is created from token usage."""1960        model = _make_model()1961        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1962        msg = result.generations[0].message1963        assert isinstance(msg, AIMessage)1964        usage = msg.usage_metadata1965        assert usage is not None1966        assert usage["input_tokens"] == 101967        assert usage["output_tokens"] == 51968        assert usage["total_tokens"] == 1519691970    def test_tool_calls_in_response(self) -> None:1971        """Test that tool calls are extracted from response."""1972        model = _make_model()1973        result = model._create_chat_result(_TOOL_RESPONSE_DICT)1974        msg = result.generations[0].message1975        assert isinstance(msg, AIMessage)1976        assert len(msg.tool_calls) == 11977        assert msg.tool_calls[0]["name"] == "GetWeather"19781979    def test_response_model_in_llm_output(self) -> None:1980        """Test that the response model is included in llm_output."""1981        model = _make_model()1982        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1983        assert result.llm_output is not None1984        assert result.llm_output["model_name"] == MODEL_NAME19851986    def test_response_model_propagated_to_llm_output(self) -> None:1987        """Test that llm_output uses response model when available."""1988        model = _make_model()1989        response = {1990            **_SIMPLE_RESPONSE_DICT,1991            "model": MODEL_NAME,1992        }1993        result = model._create_chat_result(response)1994        assert result.llm_output is not None1995        assert result.llm_output["model_name"] == MODEL_NAME19961997    def test_system_fingerprint_in_metadata(self) -> None:1998        """Test that system_fingerprint is included in response_metadata."""1999        model = _make_model()2000        response = {

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.