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