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