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