libs/partners/openrouter/tests/unit_tests/test_chat_models.py PYTHON 3,416 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 3,416.
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-5.5"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_metadata_versions(self) -> None:278        """Test that metadata reports the correct version info."""279        model = _make_model()280        assert model.metadata is not None281        versions = model.metadata["lc_versions"]282        assert "langchain-core" in versions283        assert "langchain-openrouter" in versions284285    def test_client_created(self) -> None:286        """Test that OpenRouter SDK client is created."""287        model = _make_model()288        assert model.client is not None289290    def test_client_reused_for_same_params(self) -> None:291        """Test that the SDK client is reused when model is re-validated."""292        model = _make_model()293        client_1 = model.client294        # Re-validate does not replace the existing client295        model.validate_environment()  # type: ignore[operator]296        assert model.client is client_1297298    def test_app_url_passed_to_client(self) -> None:299        """Test that app_url is passed as HTTP-Referer header via httpx clients."""300        with patch("openrouter.OpenRouter") as mock_cls:301            mock_cls.return_value = MagicMock()302            ChatOpenRouter(303                model=MODEL_NAME,304                api_key=SecretStr("test-key"),305                app_url="https://myapp.com",306            )307            call_kwargs = mock_cls.call_args[1]308            assert call_kwargs["client"].headers["HTTP-Referer"] == "https://myapp.com"309310    def test_app_title_passed_to_client(self) -> None:311        """Test that app_title is passed as X-Title header via httpx clients."""312        with patch("openrouter.OpenRouter") as mock_cls:313            mock_cls.return_value = MagicMock()314            ChatOpenRouter(315                model=MODEL_NAME,316                api_key=SecretStr("test-key"),317                app_title="My App",318            )319            call_kwargs = mock_cls.call_args[1]320            assert call_kwargs["client"].headers["X-Title"] == "My App"321322    def test_default_attribution_headers(self) -> None:323        """Test that default attribution headers are sent when not overridden."""324        with patch("openrouter.OpenRouter") as mock_cls:325            mock_cls.return_value = MagicMock()326            ChatOpenRouter(327                model=MODEL_NAME,328                api_key=SecretStr("test-key"),329            )330            call_kwargs = mock_cls.call_args[1]331            sync_headers = call_kwargs["client"].headers332            assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"333            assert sync_headers["X-Title"] == "LangChain"334335    def test_user_attribution_overrides_defaults(self) -> None:336        """Test that user-supplied attribution overrides the defaults."""337        with patch("openrouter.OpenRouter") as mock_cls:338            mock_cls.return_value = MagicMock()339            ChatOpenRouter(340                model=MODEL_NAME,341                api_key=SecretStr("test-key"),342                app_url="https://my-custom-app.com",343                app_title="My Custom App",344            )345            call_kwargs = mock_cls.call_args[1]346            sync_headers = call_kwargs["client"].headers347            assert sync_headers["HTTP-Referer"] == "https://my-custom-app.com"348            assert sync_headers["X-Title"] == "My Custom App"349350    def test_app_categories_passed_to_client(self) -> None:351        """Test that app_categories injects custom httpx clients with header."""352        with patch("openrouter.OpenRouter") as mock_cls:353            mock_cls.return_value = MagicMock()354            ChatOpenRouter(355                model=MODEL_NAME,356                api_key=SecretStr("test-key"),357                app_categories=["cli-agent", "programming-app"],358            )359            call_kwargs = mock_cls.call_args[1]360            # Custom httpx clients should be created361            assert "client" in call_kwargs362            assert "async_client" in call_kwargs363            # Verify the header value is comma-joined364            sync_headers = call_kwargs["client"].headers365            assert sync_headers["X-OpenRouter-Categories"] == (366                "cli-agent,programming-app"367            )368            async_headers = call_kwargs["async_client"].headers369            assert async_headers["X-OpenRouter-Categories"] == (370                "cli-agent,programming-app"371            )372373    def test_app_categories_none_no_categories_header(self) -> None:374        """Test that no X-OpenRouter-Categories header when categories unset."""375        with patch("openrouter.OpenRouter") as mock_cls:376            mock_cls.return_value = MagicMock()377            ChatOpenRouter(378                model=MODEL_NAME,379                api_key=SecretStr("test-key"),380            )381            call_kwargs = mock_cls.call_args[1]382            # httpx clients still created for X-Title default383            sync_headers = call_kwargs["client"].headers384            assert "X-OpenRouter-Categories" not in sync_headers385386    def test_app_categories_empty_list_no_categories_header(self) -> None:387        """Test that an empty list does not inject categories header."""388        with patch("openrouter.OpenRouter") as mock_cls:389            mock_cls.return_value = MagicMock()390            ChatOpenRouter(391                model=MODEL_NAME,392                api_key=SecretStr("test-key"),393                app_categories=[],394            )395            call_kwargs = mock_cls.call_args[1]396            sync_headers = call_kwargs["client"].headers397            assert "X-OpenRouter-Categories" not in sync_headers398399    def test_app_categories_with_other_attribution(self) -> None:400        """Test that app_categories coexists with app_url and app_title."""401        with patch("openrouter.OpenRouter") as mock_cls:402            mock_cls.return_value = MagicMock()403            ChatOpenRouter(404                model=MODEL_NAME,405                api_key=SecretStr("test-key"),406                app_url="https://myapp.com",407                app_title="My App",408                app_categories=["cli-agent"],409            )410            call_kwargs = mock_cls.call_args[1]411            sync_headers = call_kwargs["client"].headers412            assert sync_headers["HTTP-Referer"] == "https://myapp.com"413            assert sync_headers["X-Title"] == "My App"414            assert sync_headers["X-OpenRouter-Categories"] == "cli-agent"415416    def test_app_title_none_no_x_title_header(self) -> None:417        """Test that X-Title header is omitted when app_title is explicitly None."""418        with patch("openrouter.OpenRouter") as mock_cls:419            mock_cls.return_value = MagicMock()420            ChatOpenRouter(421                model=MODEL_NAME,422                api_key=SecretStr("test-key"),423                app_title=None,424            )425            call_kwargs = mock_cls.call_args[1]426            sync_headers = call_kwargs["client"].headers427            assert "X-Title" not in sync_headers428429    def test_app_url_none_no_referer_header(self) -> None:430        """Test that HTTP-Referer header is omitted when app_url is explicitly None."""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                app_url=None,437            )438            call_kwargs = mock_cls.call_args[1]439            sync_headers = call_kwargs["client"].headers440            assert "HTTP-Referer" not in sync_headers441442    def test_no_attribution_no_custom_clients(self) -> None:443        """Test that no httpx clients are created when all attribution is None."""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_url=None,450                app_title=None,451                app_categories=None,452            )453            call_kwargs = mock_cls.call_args[1]454            assert "client" not in call_kwargs455            assert "async_client" not in call_kwargs456457    def test_reasoning_in_params(self) -> None:458        """Test that `reasoning` is included in default params."""459        model = _make_model(reasoning={"effort": "high"})460        params = model._default_params461        assert params["reasoning"] == {"effort": "high"}462463    def test_openrouter_provider_in_params(self) -> None:464        """Test that `openrouter_provider` is included in default params."""465        model = _make_model(openrouter_provider={"order": ["Anthropic"]})466        params = model._default_params467        assert params["provider"] == {"order": ["Anthropic"]}468469    def test_route_in_params(self) -> None:470        """Test that `route` is included in default params."""471        model = _make_model(route="fallback")472        params = model._default_params473        assert params["route"] == "fallback"474475    def test_optional_params_excluded_when_none(self) -> None:476        """Test that None optional params are not in default params."""477        model = _make_model()478        params = model._default_params479        assert "temperature" not in params480        assert "max_tokens" not in params481        assert "top_p" not in params482        assert "reasoning" not in params483484    def test_temperature_included_when_set(self) -> None:485        """Test that temperature is included when explicitly set."""486        model = _make_model(temperature=0.5)487        params = model._default_params488        assert params["temperature"] == 0.5489490491# ===========================================================================492# Serialization tests493# ===========================================================================494495496class TestSerialization:497    """Tests for serialization round-trips."""498499    def test_is_lc_serializable(self) -> None:500        """Test that ChatOpenRouter declares itself as serializable."""501        assert ChatOpenRouter.is_lc_serializable() is True502503    @pytest.mark.filterwarnings("ignore:The function `load` is in beta")504    def test_dumpd_load_roundtrip(self) -> None:505        """Test that dumpd/load round-trip preserves model config."""506        model = _make_model(temperature=0.7, max_tokens=100)507        serialized = dumpd(model)508        deserialized = load(509            serialized,510            valid_namespaces=["langchain_openrouter"],511            allowed_objects="all",512            secrets_from_env=False,513            secrets_map={"OPENROUTER_API_KEY": "test-key"},514        )515        assert isinstance(deserialized, ChatOpenRouter)516        assert deserialized.model_name == MODEL_NAME517        assert deserialized.temperature == 0.7518        assert deserialized.max_tokens == 100519520    def test_dumps_does_not_leak_secrets(self) -> None:521        """Test that dumps output does not contain the raw API key."""522        model = _make_model(api_key=SecretStr("super-secret-key"))523        serialized = dumps(model)524        assert "super-secret-key" not in serialized525526527# ===========================================================================528# Mocked generate / stream tests529# ===========================================================================530531532class TestMockedGenerate:533    """Tests for _generate / _agenerate with a mocked SDK client."""534535    def test_invoke_basic(self) -> None:536        """Test basic invoke returns an AIMessage via mocked SDK."""537        model = _make_model()538        model.client = MagicMock()539        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)540541        result = model.invoke("Hello")542        assert isinstance(result, AIMessage)543        assert result.content == "Hello!"544        model.client.chat.send.assert_called_once()545546    def test_invoke_with_tool_response(self) -> None:547        """Test invoke that returns tool calls."""548        model = _make_model()549        model.client = MagicMock()550        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)551552        result = model.invoke("What's the weather?")553        assert isinstance(result, AIMessage)554        assert len(result.tool_calls) == 1555        assert result.tool_calls[0]["name"] == "GetWeather"556557    def test_invoke_passes_correct_messages(self) -> None:558        """Test that invoke converts messages and passes them to the SDK."""559        model = _make_model()560        model.client = MagicMock()561        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)562563        model.invoke([HumanMessage(content="Hi")])564        call_kwargs = model.client.chat.send.call_args[1]565        assert call_kwargs["messages"] == [{"role": "user", "content": "Hi"}]566567    def test_invoke_strips_internal_kwargs(self) -> None:568        """Test that LangChain-internal kwargs are stripped before SDK call."""569        model = _make_model()570        model.client = MagicMock()571        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)572573        model._generate(574            [HumanMessage(content="Hi")],575            ls_structured_output_format={"kwargs": {"method": "function_calling"}},576        )577        call_kwargs = model.client.chat.send.call_args[1]578        assert "ls_structured_output_format" not in call_kwargs579580    def test_invoke_usage_metadata(self) -> None:581        """Test that usage metadata is populated on the response."""582        model = _make_model()583        model.client = MagicMock()584        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)585586        result = model.invoke("Hello")587        assert isinstance(result, AIMessage)588        assert result.usage_metadata is not None589        assert result.usage_metadata["input_tokens"] == 10590        assert result.usage_metadata["output_tokens"] == 5591        assert result.usage_metadata["total_tokens"] == 15592593    def test_stream_basic(self) -> None:594        """Test streaming returns AIMessageChunks via mocked SDK."""595        model = _make_model()596        model.client = MagicMock()597        model.client.chat.send.return_value = _MockSyncStream(598            [dict(c) for c in _STREAM_CHUNKS]599        )600601        chunks = list(model.stream("Hello"))602        assert len(chunks) > 0603        assert all(isinstance(c, AIMessageChunk) for c in chunks)604        # Concatenated content should be "Hello world"605        full_content = "".join(c.content for c in chunks if isinstance(c.content, str))606        assert "Hello" in full_content607        assert "world" in full_content608609    def test_stream_passes_stream_true(self) -> None:610        """Test that stream sends stream=True to the SDK."""611        model = _make_model()612        model.client = MagicMock()613        model.client.chat.send.return_value = _MockSyncStream(614            [dict(c) for c in _STREAM_CHUNKS]615        )616617        list(model.stream("Hello"))618        call_kwargs = model.client.chat.send.call_args[1]619        assert call_kwargs["stream"] is True620621    def test_invoke_with_streaming_flag(self) -> None:622        """Test that invoke delegates to stream when streaming=True."""623        model = _make_model(streaming=True)624        model.client = MagicMock()625        model.client.chat.send.return_value = _MockSyncStream(626            [dict(c) for c in _STREAM_CHUNKS]627        )628629        result = model.invoke("Hello")630        assert isinstance(result, AIMessage)631        call_kwargs = model.client.chat.send.call_args[1]632        assert call_kwargs["stream"] is True633634    async def test_ainvoke_basic(self) -> None:635        """Test async invoke returns an AIMessage via mocked SDK."""636        model = _make_model()637        model.client = MagicMock()638        model.client.chat.send_async = AsyncMock(639            return_value=_make_sdk_response(_SIMPLE_RESPONSE_DICT)640        )641642        result = await model.ainvoke("Hello")643        assert isinstance(result, AIMessage)644        assert result.content == "Hello!"645        model.client.chat.send_async.assert_awaited_once()646647    async def test_astream_basic(self) -> None:648        """Test async streaming returns AIMessageChunks via mocked SDK."""649        model = _make_model()650        model.client = MagicMock()651        model.client.chat.send_async = AsyncMock(652            return_value=_MockAsyncStream(_STREAM_CHUNKS)653        )654655        chunks = [c async for c in model.astream("Hello")]656        assert len(chunks) > 0657        assert all(isinstance(c, AIMessageChunk) for c in chunks)658659    def test_stream_response_metadata_fields(self) -> None:660        """Test response-level metadata in streaming response_metadata."""661        model = _make_model()662        model.client = MagicMock()663        stream_chunks: list[dict[str, Any]] = [664            {665                "choices": [666                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}667                ],668                "model": "anthropic/claude-sonnet-4-5",669                "system_fingerprint": "fp_stream123",670                "object": "chat.completion.chunk",671                "created": 1700000000.0,672                "id": "gen-stream-meta",673            },674            {675                "choices": [676                    {677                        "delta": {},678                        "finish_reason": "stop",679                        "native_finish_reason": "end_turn",680                        "index": 0,681                    }682                ],683                "model": "anthropic/claude-sonnet-4-5",684                "system_fingerprint": "fp_stream123",685                "object": "chat.completion.chunk",686                "created": 1700000000.0,687                "id": "gen-stream-meta",688            },689        ]690        model.client.chat.send.return_value = _MockSyncStream(stream_chunks)691692        chunks = list(model.stream("Hello"))693        assert len(chunks) >= 2694695        # Find the chunk with finish_reason (final metadata chunk)696        final = [697            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"698        ]699        assert len(final) == 1700        meta = final[0].response_metadata701        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"702        assert meta["system_fingerprint"] == "fp_stream123"703        assert meta["native_finish_reason"] == "end_turn"704        assert meta["finish_reason"] == "stop"705        assert meta["id"] == "gen-stream-meta"706        assert meta["created"] == 1700000000707        assert meta["object"] == "chat.completion.chunk"708709    async def test_astream_response_metadata_fields(self) -> None:710        """Test response-level metadata in async streaming response_metadata."""711        model = _make_model()712        model.client = MagicMock()713        stream_chunks: list[dict[str, Any]] = [714            {715                "choices": [716                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}717                ],718                "model": "anthropic/claude-sonnet-4-5",719                "system_fingerprint": "fp_async123",720                "object": "chat.completion.chunk",721                "created": 1700000000.0,722                "id": "gen-astream-meta",723            },724            {725                "choices": [726                    {727                        "delta": {},728                        "finish_reason": "stop",729                        "native_finish_reason": "end_turn",730                        "index": 0,731                    }732                ],733                "model": "anthropic/claude-sonnet-4-5",734                "system_fingerprint": "fp_async123",735                "object": "chat.completion.chunk",736                "created": 1700000000.0,737                "id": "gen-astream-meta",738            },739        ]740        model.client.chat.send_async = AsyncMock(741            return_value=_MockAsyncStream(stream_chunks)742        )743744        chunks = [c async for c in model.astream("Hello")]745        assert len(chunks) >= 2746747        # Find the chunk with finish_reason (final metadata chunk)748        final = [749            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"750        ]751        assert len(final) == 1752        meta = final[0].response_metadata753        assert meta["model_name"] == "anthropic/claude-sonnet-4-5"754        assert meta["system_fingerprint"] == "fp_async123"755        assert meta["native_finish_reason"] == "end_turn"756        assert meta["id"] == "gen-astream-meta"757        assert meta["created"] == 1700000000758        assert meta["object"] == "chat.completion.chunk"759760761# ===========================================================================762# Request payload verification763# ===========================================================================764765766class TestRequestPayload:767    """Tests verifying the exact dict sent to the SDK."""768769    @pytest.fixture(autouse=True)770    def _clear_openrouter_env(self, monkeypatch: pytest.MonkeyPatch) -> None:771        """Clear env vars that would otherwise leak into tests via `from_env`."""772        monkeypatch.delenv("OPENROUTER_SESSION_ID", raising=False)773774    def test_message_format_in_payload(self) -> None:775        """Test that messages are formatted correctly in the SDK call."""776        model = _make_model(temperature=0)777        model.client = MagicMock()778        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)779780        model.invoke(781            [782                SystemMessage(content="You are helpful."),783                HumanMessage(content="Hi"),784            ]785        )786        call_kwargs = model.client.chat.send.call_args[1]787        assert call_kwargs["messages"] == [788            {"role": "system", "content": "You are helpful."},789            {"role": "user", "content": "Hi"},790        ]791792    def test_model_kwargs_forwarded(self) -> None:793        """Test that extra model_kwargs are included in the SDK call."""794        model = _make_model(model_kwargs={"top_k": 50})795        model.client = MagicMock()796        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)797798        model.invoke("Hi")799        call_kwargs = model.client.chat.send.call_args[1]800        assert call_kwargs["top_k"] == 50801802    def test_stop_sequences_in_payload(self) -> None:803        """Test that stop sequences are passed to the SDK."""804        model = _make_model()805        model.client = MagicMock()806        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)807808        model.invoke("Hi", stop=["END"])809        call_kwargs = model.client.chat.send.call_args[1]810        assert call_kwargs["stop"] == ["END"]811812    def test_tool_format_in_payload(self) -> None:813        """Test that tools are formatted in OpenAI-compatible structure."""814        model = _make_model()815        model.client = MagicMock()816        model.client.chat.send.return_value = _make_sdk_response(_TOOL_RESPONSE_DICT)817818        bound = model.bind_tools([GetWeather])819        bound.invoke("What's the weather?")820        call_kwargs = model.client.chat.send.call_args[1]821        tools = call_kwargs["tools"]822        assert len(tools) == 1823        assert tools[0]["type"] == "function"824        assert tools[0]["function"]["name"] == "GetWeather"825        assert "parameters" in tools[0]["function"]826827    def test_openrouter_params_in_payload(self) -> None:828        """Test that OpenRouter-specific params appear in the SDK call."""829        model = _make_model(830            reasoning={"effort": "high"},831            openrouter_provider={"order": ["Anthropic"]},832            route="fallback",833        )834        model.client = MagicMock()835        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)836837        model.invoke("Hi")838        call_kwargs = model.client.chat.send.call_args[1]839        assert call_kwargs["reasoning"] == {"effort": "high"}840        assert call_kwargs["provider"] == {"order": ["Anthropic"]}841        assert call_kwargs["route"] == "fallback"842843    def test_session_id_and_trace_in_payload(self) -> None:844        """Test that session_id and trace are forwarded to the SDK."""845        model = _make_model(846            session_id="session-abc",847            trace={"trace_id": "trace-1", "span_name": "summarize"},848        )849        model.client = MagicMock()850        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)851852        model.invoke("Hi")853        call_kwargs = model.client.chat.send.call_args[1]854        assert call_kwargs["session_id"] == "session-abc"855        assert call_kwargs["trace"] == {856            "trace_id": "trace-1",857            "span_name": "summarize",858        }859860    def test_session_id_and_trace_omitted_when_unset(self) -> None:861        """Test that session_id and trace are omitted when not configured."""862        model = _make_model()863        model.client = MagicMock()864        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)865866        model.invoke("Hi")867        call_kwargs = model.client.chat.send.call_args[1]868        assert "session_id" not in call_kwargs869        assert "trace" not in call_kwargs870871    def test_session_id_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:872        """Test that session_id falls back to OPENROUTER_SESSION_ID env var."""873        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session-xyz")874        model = _make_model()875        assert model.session_id == "env-session-xyz"876877        model.client = MagicMock()878        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)879        model.invoke("Hi")880        call_kwargs = model.client.chat.send.call_args[1]881        assert call_kwargs["session_id"] == "env-session-xyz"882883    def test_session_id_constructor_overrides_env(884        self, monkeypatch: pytest.MonkeyPatch885    ) -> None:886        """Test that an explicit session_id wins over the env var."""887        monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session")888        model = _make_model(session_id="explicit-session")889        assert model.session_id == "explicit-session"890891        model.client = MagicMock()892        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)893        model.invoke("Hi")894        call_kwargs = model.client.chat.send.call_args[1]895        assert call_kwargs["session_id"] == "explicit-session"896897    def test_session_id_per_call_override(self) -> None:898        """Test that a per-call session_id kwarg overrides the constructor value."""899        model = _make_model(session_id="constructor-session")900        model.client = MagicMock()901        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)902903        model.invoke("Hi", session_id="call-session")904        first_call_kwargs = model.client.chat.send.call_args[1]905        assert first_call_kwargs["session_id"] == "call-session"906907        # Per-call override must not mutate the constructor value, and the next908        # call without the kwarg should fall back to the constructor's value.909        assert model.session_id == "constructor-session"910        model.invoke("Hi")911        second_call_kwargs = model.client.chat.send.call_args[1]912        assert second_call_kwargs["session_id"] == "constructor-session"913914    def test_trace_per_call_override(self) -> None:915        """Test that a per-call trace kwarg overrides the constructor value."""916        constructor_trace = {"trace_id": "constructor-trace"}917        call_trace = {"trace_id": "call-trace", "span_name": "summarize"}918        model = _make_model(trace=constructor_trace)919        model.client = MagicMock()920        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)921922        model.invoke("Hi", trace=call_trace)923        first_call_kwargs = model.client.chat.send.call_args[1]924        assert first_call_kwargs["trace"] == call_trace925926        assert model.trace == constructor_trace927        model.invoke("Hi")928        second_call_kwargs = model.client.chat.send.call_args[1]929        assert second_call_kwargs["trace"] == constructor_trace930931    def test_empty_session_id_treated_as_unset(932        self, monkeypatch: pytest.MonkeyPatch933    ) -> None:934        """Test that empty `session_id` (constructor or env) is not forwarded."""935        # Explicit empty string on the constructor.936        model = _make_model(session_id="")937        model.client = MagicMock()938        model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)939        model.invoke("Hi")940        assert "session_id" not in model.client.chat.send.call_args[1]941942        # Empty string sourced from the env var.943        monkeypatch.setenv("OPENROUTER_SESSION_ID", "")944        env_model = _make_model()945        env_model.client = MagicMock()946        env_model.client.chat.send.return_value = _make_sdk_response(947            _SIMPLE_RESPONSE_DICT948        )949        env_model.invoke("Hi")950        assert "session_id" not in env_model.client.chat.send.call_args[1]951952953# ===========================================================================954# bind_tools tests955# ===========================================================================956957958class TestBindTools:959    """Tests for the bind_tools public method."""960961    @pytest.mark.parametrize(962        "tool_choice",963        [964            "auto",965            "none",966            "required",967            "GetWeather",968            {"type": "function", "function": {"name": "GetWeather"}},969            None,970        ],971    )972    def test_bind_tools_tool_choice(self, tool_choice: Any) -> None:973        """Test bind_tools accepts various tool_choice values."""974        model = _make_model()975        bound = model.bind_tools(976            [GetWeather, GenerateUsername], tool_choice=tool_choice977        )978        assert isinstance(bound, RunnableBinding)979980    def test_bind_tools_bool_true_single_tool(self) -> None:981        """Test bind_tools with tool_choice=True and a single tool."""982        model = _make_model()983        bound = model.bind_tools([GetWeather], tool_choice=True)984        assert isinstance(bound, RunnableBinding)985        kwargs = bound.kwargs986        assert kwargs["tool_choice"] == {987            "type": "function",988            "function": {"name": "GetWeather"},989        }990991    def test_bind_tools_bool_true_multiple_tools_raises(self) -> None:992        """Test bind_tools with tool_choice=True and multiple tools raises."""993        model = _make_model()994        with pytest.raises(ValueError, match="tool_choice can only be True"):995            model.bind_tools([GetWeather, GenerateUsername], tool_choice=True)996997    def test_bind_tools_any_maps_to_required(self) -> None:998        """Test that tool_choice='any' is mapped to 'required'."""999        model = _make_model()1000        bound = model.bind_tools([GetWeather], tool_choice="any")1001        assert isinstance(bound, RunnableBinding)1002        assert bound.kwargs["tool_choice"] == "required"10031004    def test_bind_tools_string_name_becomes_dict(self) -> None:1005        """Test that a specific tool name string is converted to a dict."""1006        model = _make_model()1007        bound = model.bind_tools([GetWeather], tool_choice="GetWeather")1008        assert isinstance(bound, RunnableBinding)1009        assert bound.kwargs["tool_choice"] == {1010            "type": "function",1011            "function": {"name": "GetWeather"},1012        }10131014    def test_bind_tools_formats_tools_correctly(self) -> None:1015        """Test that tools are converted to OpenAI format."""1016        model = _make_model()1017        bound = model.bind_tools([GetWeather])1018        assert isinstance(bound, RunnableBinding)1019        tools = bound.kwargs["tools"]1020        assert len(tools) == 11021        assert tools[0]["type"] == "function"1022        assert tools[0]["function"]["name"] == "GetWeather"10231024    def test_bind_tools_no_choice_omits_key(self) -> None:1025        """Test that tool_choice=None does not set tool_choice in kwargs."""1026        model = _make_model()1027        bound = model.bind_tools([GetWeather], tool_choice=None)1028        assert isinstance(bound, RunnableBinding)1029        assert "tool_choice" not in bound.kwargs10301031    def test_bind_tools_strict_forwarded(self) -> None:1032        """Test that strict param is forwarded to tool definitions."""1033        model = _make_model()1034        bound = model.bind_tools([GetWeather], strict=True)1035        assert isinstance(bound, RunnableBinding)1036        tools = bound.kwargs["tools"]1037        assert tools[0]["function"]["strict"] is True10381039    def test_bind_tools_strict_none_by_default(self) -> None:1040        """Test that strict is not set when not provided."""1041        model = _make_model()1042        bound = model.bind_tools([GetWeather])1043        assert isinstance(bound, RunnableBinding)1044        tools = bound.kwargs["tools"]1045        assert "strict" not in tools[0]["function"]104610471048# ===========================================================================1049# with_structured_output tests1050# ===========================================================================105110521053class TestWithStructuredOutput:1054    """Tests for the with_structured_output public method."""10551056    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1057    @pytest.mark.parametrize("include_raw", ["yes", "no"])1058    def test_with_structured_output_pydantic(1059        self,1060        method: Literal["function_calling", "json_schema"],1061        include_raw: str,1062    ) -> None:1063        """Test with_structured_output using a Pydantic schema."""1064        model = _make_model()1065        structured = model.with_structured_output(1066            GenerateUsername, method=method, include_raw=(include_raw == "yes")1067        )1068        assert structured is not None10691070    @pytest.mark.parametrize("method", ["function_calling", "json_schema"])1071    def test_with_structured_output_dict_schema(1072        self,1073        method: Literal["function_calling", "json_schema"],1074    ) -> None:1075        """Test with_structured_output using a JSON schema dict."""1076        schema = GenerateUsername.model_json_schema()1077        model = _make_model()1078        structured = model.with_structured_output(schema, method=method)1079        assert structured is not None10801081    def test_with_structured_output_none_schema_function_calling_raises(self) -> None:1082        """Test that schema=None with function_calling raises ValueError."""1083        model = _make_model()1084        with pytest.raises(ValueError, match="schema must be specified"):1085            model.with_structured_output(None, method="function_calling")10861087    def test_with_structured_output_none_schema_json_schema_raises(self) -> None:1088        """Test that schema=None with json_schema raises ValueError."""1089        model = _make_model()1090        with pytest.raises(ValueError, match="schema must be specified"):1091            model.with_structured_output(None, method="json_schema")10921093    def test_with_structured_output_invalid_method_raises(self) -> None:1094        """Test that an unrecognized method raises ValueError."""1095        model = _make_model()1096        with pytest.raises(ValueError, match="Unrecognized method"):1097            model.with_structured_output(1098                GenerateUsername,1099                method="invalid",  # type: ignore[arg-type]1100            )11011102    def test_with_structured_output_json_schema_sets_response_format(self) -> None:1103        """Test that json_schema method sets response_format correctly."""1104        model = _make_model()1105        structured = model.with_structured_output(1106            GenerateUsername, method="json_schema"1107        )1108        # The first step in the chain should be the bound model1109        bound = structured.first  # type: ignore[attr-defined]1110        assert isinstance(bound, RunnableBinding)1111        rf = bound.kwargs["response_format"]1112        assert rf["type"] == "json_schema"1113        assert rf["json_schema"]["name"] == "GenerateUsername"11141115    def test_with_structured_output_json_mode_warns_and_falls_back(self) -> None:1116        """Test that json_mode warns and falls back to json_schema."""1117        model = _make_model()1118        with pytest.warns(match="Defaulting to 'json_schema'"):1119            structured = model.with_structured_output(1120                GenerateUsername,1121                method="json_mode",  # type: ignore[arg-type]1122            )1123        bound = structured.first  # type: ignore[attr-defined]1124        assert isinstance(bound, RunnableBinding)1125        rf = bound.kwargs["response_format"]1126        assert rf["type"] == "json_schema"11271128    def test_with_structured_output_strict_function_calling(self) -> None:1129        """Test that strict is forwarded for function_calling method."""1130        model = _make_model()1131        structured = model.with_structured_output(1132            GenerateUsername, method="function_calling", strict=True1133        )1134        bound = structured.first  # type: ignore[attr-defined]1135        assert isinstance(bound, RunnableBinding)1136        tools = bound.kwargs["tools"]1137        assert tools[0]["function"]["strict"] is True11381139    def test_with_structured_output_strict_json_schema(self) -> None:1140        """Test that strict is forwarded for json_schema method."""1141        model = _make_model()1142        structured = model.with_structured_output(1143            GenerateUsername, method="json_schema", strict=True1144        )1145        bound = structured.first  # type: ignore[attr-defined]1146        assert isinstance(bound, RunnableBinding)1147        rf = bound.kwargs["response_format"]1148        assert rf["json_schema"]["strict"] is True11491150    def test_with_structured_output_json_mode_with_strict_warns_and_forwards(1151        self,1152    ) -> None:1153        """Test json_mode with strict warns and falls back to json_schema."""1154        model = _make_model()1155        with pytest.warns(match="Defaulting to 'json_schema'"):1156            structured = model.with_structured_output(1157                GenerateUsername,1158                method="json_mode",  # type: ignore[arg-type]1159                strict=True,1160            )1161        bound = structured.first  # type: ignore[attr-defined]1162        assert isinstance(bound, RunnableBinding)1163        rf = bound.kwargs["response_format"]1164        assert rf["type"] == "json_schema"1165        assert rf["json_schema"]["strict"] is True116611671168# ===========================================================================1169# Message conversion tests1170# ===========================================================================117111721173class TestMessageConversion:1174    """Tests for message conversion functions."""11751176    def test_human_message_to_dict(self) -> None:1177        """Test converting HumanMessage to dict."""1178        msg = HumanMessage(content="Hello")1179        result = _convert_message_to_dict(msg)1180        assert result == {"role": "user", "content": "Hello"}11811182    def test_system_message_to_dict(self) -> None:1183        """Test converting SystemMessage to dict."""1184        msg = SystemMessage(content="You are helpful.")1185        result = _convert_message_to_dict(msg)1186        assert result == {"role": "system", "content": "You are helpful."}11871188    def test_ai_message_to_dict(self) -> None:1189        """Test converting AIMessage to dict."""1190        msg = AIMessage(content="Hi there!")1191        result = _convert_message_to_dict(msg)1192        assert result == {"role": "assistant", "content": "Hi there!"}11931194    def test_ai_message_with_reasoning_content_to_dict(self) -> None:1195        """Test that reasoning_content is preserved when converting back to dict."""1196        msg = AIMessage(1197            content="The answer is 42.",1198            additional_kwargs={"reasoning_content": "Let me think about this..."},1199        )1200        result = _convert_message_to_dict(msg)1201        assert result["role"] == "assistant"1202        assert result["content"] == "The answer is 42."1203        assert result["reasoning"] == "Let me think about this..."12041205    def test_ai_message_with_fragmented_reasoning_details_merged(self) -> None:1206        """Fragmented `reasoning_details` are merged before serialization.12071208        Float `index` values mirror what `ChatOpenRouter.stream()` produces1209        (the OpenRouter SDK coerces `index` via Pydantic). With float1210        `index`, `langchain_core.utils._merge.merge_lists` does not auto-merge1211        list entries (its index-match path requires `int`), so fragments1212        accumulate as separate list items and require this helper to merge1213        them before the next API turn.1214        """1215        details = [1216            {1217                "type": "reasoning.text",1218                "text": "The",1219                "format": "anthropic-claude-v1",1220                "index": 0.0,1221            },1222            {1223                "type": "reasoning.text",1224                "text": " user wants",1225                "format": "anthropic-claude-v1",1226                "index": 0.0,1227            },1228            {1229                "type": "reasoning.text",1230                "signature": "sig_abc123",1231                "format": "anthropic-claude-v1",1232                "index": 0.0,1233            },1234        ]1235        msg = AIMessage(1236            content="Answer",1237            additional_kwargs={"reasoning_details": details},1238        )1239        result = _convert_message_to_dict(msg)1240        assert result["reasoning_details"] == [1241            {1242                "type": "reasoning.text",1243                "text": "The user wants",1244                "format": "anthropic-claude-v1",1245                "signature": "sig_abc123",1246                "index": 0.0,1247            }1248        ]1249        assert "reasoning" not in result12501251    def test_ai_message_distinct_reasoning_details_preserved(self) -> None:1252        """Distinct entries (different `index`) are not merged."""1253        details = [1254            {"type": "reasoning.text", "text": "First thought", "index": 0},1255            {"type": "reasoning.text", "text": "Second thought", "index": 1},1256        ]1257        msg = AIMessage(1258            content="Answer",1259            additional_kwargs={"reasoning_details": details},1260        )1261        result = _convert_message_to_dict(msg)1262        assert result["reasoning_details"] == details12631264    def test_ai_message_unindexed_reasoning_details_not_merged(self) -> None:1265        """Entries without an `index` are passed through unchanged."""1266        details = [1267            {"type": "reasoning.text", "text": "First"},1268            {"type": "reasoning.text", "text": "Second"},1269        ]1270        msg = AIMessage(1271            content="Answer",1272            additional_kwargs={"reasoning_details": details},1273        )1274        result = _convert_message_to_dict(msg)1275        assert result["reasoning_details"] == details12761277    def test_ai_message_interleaved_index_fragments_preserved(self) -> None:1278        """Only consecutive same-`index` runs merge; interleaved runs stay split."""1279        details = [1280            {"type": "reasoning.text", "text": "A", "index": 0},1281            {"type": "reasoning.text", "text": "B", "index": 1},1282            {"type": "reasoning.text", "text": "C", "index": 0},1283            {"type": "reasoning.text", "text": "D", "index": 1},1284        ]1285        msg = AIMessage(1286            content="Answer",1287            additional_kwargs={"reasoning_details": details},1288        )1289        result = _convert_message_to_dict(msg)1290        assert result["reasoning_details"] == details12911292    def test_ai_message_fragment_metadata_preserved(self) -> None:1293        """Test that metadata from later fragments is preserved after merge."""1294        details = [1295            {"type": "reasoning.text", "text": "thinking...", "index": 0},1296            {1297                "type": "reasoning.text",1298                "text": " done",1299                "index": 0,1300                "signature": "sig_abc123",1301            },1302        ]1303        msg = AIMessage(1304            content="Answer",1305            additional_kwargs={"reasoning_details": details},1306        )1307        result = _convert_message_to_dict(msg)1308        assert len(result["reasoning_details"]) == 11309        assert result["reasoning_details"][0]["text"] == "thinking... done"1310        assert result["reasoning_details"][0]["signature"] == "sig_abc123"13111312    def test_streamed_reasoning_details_roundtrip_to_next_turn_payload(self) -> None:1313        """Test the chunk-merge-to-next-turn serialization path from issue #36400."""1314        chunk_dicts = [1315            {"choices": [{"delta": {"role": "assistant", "content": ""}, "index": 0}]},1316            {1317                "choices": [1318                    {1319                        "delta": {1320                            "reasoning_details": [1321                                {1322                                    "type": "reasoning.text",1323                                    "text": "The",1324                                    "format": "anthropic-claude-v1",1325                                    "index": 0.0,1326                                }1327                            ]1328                        },1329                        "index": 0,1330                    }1331                ]1332            },1333            {1334                "choices": [1335                    {1336                        "delta": {1337                            "reasoning_details": [1338                                {1339                                    "type": "reasoning.text",1340                                    "text": " user wants",1341                                    "format": "anthropic-claude-v1",1342                                    "index": 0.0,1343                                }1344                            ]1345                        },1346                        "index": 0,1347                    }1348                ]1349            },1350            {1351                "choices": [1352                    {1353                        "delta": {1354                            "reasoning_details": [1355                                {1356                                    "type": "reasoning.text",1357                                    "signature": "sig_abc123",1358                                    "format": "anthropic-claude-v1",1359                                    "index": 0.0,1360                                }1361                            ]1362                        },1363                        "index": 0,1364                    }1365                ]1366            },1367            {"choices": [{"delta": {"content": "Answer"}, "index": 0}]},1368        ]1369        chunks = [1370            _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1371            for chunk in chunk_dicts1372        ]1373        merged_chunk = chunks[0]1374        for chunk in chunks[1:]:1375            merged_chunk = merged_chunk + chunk13761377        assert len(merged_chunk.additional_kwargs["reasoning_details"]) == 313781379        msg = AIMessage(1380            content=merged_chunk.content,1381            additional_kwargs=merged_chunk.additional_kwargs,1382            response_metadata=merged_chunk.response_metadata,1383        )13841385        result = _convert_message_to_dict(msg)1386        assert result["reasoning_details"] == [1387            {1388                "type": "reasoning.text",1389                "text": "The user wants",1390                "format": "anthropic-claude-v1",1391                "signature": "sig_abc123",1392                "index": 0.0,1393            }1394        ]13951396    def test_ai_message_with_both_reasoning_fields_to_dict(self) -> None:1397        """Test that both reasoning_content and reasoning_details are preserved."""1398        details = [{"type": "reasoning.text", "text": "detailed thinking"}]1399        msg = AIMessage(1400            content="Answer",1401            additional_kwargs={1402                "reasoning_content": "I thought about it",1403                "reasoning_details": details,1404            },1405        )1406        result = _convert_message_to_dict(msg)1407        assert result["reasoning"] == "I thought about it"1408        assert result["reasoning_details"] == details14091410    def test_reasoning_roundtrip_through_dict(self) -> None:1411        """Test that reasoning survives dict -> message -> dict roundtrip."""1412        original_dict = {1413            "role": "assistant",1414            "content": "The answer",1415            "reasoning": "My thinking process",1416            "reasoning_details": [{"type": "reasoning.text", "text": "step-by-step"}],1417        }1418        msg = _convert_dict_to_message(original_dict)1419        result = _convert_message_to_dict(msg)1420        assert result["reasoning"] == "My thinking process"1421        assert result["reasoning_details"] == original_dict["reasoning_details"]14221423    def test_tool_message_to_dict(self) -> None:1424        """Test converting ToolMessage to dict."""1425        msg = ToolMessage(content="result", tool_call_id="call_123")1426        result = _convert_message_to_dict(msg)1427        assert result == {1428            "role": "tool",1429            "content": "result",1430            "tool_call_id": "call_123",1431        }14321433    def test_chat_message_to_dict(self) -> None:1434        """Test converting ChatMessage to dict."""1435        msg = ChatMessage(content="Hello", role="developer")1436        result = _convert_message_to_dict(msg)1437        assert result == {"role": "developer", "content": "Hello"}14381439    def test_ai_message_with_tool_calls_to_dict(self) -> None:1440        """Test converting AIMessage with tool calls to dict."""1441        msg = AIMessage(1442            content="",1443            tool_calls=[1444                {1445                    "name": "get_weather",1446                    "args": {"location": "SF"},1447                    "id": "call_1",1448                    "type": "tool_call",1449                }1450            ],1451        )1452        result = _convert_message_to_dict(msg)1453        assert result["role"] == "assistant"1454        assert result["content"] is None1455        assert len(result["tool_calls"]) == 11456        assert result["tool_calls"][0]["function"]["name"] == "get_weather"14571458    def test_dict_to_ai_message(self) -> None:1459        """Test converting dict to AIMessage."""1460        d = {"role": "assistant", "content": "Hello!"}1461        msg = _convert_dict_to_message(d)1462        assert isinstance(msg, AIMessage)1463        assert msg.content == "Hello!"14641465    def test_dict_to_ai_message_with_reasoning(self) -> None:1466        """Test that reasoning is extracted from response dict."""1467        d = {1468            "role": "assistant",1469            "content": "Answer",1470            "reasoning": "Let me think...",1471        }1472        msg = _convert_dict_to_message(d)1473        assert isinstance(msg, AIMessage)1474        assert msg.additional_kwargs["reasoning_content"] == "Let me think..."14751476    def test_dict_to_ai_message_with_tool_calls(self) -> None:1477        """Test converting dict with tool calls to AIMessage."""1478        d = {1479            "role": "assistant",1480            "content": "",1481            "tool_calls": [1482                {1483                    "id": "call_1",1484                    "type": "function",1485                    "function": {1486                        "name": "get_weather",1487                        "arguments": '{"location": "SF"}',1488                    },1489                }1490            ],1491        }1492        msg = _convert_dict_to_message(d)1493        assert isinstance(msg, AIMessage)1494        assert len(msg.tool_calls) == 11495        assert msg.tool_calls[0]["name"] == "get_weather"14961497    def test_dict_to_ai_message_with_invalid_tool_calls(self) -> None:1498        """Test that malformed tool calls produce invalid_tool_calls."""1499        d = {1500            "role": "assistant",1501            "content": "",1502            "tool_calls": [1503                {1504                    "id": "call_bad",1505                    "type": "function",1506                    "function": {1507                        "name": "get_weather",1508                        "arguments": "not-valid-json{{{",1509                    },1510                }1511            ],1512        }1513        msg = _convert_dict_to_message(d)1514        assert isinstance(msg, AIMessage)1515        assert len(msg.invalid_tool_calls) == 11516        assert len(msg.tool_calls) == 01517        assert msg.invalid_tool_calls[0]["name"] == "get_weather"15181519    def test_dict_to_human_message(self) -> None:1520        """Test converting dict to HumanMessage."""1521        d = {"role": "user", "content": "Hi"}1522        msg = _convert_dict_to_message(d)1523        assert isinstance(msg, HumanMessage)15241525    def test_dict_to_system_message(self) -> None:1526        """Test converting dict to SystemMessage."""1527        d = {"role": "system", "content": "Be helpful"}1528        msg = _convert_dict_to_message(d)1529        assert isinstance(msg, SystemMessage)15301531    def test_dict_to_tool_message(self) -> None:1532        """Test converting dict with role=tool to ToolMessage."""1533        d = {1534            "role": "tool",1535            "content": "result data",1536            "tool_call_id": "call_42",1537            "name": "get_weather",1538        }1539        msg = _convert_dict_to_message(d)1540        assert isinstance(msg, ToolMessage)1541        assert msg.content == "result data"1542        assert msg.tool_call_id == "call_42"1543        assert msg.additional_kwargs["name"] == "get_weather"15441545    def test_dict_to_chat_message_unknown_role(self) -> None:1546        """Test that unrecognized roles fall back to ChatMessage."""1547        d = {"role": "developer", "content": "Some content"}1548        with pytest.warns(UserWarning, match="Unrecognized message role"):1549            msg = _convert_dict_to_message(d)1550        assert isinstance(msg, ChatMessage)1551        assert msg.role == "developer"1552        assert msg.content == "Some content"15531554    def test_ai_message_with_list_content_filters_non_text(self) -> None:1555        """Test that non-text blocks are filtered from AIMessage list content."""1556        msg = AIMessage(1557            content=[1558                {"type": "text", "text": "Hello"},1559                {"type": "image_url", "image_url": {"url": "http://example.com"}},1560            ]1561        )1562        result = _convert_message_to_dict(msg)1563        assert result["content"] == [{"type": "text", "text": "Hello"}]156415651566# ===========================================================================1567# _create_chat_result tests1568# ===========================================================================156915701571class TestCreateChatResult:1572    """Tests for _create_chat_result."""15731574    def test_model_provider_in_response_metadata(self) -> None:1575        """Test that model_provider is set in response metadata."""1576        model = _make_model()1577        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1578        assert (1579            result.generations[0].message.response_metadata.get("model_provider")1580            == "openrouter"1581        )15821583    def test_reasoning_from_response(self) -> None:1584        """Test that reasoning content is extracted from response."""1585        model = _make_model()1586        response_dict: dict[str, Any] = {1587            "choices": [1588                {1589                    "message": {1590                        "role": "assistant",1591                        "content": "Answer",1592                        "reasoning": "Let me think...",1593                    },1594                    "finish_reason": "stop",1595                }1596            ],1597        }1598        result = model._create_chat_result(response_dict)1599        assert (1600            result.generations[0].message.additional_kwargs.get("reasoning_content")1601            == "Let me think..."1602        )16031604    def test_usage_metadata_created(self) -> None:1605        """Test that usage metadata is created from token usage."""1606        model = _make_model()1607        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1608        msg = result.generations[0].message1609        assert isinstance(msg, AIMessage)1610        usage = msg.usage_metadata1611        assert usage is not None1612        assert usage["input_tokens"] == 101613        assert usage["output_tokens"] == 51614        assert usage["total_tokens"] == 1516151616    def test_tool_calls_in_response(self) -> None:1617        """Test that tool calls are extracted from response."""1618        model = _make_model()1619        result = model._create_chat_result(_TOOL_RESPONSE_DICT)1620        msg = result.generations[0].message1621        assert isinstance(msg, AIMessage)1622        assert len(msg.tool_calls) == 11623        assert msg.tool_calls[0]["name"] == "GetWeather"16241625    def test_response_model_in_llm_output(self) -> None:1626        """Test that the response model is included in llm_output."""1627        model = _make_model()1628        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1629        assert result.llm_output is not None1630        assert result.llm_output["model_name"] == MODEL_NAME16311632    def test_response_model_propagated_to_llm_output(self) -> None:1633        """Test that llm_output uses response model when available."""1634        model = _make_model()1635        response = {1636            **_SIMPLE_RESPONSE_DICT,1637            "model": MODEL_NAME,1638        }1639        result = model._create_chat_result(response)1640        assert result.llm_output is not None1641        assert result.llm_output["model_name"] == MODEL_NAME16421643    def test_system_fingerprint_in_metadata(self) -> None:1644        """Test that system_fingerprint is included in response_metadata."""1645        model = _make_model()1646        response = {1647            **_SIMPLE_RESPONSE_DICT,1648            "system_fingerprint": "fp_abc123",1649        }1650        result = model._create_chat_result(response)1651        msg = result.generations[0].message1652        assert isinstance(msg, AIMessage)1653        assert msg.response_metadata["system_fingerprint"] == "fp_abc123"16541655    def test_native_finish_reason_in_metadata(self) -> None:1656        """Test that native_finish_reason is included in response_metadata."""1657        model = _make_model()1658        response: dict[str, Any] = {1659            **_SIMPLE_RESPONSE_DICT,1660            "choices": [1661                {1662                    "message": {"role": "assistant", "content": "Hello!"},1663                    "finish_reason": "stop",1664                    "native_finish_reason": "end_turn",1665                    "index": 0,1666                }1667            ],1668        }1669        result = model._create_chat_result(response)1670        msg = result.generations[0].message1671        assert isinstance(msg, AIMessage)1672        assert msg.response_metadata["native_finish_reason"] == "end_turn"16731674    def test_cost_in_response_metadata(self) -> None:1675        """Test that OpenRouter cost data is surfaced in response_metadata."""1676        model = _make_model()1677        response: dict[str, Any] = {1678            **_SIMPLE_RESPONSE_DICT,1679            "usage": {1680                **_SIMPLE_RESPONSE_DICT["usage"],1681                "cost": 7.5e-05,1682                "cost_details": {1683                    "upstream_inference_cost": 7.745e-05,1684                    "upstream_inference_prompt_cost": 8.95e-06,1685                    "upstream_inference_completions_cost": 6.85e-05,1686                },1687            },1688        }1689        result = model._create_chat_result(response)1690        msg = result.generations[0].message1691        assert isinstance(msg, AIMessage)1692        assert msg.response_metadata["cost"] == 7.5e-051693        assert msg.response_metadata["cost_details"] == {1694            "upstream_inference_cost": 7.745e-05,1695            "upstream_inference_prompt_cost": 8.95e-06,1696            "upstream_inference_completions_cost": 6.85e-05,1697        }16981699    def test_cost_absent_when_not_in_usage(self) -> None:1700        """Test that cost fields are not added when not present in usage."""1701        model = _make_model()1702        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1703        msg = result.generations[0].message1704        assert isinstance(msg, AIMessage)1705        assert "cost" not in msg.response_metadata1706        assert "cost_details" not in msg.response_metadata17071708    def test_stream_cost_survives_final_chunk(self) -> None:1709        """Test that cost fields are preserved on the final streaming chunk.17101711        The final chunk carries both finish_reason metadata and usage/cost data.1712        Regression test: generation_info must merge into response_metadata, not1713        replace it, so cost fields set by _convert_chunk_to_message_chunk are1714        not lost.1715        """1716        model = _make_model()1717        model.client = MagicMock()1718        cost_details = {1719            "upstream_inference_cost": 7.745e-05,1720            "upstream_inference_prompt_cost": 8.95e-06,1721            "upstream_inference_completions_cost": 6.85e-05,1722        }1723        stream_chunks: list[dict[str, Any]] = [1724            {1725                "choices": [1726                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}1727                ],1728            },1729            {1730                "choices": [1731                    {1732                        "delta": {},1733                        "finish_reason": "stop",1734                        "index": 0,1735                    }1736                ],1737                "model": "openai/gpt-4o-mini",1738                "id": "gen-cost-stream",1739                "usage": {1740                    "prompt_tokens": 10,1741                    "completion_tokens": 5,1742                    "total_tokens": 15,1743                    "cost": 7.5e-05,1744                    "cost_details": cost_details,1745                },1746            },1747        ]1748        model.client.chat.send.return_value = _MockSyncStream(stream_chunks)17491750        chunks = list(model.stream("Hello"))1751        final = [1752            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"1753        ]1754        assert len(final) == 11755        meta = final[0].response_metadata1756        assert meta["cost"] == 7.5e-051757        assert meta["cost_details"] == cost_details1758        assert meta["finish_reason"] == "stop"17591760    async def test_astream_cost_survives_final_chunk(self) -> None:1761        """Test that cost fields are preserved on the final async streaming chunk.17621763        Same regression coverage as the sync test above, for the _astream path.1764        """1765        model = _make_model()1766        model.client = MagicMock()1767        cost_details = {1768            "upstream_inference_cost": 7.745e-05,1769            "upstream_inference_prompt_cost": 8.95e-06,1770            "upstream_inference_completions_cost": 6.85e-05,1771        }1772        stream_chunks: list[dict[str, Any]] = [1773            {1774                "choices": [1775                    {"delta": {"role": "assistant", "content": "Hi"}, "index": 0}1776                ],1777            },1778            {1779                "choices": [1780                    {1781                        "delta": {},1782                        "finish_reason": "stop",1783                        "index": 0,1784                    }1785                ],1786                "model": "openai/gpt-4o-mini",1787                "id": "gen-cost-astream",1788                "usage": {1789                    "prompt_tokens": 10,1790                    "completion_tokens": 5,1791                    "total_tokens": 15,1792                    "cost": 7.5e-05,1793                    "cost_details": cost_details,1794                },1795            },1796        ]1797        model.client.chat.send_async = AsyncMock(1798            return_value=_MockAsyncStream(stream_chunks)1799        )18001801        chunks = [c async for c in model.astream("Hello")]1802        final = [1803            c for c in chunks if c.response_metadata.get("finish_reason") == "stop"1804        ]1805        assert len(final) == 11806        meta = final[0].response_metadata1807        assert meta["cost"] == 7.5e-051808        assert meta["cost_details"] == cost_details1809        assert meta["finish_reason"] == "stop"18101811    def test_missing_optional_metadata_excluded(self) -> None:1812        """Test that absent optional fields are not added to response_metadata."""1813        model = _make_model()1814        response: dict[str, Any] = {1815            "choices": [1816                {1817                    "message": {"role": "assistant", "content": "Hello!"},1818                    "finish_reason": "stop",1819                }1820            ],1821        }1822        result = model._create_chat_result(response)1823        msg = result.generations[0].message1824        assert isinstance(msg, AIMessage)1825        assert "system_fingerprint" not in msg.response_metadata1826        assert "native_finish_reason" not in msg.response_metadata1827        assert "model" not in msg.response_metadata1828        assert result.llm_output is not None1829        assert "id" not in result.llm_output1830        assert "created" not in result.llm_output1831        assert "object" not in result.llm_output18321833    def test_id_created_object_in_llm_output(self) -> None:1834        """Test that id, created, and object are included in llm_output."""1835        model = _make_model()1836        result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)1837        assert result.llm_output is not None1838        assert result.llm_output["id"] == "gen-abc123"1839        assert result.llm_output["created"] == 17000000001840        assert result.llm_output["object"] == "chat.completion"18411842    def test_float_token_usage_normalized_to_int_in_usage_metadata(self) -> None:1843        """Test that float token counts are cast to int in usage_metadata."""1844        model = _make_model()1845        response: dict[str, Any] = {1846            "choices": [1847                {1848                    "message": {"role": "assistant", "content": "Hello!"},1849                    "finish_reason": "stop",1850                }1851            ],1852            "usage": {1853                "prompt_tokens": 585.0,1854                "completion_tokens": 56.0,1855                "total_tokens": 641.0,1856                "completion_tokens_details": {"reasoning_tokens": 10.0},1857                "prompt_tokens_details": {"cached_tokens": 20.0},1858            },1859            "model": MODEL_NAME,1860        }1861        result = model._create_chat_result(response)1862        msg = result.generations[0].message1863        assert isinstance(msg, AIMessage)1864        usage = msg.usage_metadata1865        assert usage is not None1866        assert usage["input_tokens"] == 5851867        assert isinstance(usage["input_tokens"], int)1868        assert usage["output_tokens"] == 561869        assert isinstance(usage["output_tokens"], int)1870        assert usage["total_tokens"] == 6411871        assert isinstance(usage["total_tokens"], int)1872        assert usage["input_token_details"]["cache_read"] == 201873        assert isinstance(usage["input_token_details"]["cache_read"], int)1874        assert usage["output_token_details"]["reasoning"] == 101875        assert isinstance(usage["output_token_details"]["reasoning"], int)187618771878class TestCreateUsageMetadataZeroTotal:1879    """Test that explicit total_tokens=0 is preserved, not replaced by sum."""18801881    def test_zero_total_tokens_preserved(self) -> None:1882        token_usage = {1883            "prompt_tokens": 10,1884            "completion_tokens": 5,1885            "total_tokens": 0,1886        }1887        result = _create_usage_metadata(token_usage)1888        assert result["total_tokens"] == 018891890    def test_zero_input_tokens_preferred_key(self) -> None:1891        """prompt_tokens=0 must not fall through to input_tokens."""1892        token_usage = {1893            "prompt_tokens": 0,1894            "input_tokens": 50,1895            "completion_tokens": 5,1896            "total_tokens": 55,1897        }1898        result = _create_usage_metadata(token_usage)1899        assert result["input_tokens"] == 019001901    def test_zero_output_tokens_preferred_key(self) -> None:1902        """completion_tokens=0 must not fall through to output_tokens."""1903        token_usage = {1904            "prompt_tokens": 10,1905            "completion_tokens": 0,1906            "output_tokens": 50,1907            "total_tokens": 60,1908        }1909        result = _create_usage_metadata(token_usage)1910        assert result["output_tokens"] == 0191119121913# ===========================================================================1914# Streaming chunk tests1915# ===========================================================================191619171918class TestStreamingChunks:1919    """Tests for streaming chunk conversion."""19201921    def test_reasoning_in_streaming_chunk(self) -> None:1922        """Test that reasoning is extracted from streaming delta."""1923        chunk: dict[str, Any] = {1924            "choices": [1925                {1926                    "delta": {1927                        "content": "Main content",1928                        "reasoning": "Streaming reasoning",1929                    },1930                },1931            ],1932        }1933        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1934        assert isinstance(message_chunk, AIMessageChunk)1935        assert (1936            message_chunk.additional_kwargs.get("reasoning_content")1937            == "Streaming reasoning"1938        )19391940    def test_model_provider_in_streaming_chunk(self) -> None:1941        """Test that model_provider is set in streaming chunk metadata."""1942        chunk: dict[str, Any] = {1943            "choices": [1944                {1945                    "delta": {"content": "Hello"},1946                },1947            ],1948        }1949        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1950        assert isinstance(message_chunk, AIMessageChunk)1951        assert message_chunk.response_metadata.get("model_provider") == "openrouter"19521953    def test_chunk_without_reasoning(self) -> None:1954        """Test that chunk without reasoning fields works correctly."""1955        chunk: dict[str, Any] = {"choices": [{"delta": {"content": "Hello"}}]}1956        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1957        assert isinstance(message_chunk, AIMessageChunk)1958        assert message_chunk.additional_kwargs.get("reasoning_content") is None19591960    def test_chunk_with_empty_delta(self) -> None:1961        """Test that chunk with empty delta works correctly."""1962        chunk: dict[str, Any] = {"choices": [{"delta": {}}]}1963        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1964        assert isinstance(message_chunk, AIMessageChunk)1965        assert message_chunk.additional_kwargs.get("reasoning_content") is None19661967    def test_chunk_with_tool_calls(self) -> None:1968        """Test that tool calls are extracted from streaming delta."""1969        chunk: dict[str, Any] = {1970            "choices": [1971                {1972                    "delta": {1973                        "tool_calls": [1974                            {1975                                "index": 0,1976                                "id": "call_1",1977                                "type": "function",1978                                "function": {1979                                    "name": "get_weather",1980                                    "arguments": '{"loc',1981                                },1982                            }1983                        ],1984                    },1985                },1986            ],1987        }1988        message_chunk = _convert_chunk_to_message_chunk(chunk, AIMessageChunk)1989        assert isinstance(message_chunk, AIMessageChunk)1990        assert len(message_chunk.tool_call_chunks) == 11991        assert message_chunk.tool_call_chunks[0]["name"] == "get_weather"1992        assert message_chunk.tool_call_chunks[0]["args"] == '{"loc'1993        assert message_chunk.tool_call_chunks[0]["id"] == "call_1"1994        assert message_chunk.tool_call_chunks[0]["index"] == 019951996    def test_chunk_with_malformed_tool_call_skips_bad_keeps_good(self) -> None:1997        """Test that a malformed tool call chunk is skipped; valid ones kept."""1998        chunk: dict[str, Any] = {1999            "choices": [2000                {

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.