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.