libs/partners/openrouter/tests/unit_tests/test_chat_models.py PYTHON 3,407 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 3,407.
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    _has_file_content_blocks,35    _wrap_messages_for_sdk,36)3738MODEL_NAME = "openai/gpt-4o-mini"394041def _make_model(**kwargs: Any) -> ChatOpenRouter:42    """Create a `ChatOpenRouter` with sane defaults for unit tests."""43    defaults: dict[str, Any] = {"model": MODEL_NAME, "api_key": SecretStr("test-key")}44    defaults.update(kwargs)45    return ChatOpenRouter(**defaults)464748# ---------------------------------------------------------------------------49# Pydantic schemas used across multiple test classes50# ---------------------------------------------------------------------------515253class GetWeather(BaseModel):54    """Get the current weather in a given location."""5556    location: str = Field(description="The city and state")575859class GenerateUsername(BaseModel):60    """Generate a username from a full name."""6162    name: str = Field(description="The full name")63    hair_color: str = Field(description="The hair color")646566# ---------------------------------------------------------------------------67# Mock helpers for SDK responses68# ---------------------------------------------------------------------------6970_SIMPLE_RESPONSE_DICT: dict[str, Any] = {71    "id": "gen-abc123",72    "choices": [73        {74            "message": {"role": "assistant", "content": "Hello!"},75            "finish_reason": "stop",76            "index": 0,77        }78    ],79    "usage": {80        "prompt_tokens": 10,81        "completion_tokens": 5,82        "total_tokens": 15,83    },84    "model": MODEL_NAME,85    "object": "chat.completion",86    "created": 1700000000.0,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]148149150def _make_sdk_response(response_dict: dict[str, Any]) -> MagicMock:151    """Build a MagicMock that behaves like an SDK ChatResponse."""152    mock = MagicMock()153    mock.model_dump.return_value = response_dict154    return mock155156157class _MockSyncStream:158    """Synchronous iterator that mimics the SDK EventStream."""159160    def __init__(self, chunks: list[dict[str, Any]]) -> None:161        self._chunks = chunks162163    def __iter__(self) -> _MockSyncStream:164        return self165166    def __next__(self) -> MagicMock:167        if not self._chunks:168            raise StopIteration169        chunk = self._chunks.pop(0)170        mock = MagicMock()171        mock.model_dump.return_value = chunk172        return mock173174175class _MockAsyncStream:176    """Async iterator that mimics the SDK EventStreamAsync."""177178    def __init__(self, chunks: list[dict[str, Any]]) -> None:179        self._chunks = list(chunks)180181    def __aiter__(self) -> _MockAsyncStream:182        return self183184    async def __anext__(self) -> MagicMock:185        if not self._chunks:186            raise StopAsyncIteration187        chunk = self._chunks.pop(0)188        mock = MagicMock()189        mock.model_dump.return_value = chunk190        return mock191192193# ===========================================================================194# Instantiation tests195# ===========================================================================196197198class TestChatOpenRouterInstantiation:199    """Tests for `ChatOpenRouter` instantiation."""200201    def test_basic_instantiation(self) -> None:202        """Test basic model instantiation with required params."""203        model = _make_model()204        assert model.model_name == MODEL_NAME205        assert model.model == MODEL_NAME206        assert model.openrouter_api_base is None207208    def test_api_key_from_field(self) -> None:209        """Test that API key is properly set."""210        model = _make_model()211        assert model.openrouter_api_key is not None212        assert model.openrouter_api_key.get_secret_value() == "test-key"213214    def test_api_key_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:215        """Test that API key is read from OPENROUTER_API_KEY env var."""216        monkeypatch.setenv("OPENROUTER_API_KEY", "env-key-123")217        model = ChatOpenRouter(model=MODEL_NAME)218        assert model.openrouter_api_key is not None219        assert model.openrouter_api_key.get_secret_value() == "env-key-123"220221    def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:222        """Test that missing API key raises ValueError."""223        monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)224        with pytest.raises(ValueError, match="OPENROUTER_API_KEY must be set"):225            ChatOpenRouter(model=MODEL_NAME)226227    def test_model_required(self) -> None:228        """Test that model name is required."""229        with pytest.raises((ValueError, TypeError)):230            ChatOpenRouter(api_key=SecretStr("test-key"))  # type: ignore[call-arg]231232    def test_secret_masking(self) -> None:233        """Test that API key is not exposed in string representation."""234        model = _make_model(api_key=SecretStr("super-secret"))235        model_str = str(model)236        assert "super-secret" not in model_str237238    def test_secret_masking_repr(self) -> None:239        """Test that API key is masked in repr too."""240        model = _make_model(api_key=SecretStr("super-secret"))241        assert "super-secret" not in repr(model)242243    def test_api_key_is_secret_str(self) -> None:244        """Test that openrouter_api_key is a SecretStr instance."""245        model = _make_model()246        assert isinstance(model.openrouter_api_key, SecretStr)247248    def test_llm_type(self) -> None:249        """Test _llm_type property."""250        model = _make_model()251        assert model._llm_type == "openrouter-chat"252253    def test_ls_params(self) -> None:254        """Test LangSmith params include openrouter provider."""255        model = _make_model()256        ls_params = model._get_ls_params()257        assert ls_params["ls_provider"] == "openrouter"258259    def test_ls_params_includes_max_tokens(self) -> None:260        """Test that ls_max_tokens is set when max_tokens is configured."""261        model = _make_model(max_tokens=512)262        ls_params = model._get_ls_params()263        assert ls_params["ls_max_tokens"] == 512264265    def test_ls_params_stop_string_wrapped_in_list(self) -> None:266        """Test that a string stop value is wrapped in a list for ls_stop."""267        model = _make_model(stop_sequences="END")268        ls_params = model._get_ls_params()269        assert ls_params["ls_stop"] == ["END"]270271    def test_ls_params_stop_list_passthrough(self) -> None:272        """Test that a list stop value is passed through directly."""273        model = _make_model(stop_sequences=["END", "STOP"])274        ls_params = model._get_ls_params()275        assert ls_params["ls_stop"] == ["END", "STOP"]276277    def test_client_created(self) -> None:278        """Test that OpenRouter SDK client is created."""279        model = _make_model()280        assert model.client is not None281282    def test_client_reused_for_same_params(self) -> None:283        """Test that the SDK client is reused when model is re-validated."""284        model = _make_model()285        client_1 = model.client286        # Re-validate does not replace the existing client287        model.validate_environment()  # type: ignore[operator]288        assert model.client is client_1289290    def test_app_url_passed_to_client(self) -> None:291        """Test that app_url is passed as HTTP-Referer header via httpx clients."""292        with patch("openrouter.OpenRouter") as mock_cls:293            mock_cls.return_value = MagicMock()294            ChatOpenRouter(295                model=MODEL_NAME,296                api_key=SecretStr("test-key"),297                app_url="https://myapp.com",298            )299            call_kwargs = mock_cls.call_args[1]300            assert call_kwargs["client"].headers["HTTP-Referer"] == "https://myapp.com"301302    def test_app_title_passed_to_client(self) -> None:303        """Test that app_title is passed as X-Title header via httpx clients."""304        with patch("openrouter.OpenRouter") as mock_cls:305            mock_cls.return_value = MagicMock()306            ChatOpenRouter(307                model=MODEL_NAME,308                api_key=SecretStr("test-key"),309                app_title="My App",310            )311            call_kwargs = mock_cls.call_args[1]312            assert call_kwargs["client"].headers["X-Title"] == "My App"313314    def test_default_attribution_headers(self) -> None:315        """Test that default attribution headers are sent when not overridden."""316        with patch("openrouter.OpenRouter") as mock_cls:317            mock_cls.return_value = MagicMock()318            ChatOpenRouter(319                model=MODEL_NAME,320                api_key=SecretStr("test-key"),321            )322            call_kwargs = mock_cls.call_args[1]323            sync_headers = call_kwargs["client"].headers324            assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"325            assert sync_headers["X-Title"] == "LangChain"326327    def test_user_attribution_overrides_defaults(self) -> None:328        """Test that user-supplied attribution overrides the defaults."""329        with patch("openrouter.OpenRouter") as mock_cls:330            mock_cls.return_value = MagicMock()331            ChatOpenRouter(332                model=MODEL_NAME,333                api_key=SecretStr("test-key"),334                app_url="https://my-custom-app.com",335                app_title="My Custom App",336            )337            call_kwargs = mock_cls.call_args[1]338            sync_headers = call_kwargs["client"].headers339            assert sync_headers["HTTP-Referer"] == "https://my-custom-app.com"340            assert sync_headers["X-Title"] == "My Custom App"341342    def test_app_categories_passed_to_client(self) -> None:343        """Test that app_categories injects custom httpx clients with header."""344        with patch("openrouter.OpenRouter") as mock_cls:345            mock_cls.return_value = MagicMock()346            ChatOpenRouter(347                model=MODEL_NAME,348                api_key=SecretStr("test-key"),349                app_categories=["cli-agent", "programming-app"],350            )351            call_kwargs = mock_cls.call_args[1]352            # Custom httpx clients should be created353            assert "client" in call_kwargs354            assert "async_client" in call_kwargs355            # Verify the header value is comma-joined356            sync_headers = call_kwargs["client"].headers357            assert sync_headers["X-OpenRouter-Categories"] == (358                "cli-agent,programming-app"359            )360            async_headers = call_kwargs["async_client"].headers361            assert async_headers["X-OpenRouter-Categories"] == (362                "cli-agent,programming-app"363            )364365    def test_app_categories_none_no_categories_header(self) -> None:366        """Test that no X-OpenRouter-Categories header when categories unset."""367        with patch("openrouter.OpenRouter") as mock_cls:368            mock_cls.return_value = MagicMock()369            ChatOpenRouter(370                model=MODEL_NAME,371                api_key=SecretStr("test-key"),372            )373            call_kwargs = mock_cls.call_args[1]374            # httpx clients still created for X-Title default375            sync_headers = call_kwargs["client"].headers376            assert "X-OpenRouter-Categories" not in sync_headers377378    def test_app_categories_empty_list_no_categories_header(self) -> None:379        """Test that an empty list does not inject categories header."""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                app_categories=[],386            )387            call_kwargs = mock_cls.call_args[1]388            sync_headers = call_kwargs["client"].headers389            assert "X-OpenRouter-Categories" not in sync_headers390391    def test_app_categories_with_other_attribution(self) -> None:392        """Test that app_categories coexists with app_url and app_title."""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://myapp.com",399                app_title="My App",400                app_categories=["cli-agent"],401            )402            call_kwargs = mock_cls.call_args[1]403            sync_headers = call_kwargs["client"].headers404            assert sync_headers["HTTP-Referer"] == "https://myapp.com"405            assert sync_headers["X-Title"] == "My App"406            assert sync_headers["X-OpenRouter-Categories"] == "cli-agent"407408    def test_app_title_none_no_x_title_header(self) -> None:409        """Test that X-Title header is omitted when app_title is explicitly None."""410        with patch("openrouter.OpenRouter") as mock_cls:411            mock_cls.return_value = MagicMock()412            ChatOpenRouter(413                model=MODEL_NAME,414                api_key=SecretStr("test-key"),415                app_title=None,416            )417            call_kwargs = mock_cls.call_args[1]418            sync_headers = call_kwargs["client"].headers419            assert "X-Title" not in sync_headers420421    def test_app_url_none_no_referer_header(self) -> None:422        """Test that HTTP-Referer header is omitted when app_url is explicitly None."""423        with patch("openrouter.OpenRouter") as mock_cls:424            mock_cls.return_value = MagicMock()425            ChatOpenRouter(426                model=MODEL_NAME,427                api_key=SecretStr("test-key"),428                app_url=None,429            )430            call_kwargs = mock_cls.call_args[1]431            sync_headers = call_kwargs["client"].headers432            assert "HTTP-Referer" not in sync_headers433434    def test_no_attribution_no_custom_clients(self) -> None:435        """Test that no httpx clients are created when all attribution is None."""436        with patch("openrouter.OpenRouter") as mock_cls:437            mock_cls.return_value = MagicMock()438            ChatOpenRouter(439                model=MODEL_NAME,440                api_key=SecretStr("test-key"),441                app_url=None,442                app_title=None,443                app_categories=None,444            )445            call_kwargs = mock_cls.call_args[1]446            assert "client" not in call_kwargs447            assert "async_client" not in call_kwargs448449    def test_reasoning_in_params(self) -> None:450        """Test that `reasoning` is included in default params."""451        model = _make_model(reasoning={"effort": "high"})452        params = model._default_params453        assert params["reasoning"] == {"effort": "high"}454455    def test_openrouter_provider_in_params(self) -> None:456        """Test that `openrouter_provider` is included in default params."""457        model = _make_model(openrouter_provider={"order": ["Anthropic"]})458        params = model._default_params459        assert params["provider"] == {"order": ["Anthropic"]}460461    def test_route_in_params(self) -> None:462        """Test that `route` is included in default params."""463        model = _make_model(route="fallback")464        params = model._default_params465        assert params["route"] == "fallback"466467    def test_optional_params_excluded_when_none(self) -> None:468        """Test that None optional params are not in default params."""469        model = _make_model()470        params = model._default_params471        assert "temperature" not in params472        assert "max_tokens" not in params473        assert "top_p" not in params474        assert "reasoning" not in params475476    def test_temperature_included_when_set(self) -> None:477        """Test that temperature is included when explicitly set."""478        model = _make_model(temperature=0.5)479        params = model._default_params480        assert params["temperature"] == 0.5481482483# ===========================================================================484# Serialization tests485# ===========================================================================486487488class TestSerialization:489    """Tests for serialization round-trips."""490491    def test_is_lc_serializable(self) -> None:492        """Test that ChatOpenRouter declares itself as serializable."""493        assert ChatOpenRouter.is_lc_serializable() is True494495    def test_dumpd_load_roundtrip(self) -> None:496        """Test that dumpd/load round-trip preserves model config."""497        model = _make_model(temperature=0.7, max_tokens=100)498        serialized = dumpd(model)499        deserialized = load(500            serialized,501            valid_namespaces=["langchain_openrouter"],502            allowed_objects="all",503            secrets_from_env=False,504            secrets_map={"OPENROUTER_API_KEY": "test-key"},505        )506        assert isinstance(deserialized, ChatOpenRouter)507        assert deserialized.model_name == MODEL_NAME508        assert deserialized.temperature == 0.7509        assert deserialized.max_tokens == 100510511    def test_dumps_does_not_leak_secrets(self) -> None:512        """Test that dumps output does not contain the raw API key."""513        model = _make_model(api_key=SecretStr("super-secret-key"))514        serialized = dumps(model)515        assert "super-secret-key" not in serialized516517518# ===========================================================================519# Mocked generate / stream tests520# ===========================================================================521522523class TestMockedGenerate:524    """Tests for _generate / _agenerate with a mocked SDK client."""525526    def test_invoke_basic(self) -> None:527        """Test basic invoke returns an AIMessage via mocked SDK."""528        model = _make_model()529        model.client = MagicMock()530        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)531532        result = model.invoke("Hello")533        assert isinstance(result, AIMessage)534        assert result.content == "Hello!"535        model.client.chat.send.assert_called_once()536537    def test_invoke_with_tool_response(self) -> None:538        """Test invoke that returns tool calls."""539        model = _make_model()540        model.client = MagicMock()541        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)542543        result = model.invoke("What's the weather?")544        assert isinstance(result, AIMessage)545        assert len(result.tool_calls) == 1546        assert result.tool_calls[0]["name"] == "GetWeather"547548    def test_invoke_passes_correct_messages(self) -> None:549        """Test that invoke converts messages and passes them to the SDK."""550        model = _make_model()551        model.client = MagicMock()552        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)553554        model.invoke([HumanMessage(content="Hi")])555        call_kwargs = model.client.chat.send.call_args[1]556        assert call_kwargs["messages"] == [{"role": "user", "content": "Hi"}]557558    def test_invoke_strips_internal_kwargs(self) -> None:559        """Test that LangChain-internal kwargs are stripped before SDK call."""560        model = _make_model()561        model.client = MagicMock()562        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)563564        model._generate(565            [HumanMessage(content="Hi")],566            ls_structured_output_format={"kwargs": {"method": "function_calling"}},567        )568        call_kwargs = model.client.chat.send.call_args[1]569        assert "ls_structured_output_format" not in call_kwargs570571    def test_invoke_usage_metadata(self) -> None:572        """Test that usage metadata is populated on the response."""573        model = _make_model()574        model.client = MagicMock()575        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)576577        result = model.invoke("Hello")578        assert isinstance(result, AIMessage)579        assert result.usage_metadata is not None580        assert result.usage_metadata["input_tokens"] == 10581        assert result.usage_metadata["output_tokens"] == 5582        assert result.usage_metadata["total_tokens"] == 15583584    def test_stream_basic(self) -> None:585        """Test streaming returns AIMessageChunks via mocked SDK."""586        model = _make_model()587        model.client = MagicMock()588        model.client.chat.send.return_value = _MockSyncStream(589            [dict(c) for c in _STREAM_CHUNKS]590        )591592        chunks = list(model.stream("Hello"))593        assert len(chunks) > 0594        assert all(isinstance(c, AIMessageChunk) for c in chunks)595        # Concatenated content should be "Hello world"596        full_content = "".join(c.content for c in chunks if isinstance(c.content, str))597        assert "Hello" in full_content598        assert "world" in full_content599600    def test_stream_passes_stream_true(self) -> None:601        """Test that stream sends stream=True to the SDK."""602        model = _make_model()603        model.client = MagicMock()604        model.client.chat.send.return_value = _MockSyncStream(605            [dict(c) for c in _STREAM_CHUNKS]606        )607608        list(model.stream("Hello"))609        call_kwargs = model.client.chat.send.call_args[1]610        assert call_kwargs["stream"] is True611612    def test_invoke_with_streaming_flag(self) -> None:613        """Test that invoke delegates to stream when streaming=True."""614        model = _make_model(streaming=True)615        model.client = MagicMock()616        model.client.chat.send.return_value = _MockSyncStream(617            [dict(c) for c in _STREAM_CHUNKS]618        )619620        result = model.invoke("Hello")621        assert isinstance(result, AIMessage)622        call_kwargs = model.client.chat.send.call_args[1]623        assert call_kwargs["stream"] is True624625    async def test_ainvoke_basic(self) -> None:626        """Test async invoke returns an AIMessage via mocked SDK."""627        model = _make_model()628        model.client = MagicMock()629        model.client.chat.send_async = AsyncMock(630            return_value=_make_sdk_response(_SIMPLE_RESPONSE_DICT)631        )632633        result = await model.ainvoke("Hello")634        assert isinstance(result, AIMessage)635        assert result.content == "Hello!"636        model.client.chat.send_async.assert_awaited_once()637638    async def test_astream_basic(self) -> None:639        """Test async streaming returns AIMessageChunks via mocked SDK."""640        model = _make_model()641        model.client = MagicMock()642        model.client.chat.send_async = AsyncMock(643            return_value=_MockAsyncStream(_STREAM_CHUNKS)644        )645646        chunks = [c async for c in model.astream("Hello")]647        assert len(chunks) > 0648        assert all(isinstance(c, AIMessageChunk) for c in chunks)649650    def test_stream_response_metadata_fields(self) -> None:651        """Test response-level metadata in streaming response_metadata."""652        model = _make_model()653        model.client = MagicMock()654        stream_chunks: list[dict[str, Any]] = [655            {656                "choices": [657                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}658                ],659                "model": "anthropic/claude-sonnet-4-5",660                "system_fingerprint": "fp_stream123",661                "object": "chat.completion.chunk",662                "created": 1700000000.0,663                "id": "gen-stream-meta",664            },665            {666                "choices": [667                    {668                        "delta": {},669                        "finish_reason": "stop",670                        "native_finish_reason": "end_turn",671                        "index": 0,672                    }673                ],674                "model": "anthropic/claude-sonnet-4-5",675                "system_fingerprint": "fp_stream123",676                "object": "chat.completion.chunk",677                "created": 1700000000.0,678                "id": "gen-stream-meta",679            },680        ]681        model.client.chat.send.return_value = _MockSyncStream(stream_chunks)682683        chunks = list(model.stream("Hello"))684        assert len(chunks) >= 2685686        # Find the chunk with finish_reason (final metadata chunk)687        final = [688            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"689        ]690        assert len(final) == 1691        meta = final[0].response_metadata692        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"693        assert meta["system_fingerprint"] == "fp_stream123"694        assert meta["native_finish_reason"] == "end_turn"695        assert meta["finish_reason"] == "stop"696        assert meta["id"] == "gen-stream-meta"697        assert meta["created"] == 1700000000698        assert meta["object"] == "chat.completion.chunk"699700    async def test_astream_response_metadata_fields(self) -> None:701        """Test response-level metadata in async streaming response_metadata."""702        model = _make_model()703        model.client = MagicMock()704        stream_chunks: list[dict[str, Any]] = [705            {706                "choices": [707                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}708                ],709                "model": "anthropic/claude-sonnet-4-5",710                "system_fingerprint": "fp_async123",711                "object": "chat.completion.chunk",712                "created": 1700000000.0,713                "id": "gen-astream-meta",714            },715            {716                "choices": [717                    {718                        "delta": {},719                        "finish_reason": "stop",720                        "native_finish_reason": "end_turn",721                        "index": 0,722                    }723                ],724                "model": "anthropic/claude-sonnet-4-5",725                "system_fingerprint": "fp_async123",726                "object": "chat.completion.chunk",727                "created": 1700000000.0,728                "id": "gen-astream-meta",729            },730        ]731        model.client.chat.send_async = AsyncMock(732            return_value=_MockAsyncStream(stream_chunks)733        )734735        chunks = [c async for c in model.astream("Hello")]736        assert len(chunks) >= 2737738        # Find the chunk with finish_reason (final metadata chunk)739        final = [740            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"741        ]742        assert len(final) == 1743        meta = final[0].response_metadata744        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"745        assert meta["system_fingerprint"] == "fp_async123"746        assert meta["native_finish_reason"] == "end_turn"747        assert meta["id"] == "gen-astream-meta"748        assert meta["created"] == 1700000000749        assert meta["object"] == "chat.completion.chunk"750751752# ===========================================================================753# Request payload verification754# ===========================================================================755756757class TestRequestPayload:758    """Tests verifying the exact dict sent to the SDK."""759760    @pytest.fixture(autouse=True)761    def _clear_openrouter_env(self, monkeypatch: pytest.MonkeyPatch) -> None:762        """Clear env vars that would otherwise leak into tests via `from_env`."""763        monkeypatch.delenv("OPENROUTER_SESSION_ID", raising=False)764765    def test_message_format_in_payload(self) -> None:766        """Test that messages are formatted correctly in the SDK call."""767        model = _make_model(temperature=0)768        model.client = MagicMock()769        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)770771        model.invoke(772            [773                SystemMessage(content="You are helpful."),774                HumanMessage(content="Hi"),775            ]776        )777        call_kwargs = model.client.chat.send.call_args[1]778        assert call_kwargs["messages"] == [779            {"role": "system", "content": "You are helpful."},780            {"role": "user", "content": "Hi"},781        ]782783    def test_model_kwargs_forwarded(self) -> None:784        """Test that extra model_kwargs are included in the SDK call."""785        model = _make_model(model_kwargs={"top_k": 50})786        model.client = MagicMock()787        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)788789        model.invoke("Hi")790        call_kwargs = model.client.chat.send.call_args[1]791        assert call_kwargs["top_k"] == 50792793    def test_stop_sequences_in_payload(self) -> None:794        """Test that stop sequences are passed to the SDK."""795        model = _make_model()796        model.client = MagicMock()797        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)798799        model.invoke("Hi", stop=["END"])800        call_kwargs = model.client.chat.send.call_args[1]801        assert call_kwargs["stop"] == ["END"]802803    def test_tool_format_in_payload(self) -> None:804        """Test that tools are formatted in OpenAI-compatible structure."""805        model = _make_model()806        model.client = MagicMock()807        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)808809        bound = model.bind_tools([GetWeather])810        bound.invoke("What's the weather?")811        call_kwargs = model.client.chat.send.call_args[1]812        tools = call_kwargs["tools"]813        assert len(tools) == 1814        assert tools[0]["type"] == "function"815        assert tools[0]["function"]["name"] == "GetWeather"816        assert "parameters" in tools[0]["function"]817818    def test_openrouter_params_in_payload(self) -> None:819        """Test that OpenRouter-specific params appear in the SDK call."""820        model = _make_model(821            reasoning={"effort": "high"},822            openrouter_provider={"order": ["Anthropic"]},823            route="fallback",824        )825        model.client = MagicMock()826        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)827828        model.invoke("Hi")829        call_kwargs = model.client.chat.send.call_args[1]830        assert call_kwargs["reasoning"] == {"effort": "high"}831        assert call_kwargs["provider"] == {"order": ["Anthropic"]}832        assert call_kwargs["route"] == "fallback"833834    def test_session_id_and_trace_in_payload(self) -> None:835        """Test that session_id and trace are forwarded to the SDK."""836        model = _make_model(837            session_id="session-abc",838            trace={"trace_id": "trace-1", "span_name": "summarize"},839        )840        model.client = MagicMock()841        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)842843        model.invoke("Hi")844        call_kwargs = model.client.chat.send.call_args[1]845        assert call_kwargs["session_id"] == "session-abc"846        assert call_kwargs["trace"] == {847            "trace_id": "trace-1",848            "span_name": "summarize",849        }850851    def test_session_id_and_trace_omitted_when_unset(self) -> None:852        """Test that session_id and trace are omitted when not configured."""853        model = _make_model()854        model.client = MagicMock()855        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)856857        model.invoke("Hi")858        call_kwargs = model.client.chat.send.call_args[1]859        assert "session_id" not in call_kwargs860        assert "trace" not in call_kwargs861862    def test_session_id_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:863        """Test that session_id falls back to OPENROUTER_SESSION_ID env var."""864        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session-xyz")865        model = _make_model()866        assert model.session_id == "env-session-xyz"867868        model.client = MagicMock()869        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)870        model.invoke("Hi")871        call_kwargs = model.client.chat.send.call_args[1]872        assert call_kwargs["session_id"] == "env-session-xyz"873874    def test_session_id_constructor_overrides_env(875        self, monkeypatch: pytest.MonkeyPatch876    ) -> None:877        """Test that an explicit session_id wins over the env var."""878        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session")879        model = _make_model(session_id="explicit-session")880        assert model.session_id == "explicit-session"881882        model.client = MagicMock()883        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)884        model.invoke("Hi")885        call_kwargs = model.client.chat.send.call_args[1]886        assert call_kwargs["session_id"] == "explicit-session"887888    def test_session_id_per_call_override(self) -> None:889        """Test that a per-call session_id kwarg overrides the constructor value."""890        model = _make_model(session_id="constructor-session")891        model.client = MagicMock()892        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)893894        model.invoke("Hi", session_id="call-session")895        first_call_kwargs = model.client.chat.send.call_args[1]896        assert first_call_kwargs["session_id"] == "call-session"897898        # Per-call override must not mutate the constructor value, and the next899        # call without the kwarg should fall back to the constructor's value.900        assert model.session_id == "constructor-session"901        model.invoke("Hi")902        second_call_kwargs = model.client.chat.send.call_args[1]903        assert second_call_kwargs["session_id"] == "constructor-session"904905    def test_trace_per_call_override(self) -> None:906        """Test that a per-call trace kwarg overrides the constructor value."""907        constructor_trace = {"trace_id": "constructor-trace"}908        call_trace = {"trace_id": "call-trace", "span_name": "summarize"}909        model = _make_model(trace=constructor_trace)910        model.client = MagicMock()911        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)912913        model.invoke("Hi", trace=call_trace)914        first_call_kwargs = model.client.chat.send.call_args[1]915        assert first_call_kwargs["trace"] == call_trace916917        assert model.trace == constructor_trace918        model.invoke("Hi")919        second_call_kwargs = model.client.chat.send.call_args[1]920        assert second_call_kwargs["trace"] == constructor_trace921922    def test_empty_session_id_treated_as_unset(923        self, monkeypatch: pytest.MonkeyPatch924    ) -> None:925        """Test that empty `session_id` (constructor or env) is not forwarded."""926        # Explicit empty string on the constructor.927        model = _make_model(session_id="")928        model.client = MagicMock()929        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)930        model.invoke("Hi")931        assert "session_id" not in model.client.chat.send.call_args[1]932933        # Empty string sourced from the env var.934        monkeypatch.setenv("OPENROUTER_SESSION_ID", "")935        env_model = _make_model()936        env_model.client = MagicMock()937        env_model.client.chat.send.return_value = _make_sdk_response(938            _SIMPLE_RESPONSE_DICT939        )940        env_model.invoke("Hi")941        assert "session_id" not in env_model.client.chat.send.call_args[1]942943944# ===========================================================================945# bind_tools tests946# ===========================================================================947948949class TestBindTools:950    """Tests for the bind_tools public method."""951952    @pytest.mark.parametrize(953        "tool_choice",954        [955            "auto",956            "none",957            "required",958            "GetWeather",959            {"type": "function", "function": {"name": "GetWeather"}},960            None,961        ],962    )963    def test_bind_tools_tool_choice(self, tool_choice: Any) -> None:964        """Test bind_tools accepts various tool_choice values."""965        model = _make_model()966        bound = model.bind_tools(967            [GetWeather, GenerateUsername], tool_choice=tool_choice968        )969        assert isinstance(bound, RunnableBinding)970971    def test_bind_tools_bool_true_single_tool(self) -> None:972        """Test bind_tools with tool_choice=True and a single tool."""973        model = _make_model()974        bound = model.bind_tools([GetWeather], tool_choice=True)975        assert isinstance(bound, RunnableBinding)976        kwargs = bound.kwargs977        assert kwargs["tool_choice"] == {978            "type": "function",979            "function": {"name": "GetWeather"},980        }981982    def test_bind_tools_bool_true_multiple_tools_raises(self) -> None:983        """Test bind_tools with tool_choice=True and multiple tools raises."""984        model = _make_model()985        with pytest.raises(ValueError, match="tool_choice can only be True"):986            model.bind_tools([GetWeather, GenerateUsername], tool_choice=True)987988    def test_bind_tools_any_maps_to_required(self) -> None:989        """Test that tool_choice='any' is mapped to 'required'."""990        model = _make_model()991        bound = model.bind_tools([GetWeather], tool_choice="any")992        assert isinstance(bound, RunnableBinding)993        assert bound.kwargs["tool_choice"] == "required"994995    def test_bind_tools_string_name_becomes_dict(self) -> None:996        """Test that a specific tool name string is converted to a dict."""997        model = _make_model()998        bound = model.bind_tools([GetWeather], tool_choice="GetWeather")999        assert isinstance(bound, RunnableBinding)1000        assert bound.kwargs["tool_choice"] == {1001            "type": "function",1002            "function": {"name": "GetWeather"},1003        }10041005    def test_bind_tools_formats_tools_correctly(self) -> None:1006        """Test that tools are converted to OpenAI format."""1007        model = _make_model()1008        bound = model.bind_tools([GetWeather])1009        assert isinstance(bound, RunnableBinding)1010        tools = bound.kwargs["tools"]1011        assert len(tools) == 11012        assert tools[0]["type"] == "function"1013        assert tools[0]["function"]["name"] == "GetWeather"10141015    def test_bind_tools_no_choice_omits_key(self) -> None:1016        """Test that tool_choice=None does not set tool_choice in kwargs."""1017        model = _make_model()1018        bound = model.bind_tools([GetWeather], tool_choice=None)1019        assert isinstance(bound, RunnableBinding)1020        assert "tool_choice" not in bound.kwargs10211022    def test_bind_tools_strict_forwarded(self) -> None:1023        """Test that strict param is forwarded to tool definitions."""1024        model = _make_model()1025        bound = model.bind_tools([GetWeather], strict=True)1026        assert isinstance(bound, RunnableBinding)1027        tools = bound.kwargs["tools"]1028        assert tools[0]["function"]["strict"] is True10291030    def test_bind_tools_strict_none_by_default(self) -> None:1031        """Test that strict is not set when not provided."""1032        model = _make_model()1033        bound = model.bind_tools([GetWeather])1034        assert isinstance(bound, RunnableBinding)1035        tools = bound.kwargs["tools"]1036        assert "strict" not in tools[0]["function"]103710381039# ===========================================================================1040# with_structured_output tests1041# ===========================================================================104210431044class TestWithStructuredOutput:1045    """Tests for the with_structured_output public method."""10461047    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1048    @pytest.mark.parametrize("include_raw", ["yes", "no"])1049    def test_with_structured_output_pydantic(1050        self,1051        method: Literal["function_calling", "json_schema"],1052        include_raw: str,1053    ) -> None:1054        """Test with_structured_output using a Pydantic schema."""1055        model = _make_model()1056        structured = model.with_structured_output(1057            GenerateUsername, method=method, include_raw=(include_raw == "yes")1058        )1059        assert structured is not None10601061    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1062    def test_with_structured_output_dict_schema(1063        self,1064        method: Literal["function_calling", "json_schema"],1065    ) -> None:1066        """Test with_structured_output using a JSON schema dict."""1067        schema = GenerateUsername.model_json_schema()1068        model = _make_model()1069        structured = model.with_structured_output(schema, method=method)1070        assert structured is not None10711072    def test_with_structured_output_none_schema_function_calling_raises(self) -> None:1073        """Test that schema=None with function_calling raises ValueError."""1074        model = _make_model()1075        with pytest.raises(ValueError, match="schema must be specified"):1076            model.with_structured_output(None, method="function_calling")10771078    def test_with_structured_output_none_schema_json_schema_raises(self) -> None:1079        """Test that schema=None with json_schema raises ValueError."""1080        model = _make_model()1081        with pytest.raises(ValueError, match="schema must be specified"):1082            model.with_structured_output(None, method="json_schema")10831084    def test_with_structured_output_invalid_method_raises(self) -> None:1085        """Test that an unrecognized method raises ValueError."""1086        model = _make_model()1087        with pytest.raises(ValueError, match="Unrecognized method"):1088            model.with_structured_output(1089                GenerateUsername,1090                method="invalid",  # type: ignore[arg-type]1091            )10921093    def test_with_structured_output_json_schema_sets_response_format(self) -> None:1094        """Test that json_schema method sets response_format correctly."""1095        model = _make_model()1096        structured = model.with_structured_output(1097            GenerateUsername, method="json_schema"1098        )1099        # The first step in the chain should be the bound model1100        bound = structured.first  # type: ignore[attr-defined]1101        assert isinstance(bound, RunnableBinding)1102        rf = bound.kwargs["response_format"]1103        assert rf["type"] == "json_schema"1104        assert rf["json_schema"]["name"] == "GenerateUsername"11051106    def test_with_structured_output_json_mode_warns_and_falls_back(self) -> None:1107        """Test that json_mode warns and falls back to json_schema."""1108        model = _make_model()1109        with pytest.warns(match="Defaulting to 'json_schema'"):1110            structured = model.with_structured_output(1111                GenerateUsername,1112                method="json_mode",  # type: ignore[arg-type]1113            )1114        bound = structured.first  # type: ignore[attr-defined]1115        assert isinstance(bound, RunnableBinding)1116        rf = bound.kwargs["response_format"]1117        assert rf["type"] == "json_schema"11181119    def test_with_structured_output_strict_function_calling(self) -> None:1120        """Test that strict is forwarded for function_calling method."""1121        model = _make_model()1122        structured = model.with_structured_output(1123            GenerateUsername, method="function_calling", strict=True1124        )1125        bound = structured.first  # type: ignore[attr-defined]1126        assert isinstance(bound, RunnableBinding)1127        tools = bound.kwargs["tools"]1128        assert tools[0]["function"]["strict"] is True11291130    def test_with_structured_output_strict_json_schema(self) -> None:1131        """Test that strict is forwarded for json_schema method."""1132        model = _make_model()1133        structured = model.with_structured_output(1134            GenerateUsername, method="json_schema", strict=True1135        )1136        bound = structured.first  # type: ignore[attr-defined]1137        assert isinstance(bound, RunnableBinding)1138        rf = bound.kwargs["response_format"]1139        assert rf["json_schema"]["strict"] is True11401141    def test_with_structured_output_json_mode_with_strict_warns_and_forwards(1142        self,1143    ) -> None:1144        """Test json_mode with strict warns and falls back to json_schema."""1145        model = _make_model()1146        with pytest.warns(match="Defaulting to 'json_schema'"):1147            structured = model.with_structured_output(1148                GenerateUsername,1149                method="json_mode",  # type: ignore[arg-type]1150                strict=True,1151            )1152        bound = structured.first  # type: ignore[attr-defined]1153        assert isinstance(bound, RunnableBinding)1154        rf = bound.kwargs["response_format"]1155        assert rf["type"] == "json_schema"1156        assert rf["json_schema"]["strict"] is True115711581159# ===========================================================================1160# Message conversion tests1161# ===========================================================================116211631164class TestMessageConversion:1165    """Tests for message conversion functions."""11661167    def test_human_message_to_dict(self) -> None:1168        """Test converting HumanMessage to dict."""1169        msg = HumanMessage(content="Hello")1170        result = _convert_message_to_dict(msg)1171        assert result == {"role": "user", "content": "Hello"}11721173    def test_system_message_to_dict(self) -> None:1174        """Test converting SystemMessage to dict."""1175        msg = SystemMessage(content="You are helpful.")1176        result = _convert_message_to_dict(msg)1177        assert result == {"role": "system", "content": "You are helpful."}11781179    def test_ai_message_to_dict(self) -> None:1180        """Test converting AIMessage to dict."""1181        msg = AIMessage(content="Hi there!")1182        result = _convert_message_to_dict(msg)1183        assert result == {"role": "assistant", "content": "Hi there!"}11841185    def test_ai_message_with_reasoning_content_to_dict(self) -> None:1186        """Test that reasoning_content is preserved when converting back to dict."""1187        msg = AIMessage(1188            content="The answer is 42.",1189            additional_kwargs={"reasoning_content": "Let me think about this..."},1190        )1191        result = _convert_message_to_dict(msg)1192        assert result["role"] == "assistant"1193        assert result["content"] == "The answer is 42."1194        assert result["reasoning"] == "Let me think about this..."11951196    def test_ai_message_with_fragmented_reasoning_details_merged(self) -> None:1197        """Fragmented `reasoning_details` are merged before serialization.11981199        Float `index` values mirror what `ChatOpenRouter.stream()` produces1200        (the OpenRouter SDK coerces `index` via Pydantic). With float1201        `index`, `langchain_core.utils._merge.merge_lists` does not auto-merge1202        list entries (its index-match path requires `int`), so fragments1203        accumulate as separate list items and require this helper to merge1204        them before the next API turn.1205        """1206        details = [1207            {1208                "type": "reasoning.text",1209                "text": "The",1210                "format": "anthropic-claude-v1",1211                "index": 0.0,1212            },1213            {1214                "type": "reasoning.text",1215                "text": " user wants",1216                "format": "anthropic-claude-v1",1217                "index": 0.0,1218            },1219            {1220                "type": "reasoning.text",1221                "signature": "sig_abc123",1222                "format": "anthropic-claude-v1",1223                "index": 0.0,1224            },1225        ]1226        msg = AIMessage(1227            content="Answer",1228            additional_kwargs={"reasoning_details": details},1229        )1230        result = _convert_message_to_dict(msg)1231        assert result["reasoning_details"] == [1232            {1233                "type": "reasoning.text",1234                "text": "The user wants",1235                "format": "anthropic-claude-v1",1236                "signature": "sig_abc123",1237                "index": 0.0,1238            }1239        ]1240        assert "reasoning" not in result12411242    def test_ai_message_distinct_reasoning_details_preserved(self) -> None:1243        """Distinct entries (different `index`) are not merged."""1244        details = [1245            {"type": "reasoning.text", "text": "First thought", "index": 0},1246            {"type": "reasoning.text", "text": "Second thought", "index": 1},1247        ]1248        msg = AIMessage(1249            content="Answer",1250            additional_kwargs={"reasoning_details": details},1251        )1252        result = _convert_message_to_dict(msg)1253        assert result["reasoning_details"] == details12541255    def test_ai_message_unindexed_reasoning_details_not_merged(self) -> None:1256        """Entries without an `index` are passed through unchanged."""1257        details = [1258            {"type": "reasoning.text", "text": "First"},1259            {"type": "reasoning.text", "text": "Second"},1260        ]1261        msg = AIMessage(1262            content="Answer",1263            additional_kwargs={"reasoning_details": details},1264        )1265        result = _convert_message_to_dict(msg)1266        assert result["reasoning_details"] == details12671268    def test_ai_message_interleaved_index_fragments_preserved(self) -> None:1269        """Only consecutive same-`index` runs merge; interleaved runs stay split."""1270        details = [1271            {"type": "reasoning.text", "text": "A", "index": 0},1272            {"type": "reasoning.text", "text": "B", "index": 1},1273            {"type": "reasoning.text", "text": "C", "index": 0},1274            {"type": "reasoning.text", "text": "D", "index": 1},1275        ]1276        msg = AIMessage(1277            content="Answer",1278            additional_kwargs={"reasoning_details": details},1279        )1280        result = _convert_message_to_dict(msg)1281        assert result["reasoning_details"] == details12821283    def test_ai_message_fragment_metadata_preserved(self) -> None:1284        """Test that metadata from later fragments is preserved after merge."""1285        details = [1286            {"type": "reasoning.text", "text": "thinking...", "index": 0},1287            {1288                "type": "reasoning.text",1289                "text": " done",1290                "index": 0,1291                "signature": "sig_abc123",1292            },1293        ]1294        msg = AIMessage(1295            content="Answer",1296            additional_kwargs={"reasoning_details": details},1297        )1298        result = _convert_message_to_dict(msg)1299        assert len(result["reasoning_details"]) == 11300        assert result["reasoning_details"][0]["text"] == "thinking... done"1301        assert result["reasoning_details"][0]["signature"] == "sig_abc123"13021303    def test_streamed_reasoning_details_roundtrip_to_next_turn_payload(self) -> None:1304        """Test the chunk-merge-to-next-turn serialization path from issue #36400."""1305        chunk_dicts = [1306            {"choices": [{"delta": {"role": "assistant", "content": ""}, "index": 0}]},1307            {1308                "choices": [1309                    {1310                        "delta": {1311                            "reasoning_details": [1312                                {1313                                    "type": "reasoning.text",1314                                    "text": "The",1315                                    "format": "anthropic-claude-v1",1316                                    "index": 0.0,1317                                }1318                            ]1319                        },1320                        "index": 0,1321                    }1322                ]1323            },1324            {1325                "choices": [1326                    {1327                        "delta": {1328                            "reasoning_details": [1329                                {1330                                    "type": "reasoning.text",1331                                    "text": " user wants",1332                                    "format": "anthropic-claude-v1",1333                                    "index": 0.0,1334                                }1335                            ]1336                        },1337                        "index": 0,1338                    }1339                ]1340            },1341            {1342                "choices": [1343                    {1344                        "delta": {1345                            "reasoning_details": [1346                                {1347                                    "type": "reasoning.text",1348                                    "signature": "sig_abc123",1349                                    "format": "anthropic-claude-v1",1350                                    "index": 0.0,1351                                }1352                            ]1353                        },1354                        "index": 0,1355                    }1356                ]1357            },1358            {"choices": [{"delta": {"content": "Answer"}, "index": 0}]},1359        ]1360        chunks = [1361            _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1362            for chunk in chunk_dicts1363        ]1364        merged_chunk = chunks[0]1365        for chunk in chunks[1:]:1366            merged_chunk = merged_chunk + chunk13671368        assert len(merged_chunk.additional_kwargs["reasoning_details"]) == 313691370        msg = AIMessage(1371            content=merged_chunk.content,1372            additional_kwargs=merged_chunk.additional_kwargs,1373            response_metadata=merged_chunk.response_metadata,1374        )13751376        result = _convert_message_to_dict(msg)1377        assert result["reasoning_details"] == [1378            {1379                "type": "reasoning.text",1380                "text": "The user wants",1381                "format": "anthropic-claude-v1",1382                "signature": "sig_abc123",1383                "index": 0.0,1384            }1385        ]13861387    def test_ai_message_with_both_reasoning_fields_to_dict(self) -> None:1388        """Test that both reasoning_content and reasoning_details are preserved."""1389        details = [{"type": "reasoning.text", "text": "detailed thinking"}]1390        msg = AIMessage(1391            content="Answer",1392            additional_kwargs={1393                "reasoning_content": "I thought about it",1394                "reasoning_details": details,1395            },1396        )1397        result = _convert_message_to_dict(msg)1398        assert result["reasoning"] == "I thought about it"1399        assert result["reasoning_details"] == details14001401    def test_reasoning_roundtrip_through_dict(self) -> None:1402        """Test that reasoning survives dict -> message -> dict roundtrip."""1403        original_dict = {1404            "role": "assistant",1405            "content": "The answer",1406            "reasoning": "My thinking process",1407            "reasoning_details": [{"type": "reasoning.text", "text": "step-by-step"}],1408        }1409        msg = _convert_dict_to_message(original_dict)1410        result = _convert_message_to_dict(msg)1411        assert result["reasoning"] == "My thinking process"1412        assert result["reasoning_details"] == original_dict["reasoning_details"]14131414    def test_tool_message_to_dict(self) -> None:1415        """Test converting ToolMessage to dict."""1416        msg = ToolMessage(content="result", tool_call_id="call_123")1417        result = _convert_message_to_dict(msg)1418        assert result == {1419            "role": "tool",1420            "content": "result",1421            "tool_call_id": "call_123",1422        }14231424    def test_chat_message_to_dict(self) -> None:1425        """Test converting ChatMessage to dict."""1426        msg = ChatMessage(content="Hello", role="developer")1427        result = _convert_message_to_dict(msg)1428        assert result == {"role": "developer", "content": "Hello"}14291430    def test_ai_message_with_tool_calls_to_dict(self) -> None:1431        """Test converting AIMessage with tool calls to dict."""1432        msg = AIMessage(1433            content="",1434            tool_calls=[1435                {1436                    "name": "get_weather",1437                    "args": {"location": "SF"},1438                    "id": "call_1",1439                    "type": "tool_call",1440                }1441            ],1442        )1443        result = _convert_message_to_dict(msg)1444        assert result["role"] == "assistant"1445        assert result["content"] is None1446        assert len(result["tool_calls"]) == 11447        assert result["tool_calls"][0]["function"]["name"] == "get_weather"14481449    def test_dict_to_ai_message(self) -> None:1450        """Test converting dict to AIMessage."""1451        d = {"role": "assistant", "content": "Hello!"}1452        msg = _convert_dict_to_message(d)1453        assert isinstance(msg, AIMessage)1454        assert msg.content == "Hello!"14551456    def test_dict_to_ai_message_with_reasoning(self) -> None:1457        """Test that reasoning is extracted from response dict."""1458        d = {1459            "role": "assistant",1460            "content": "Answer",1461            "reasoning": "Let me think...",1462        }1463        msg = _convert_dict_to_message(d)1464        assert isinstance(msg, AIMessage)1465        assert msg.additional_kwargs["reasoning_content"] == "Let me think..."14661467    def test_dict_to_ai_message_with_tool_calls(self) -> None:1468        """Test converting dict with tool calls to AIMessage."""1469        d = {1470            "role": "assistant",1471            "content": "",1472            "tool_calls": [1473                {1474                    "id": "call_1",1475                    "type": "function",1476                    "function": {1477                        "name": "get_weather",1478                        "arguments": '{"location": "SF"}',1479                    },1480                }1481            ],1482        }1483        msg = _convert_dict_to_message(d)1484        assert isinstance(msg, AIMessage)1485        assert len(msg.tool_calls) == 11486        assert msg.tool_calls[0]["name"] == "get_weather"14871488    def test_dict_to_ai_message_with_invalid_tool_calls(self) -> None:1489        """Test that malformed tool calls produce invalid_tool_calls."""1490        d = {1491            "role": "assistant",1492            "content": "",1493            "tool_calls": [1494                {1495                    "id": "call_bad",1496                    "type": "function",1497                    "function": {1498                        "name": "get_weather",1499                        "arguments": "not-valid-json{{{",1500                    },1501                }1502            ],1503        }1504        msg = _convert_dict_to_message(d)1505        assert isinstance(msg, AIMessage)1506        assert len(msg.invalid_tool_calls) == 11507        assert len(msg.tool_calls) == 01508        assert msg.invalid_tool_calls[0]["name"] == "get_weather"15091510    def test_dict_to_human_message(self) -> None:1511        """Test converting dict to HumanMessage."""1512        d = {"role": "user", "content": "Hi"}1513        msg = _convert_dict_to_message(d)1514        assert isinstance(msg, HumanMessage)15151516    def test_dict_to_system_message(self) -> None:1517        """Test converting dict to SystemMessage."""1518        d = {"role": "system", "content": "Be helpful"}1519        msg = _convert_dict_to_message(d)1520        assert isinstance(msg, SystemMessage)15211522    def test_dict_to_tool_message(self) -> None:1523        """Test converting dict with role=tool to ToolMessage."""1524        d = {1525            "role": "tool",1526            "content": "result data",1527            "tool_call_id": "call_42",1528            "name": "get_weather",1529        }1530        msg = _convert_dict_to_message(d)1531        assert isinstance(msg, ToolMessage)1532        assert msg.content == "result data"1533        assert msg.tool_call_id == "call_42"1534        assert msg.additional_kwargs["name"] == "get_weather"15351536    def test_dict_to_chat_message_unknown_role(self) -> None:1537        """Test that unrecognized roles fall back to ChatMessage."""1538        d = {"role": "developer", "content": "Some content"}1539        with pytest.warns(UserWarning, match="Unrecognized message role"):1540            msg = _convert_dict_to_message(d)1541        assert isinstance(msg, ChatMessage)1542        assert msg.role == "developer"1543        assert msg.content == "Some content"15441545    def test_ai_message_with_list_content_filters_non_text(self) -> None:1546        """Test that non-text blocks are filtered from AIMessage list content."""1547        msg = AIMessage(1548            content=[1549                {"type": "text", "text": "Hello"},1550                {"type": "image_url", "image_url": {"url": "http://example.com"}},1551            ]1552        )1553        result = _convert_message_to_dict(msg)1554        assert result["content"] == [{"type": "text", "text": "Hello"}]155515561557# ===========================================================================1558# _create_chat_result tests1559# ===========================================================================156015611562class TestCreateChatResult:1563    """Tests for _create_chat_result."""15641565    def test_model_provider_in_response_metadata(self) -> None:1566        """Test that model_provider is set in response metadata."""1567        model = _make_model()1568        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1569        assert (1570            result.generations[0].message.response_metadata.get("model_provider")1571            == "openrouter"1572        )15731574    def test_reasoning_from_response(self) -> None:1575        """Test that reasoning content is extracted from response."""1576        model = _make_model()1577        response_dict: dict[str, Any] = {1578            "choices": [1579                {1580                    "message": {1581                        "role": "assistant",1582                        "content": "Answer",1583                        "reasoning": "Let me think...",1584                    },1585                    "finish_reason": "stop",1586                }1587            ],1588        }1589        result = model._create_chat_result(response_dict)1590        assert (1591            result.generations[0].message.additional_kwargs.get("reasoning_content")1592            == "Let me think..."1593        )15941595    def test_usage_metadata_created(self) -> None:1596        """Test that usage metadata is created from token usage."""1597        model = _make_model()1598        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1599        msg = result.generations[0].message1600        assert isinstance(msg, AIMessage)1601        usage = msg.usage_metadata1602        assert usage is not None1603        assert usage["input_tokens"] == 101604        assert usage["output_tokens"] == 51605        assert usage["total_tokens"] == 1516061607    def test_tool_calls_in_response(self) -> None:1608        """Test that tool calls are extracted from response."""1609        model = _make_model()1610        result = model._create_chat_result(_TOOL_RESPONSE_DICT)1611        msg = result.generations[0].message1612        assert isinstance(msg, AIMessage)1613        assert len(msg.tool_calls) == 11614        assert msg.tool_calls[0]["name"] == "GetWeather"16151616    def test_response_model_in_llm_output(self) -> None:1617        """Test that the response model is included in llm_output."""1618        model = _make_model()1619        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1620        assert result.llm_output is not None1621        assert result.llm_output["model_name"] == MODEL_NAME16221623    def test_response_model_propagated_to_llm_output(self) -> None:1624        """Test that llm_output uses response model when available."""1625        model = _make_model()1626        response = {1627            **_SIMPLE_RESPONSE_DICT,1628            "model": "openai/gpt-4o",1629        }1630        result = model._create_chat_result(response)1631        assert result.llm_output is not None1632        assert result.llm_output["model_name"] == "openai/gpt-4o"16331634    def test_system_fingerprint_in_metadata(self) -> None:1635        """Test that system_fingerprint is included in response_metadata."""1636        model = _make_model()1637        response = {1638            **_SIMPLE_RESPONSE_DICT,1639            "system_fingerprint": "fp_abc123",1640        }1641        result = model._create_chat_result(response)1642        msg = result.generations[0].message1643        assert isinstance(msg, AIMessage)1644        assert msg.response_metadata["system_fingerprint"] == "fp_abc123"16451646    def test_native_finish_reason_in_metadata(self) -> None:1647        """Test that native_finish_reason is included in response_metadata."""1648        model = _make_model()1649        response: dict[str, Any] = {1650            **_SIMPLE_RESPONSE_DICT,1651            "choices": [1652                {1653                    "message": {"role": "assistant", "content": "Hello!"},1654                    "finish_reason": "stop",1655                    "native_finish_reason": "end_turn",1656                    "index": 0,1657                }1658            ],1659        }1660        result = model._create_chat_result(response)1661        msg = result.generations[0].message1662        assert isinstance(msg, AIMessage)1663        assert msg.response_metadata["native_finish_reason"] == "end_turn"16641665    def test_cost_in_response_metadata(self) -> None:1666        """Test that OpenRouter cost data is surfaced in response_metadata."""1667        model = _make_model()1668        response: dict[str, Any] = {1669            **_SIMPLE_RESPONSE_DICT,1670            "usage": {1671                **_SIMPLE_RESPONSE_DICT["usage"],1672                "cost": 7.5e-05,1673                "cost_details": {1674                    "upstream_inference_cost": 7.745e-05,1675                    "upstream_inference_prompt_cost": 8.95e-06,1676                    "upstream_inference_completions_cost": 6.85e-05,1677                },1678            },1679        }1680        result = model._create_chat_result(response)1681        msg = result.generations[0].message1682        assert isinstance(msg, AIMessage)1683        assert msg.response_metadata["cost"] == 7.5e-051684        assert msg.response_metadata["cost_details"] == {1685            "upstream_inference_cost": 7.745e-05,1686            "upstream_inference_prompt_cost": 8.95e-06,1687            "upstream_inference_completions_cost": 6.85e-05,1688        }16891690    def test_cost_absent_when_not_in_usage(self) -> None:1691        """Test that cost fields are not added when not present in usage."""1692        model = _make_model()1693        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1694        msg = result.generations[0].message1695        assert isinstance(msg, AIMessage)1696        assert "cost" not in msg.response_metadata1697        assert "cost_details" not in msg.response_metadata16981699    def test_stream_cost_survives_final_chunk(self) -> None:1700        """Test that cost fields are preserved on the final streaming chunk.17011702        The final chunk carries both finish_reason metadata and usage/cost data.1703        Regression test: generation_info must merge into response_metadata, not1704        replace it, so cost fields set by _convert_chunk_to_message_chunk are1705        not lost.1706        """1707        model = _make_model()1708        model.client = MagicMock()1709        cost_details = {1710            "upstream_inference_cost": 7.745e-05,1711            "upstream_inference_prompt_cost": 8.95e-06,1712            "upstream_inference_completions_cost": 6.85e-05,1713        }1714        stream_chunks: list[dict[str, Any]] = [1715            {1716                "choices": [1717                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}1718                ],1719            },1720            {1721                "choices": [1722                    {1723                        "delta": {},1724                        "finish_reason": "stop",1725                        "index": 0,1726                    }1727                ],1728                "model": "openai/gpt-4o-mini",1729                "id": "gen-cost-stream",1730                "usage": {1731                    "prompt_tokens": 10,1732                    "completion_tokens": 5,1733                    "total_tokens": 15,1734                    "cost": 7.5e-05,1735                    "cost_details": cost_details,1736                },1737            },1738        ]1739        model.client.chat.send.return_value = _MockSyncStream(stream_chunks)17401741        chunks = list(model.stream("Hello"))1742        final = [1743            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"1744        ]1745        assert len(final) == 11746        meta = final[0].response_metadata1747        assert meta["cost"] == 7.5e-051748        assert meta["cost_details"] == cost_details1749        assert meta["finish_reason"] == "stop"17501751    async def test_astream_cost_survives_final_chunk(self) -> None:1752        """Test that cost fields are preserved on the final async streaming chunk.17531754        Same regression coverage as the sync test above, for the _astream path.1755        """1756        model = _make_model()1757        model.client = MagicMock()1758        cost_details = {1759            "upstream_inference_cost": 7.745e-05,1760            "upstream_inference_prompt_cost": 8.95e-06,1761            "upstream_inference_completions_cost": 6.85e-05,1762        }1763        stream_chunks: list[dict[str, Any]] = [1764            {1765                "choices": [1766                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}1767                ],1768            },1769            {1770                "choices": [1771                    {1772                        "delta": {},1773                        "finish_reason": "stop",1774                        "index": 0,1775                    }1776                ],1777                "model": "openai/gpt-4o-mini",1778                "id": "gen-cost-astream",1779                "usage": {1780                    "prompt_tokens": 10,1781                    "completion_tokens": 5,1782                    "total_tokens": 15,1783                    "cost": 7.5e-05,1784                    "cost_details": cost_details,1785                },1786            },1787        ]1788        model.client.chat.send_async = AsyncMock(1789            return_value=_MockAsyncStream(stream_chunks)1790        )17911792        chunks = [c async for c in model.astream("Hello")]1793        final = [1794            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"1795        ]1796        assert len(final) == 11797        meta = final[0].response_metadata1798        assert meta["cost"] == 7.5e-051799        assert meta["cost_details"] == cost_details1800        assert meta["finish_reason"] == "stop"18011802    def test_missing_optional_metadata_excluded(self) -> None:1803        """Test that absent optional fields are not added to response_metadata."""1804        model = _make_model()1805        response: dict[str, Any] = {1806            "choices": [1807                {1808                    "message": {"role": "assistant", "content": "Hello!"},1809                    "finish_reason": "stop",1810                }1811            ],1812        }1813        result = model._create_chat_result(response)1814        msg = result.generations[0].message1815        assert isinstance(msg, AIMessage)1816        assert "system_fingerprint" not in msg.response_metadata1817        assert "native_finish_reason" not in msg.response_metadata1818        assert "model" not in msg.response_metadata1819        assert result.llm_output is not None1820        assert "id" not in result.llm_output1821        assert "created" not in result.llm_output1822        assert "object" not in result.llm_output18231824    def test_id_created_object_in_llm_output(self) -> None:1825        """Test that id, created, and object are included in llm_output."""1826        model = _make_model()1827        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1828        assert result.llm_output is not None1829        assert result.llm_output["id"] == "gen-abc123"1830        assert result.llm_output["created"] == 17000000001831        assert result.llm_output["object"] == "chat.completion"18321833    def test_float_token_usage_normalized_to_int_in_usage_metadata(self) -> None:1834        """Test that float token counts are cast to int in usage_metadata."""1835        model = _make_model()1836        response: dict[str, Any] = {1837            "choices": [1838                {1839                    "message": {"role": "assistant", "content": "Hello!"},1840                    "finish_reason": "stop",1841                }1842            ],1843            "usage": {1844                "prompt_tokens": 585.0,1845                "completion_tokens": 56.0,1846                "total_tokens": 641.0,1847                "completion_tokens_details": {"reasoning_tokens": 10.0},1848                "prompt_tokens_details": {"cached_tokens": 20.0},1849            },1850            "model": MODEL_NAME,1851        }1852        result = model._create_chat_result(response)1853        msg = result.generations[0].message1854        assert isinstance(msg, AIMessage)1855        usage = msg.usage_metadata1856        assert usage is not None1857        assert usage["input_tokens"] == 5851858        assert isinstance(usage["input_tokens"], int)1859        assert usage["output_tokens"] == 561860        assert isinstance(usage["output_tokens"], int)1861        assert usage["total_tokens"] == 6411862        assert isinstance(usage["total_tokens"], int)1863        assert usage["input_token_details"]["cache_read"] == 201864        assert isinstance(usage["input_token_details"]["cache_read"], int)1865        assert usage["output_token_details"]["reasoning"] == 101866        assert isinstance(usage["output_token_details"]["reasoning"], int)186718681869class TestCreateUsageMetadataZeroTotal:1870    """Test that explicit total_tokens=0 is preserved, not replaced by sum."""18711872    def test_zero_total_tokens_preserved(self) -> None:1873        token_usage = {1874            "prompt_tokens": 10,1875            "completion_tokens": 5,1876            "total_tokens": 0,1877        }1878        result = _create_usage_metadata(token_usage)1879        assert result["total_tokens"] == 018801881    def test_zero_input_tokens_preferred_key(self) -> None:1882        """prompt_tokens=0 must not fall through to input_tokens."""1883        token_usage = {1884            "prompt_tokens": 0,1885            "input_tokens": 50,1886            "completion_tokens": 5,1887            "total_tokens": 55,1888        }1889        result = _create_usage_metadata(token_usage)1890        assert result["input_tokens"] == 018911892    def test_zero_output_tokens_preferred_key(self) -> None:1893        """completion_tokens=0 must not fall through to output_tokens."""1894        token_usage = {1895            "prompt_tokens": 10,1896            "completion_tokens": 0,1897            "output_tokens": 50,1898            "total_tokens": 60,1899        }1900        result = _create_usage_metadata(token_usage)1901        assert result["output_tokens"] == 0190219031904# ===========================================================================1905# Streaming chunk tests1906# ===========================================================================190719081909class TestStreamingChunks:1910    """Tests for streaming chunk conversion."""19111912    def test_reasoning_in_streaming_chunk(self) -> None:1913        """Test that reasoning is extracted from streaming delta."""1914        chunk: dict[str, Any] = {1915            "choices": [1916                {1917                    "delta": {1918                        "content": "Main content",1919                        "reasoning": "Streaming reasoning",1920                    },1921                },1922            ],1923        }1924        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1925        assert isinstance(message_chunk, AIMessageChunk)1926        assert (1927            message_chunk.additional_kwargs.get("reasoning_content")1928            == "Streaming reasoning"1929        )19301931    def test_model_provider_in_streaming_chunk(self) -> None:1932        """Test that model_provider is set in streaming chunk metadata."""1933        chunk: dict[str, Any] = {1934            "choices": [1935                {1936                    "delta": {"content": "Hello"},1937                },1938            ],1939        }1940        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1941        assert isinstance(message_chunk, AIMessageChunk)1942        assert message_chunk.response_metadata.get("model_provider") == "openrouter"19431944    def test_chunk_without_reasoning(self) -> None:1945        """Test that chunk without reasoning fields works correctly."""1946        chunk: dict[str, Any] = {"choices": [{"delta": {"content": "Hello"}}]}1947        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1948        assert isinstance(message_chunk, AIMessageChunk)1949        assert message_chunk.additional_kwargs.get("reasoning_content") is None19501951    def test_chunk_with_empty_delta(self) -> None:1952        """Test that chunk with empty delta works correctly."""1953        chunk: dict[str, Any] = {"choices": [{"delta": {}}]}1954        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1955        assert isinstance(message_chunk, AIMessageChunk)1956        assert message_chunk.additional_kwargs.get("reasoning_content") is None19571958    def test_chunk_with_tool_calls(self) -> None:1959        """Test that tool calls are extracted from streaming delta."""1960        chunk: dict[str, Any] = {1961            "choices": [1962                {1963                    "delta": {1964                        "tool_calls": [1965                            {1966                                "index": 0,1967                                "id": "call_1",1968                                "type": "function",1969                                "function": {1970                                    "name": "get_weather",1971                                    "arguments": '{"loc',1972                                },1973                            }1974                        ],1975                    },1976                },1977            ],1978        }1979        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1980        assert isinstance(message_chunk, AIMessageChunk)1981        assert len(message_chunk.tool_call_chunks) == 11982        assert message_chunk.tool_call_chunks[0]["name"] == "get_weather"1983        assert message_chunk.tool_call_chunks[0]["args"] == '{"loc'1984        assert message_chunk.tool_call_chunks[0]["id"] == "call_1"1985        assert message_chunk.tool_call_chunks[0]["index"] == 019861987    def test_chunk_with_malformed_tool_call_skips_bad_keeps_good(self) -> None:1988        """Test that a malformed tool call chunk is skipped; valid ones kept."""1989        chunk: dict[str, Any] = {1990            "choices": [1991                {1992                    "delta": {1993                        "tool_calls": [1994                            {1995                                "index": 0,1996                                "id": "call_good",1997                                "type": "function",1998                                "function": {1999                                    "name": "get_weather",2000                                    "arguments": "{}",

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.