Overuse may indicate design issues; consider polymorphism
if isinstance(actual, str) or isinstance(expected, str):
1"""Test base chat model."""23import uuid4import warnings5from collections.abc import AsyncIterator, Iterator6from contextlib import contextmanager7from importlib.metadata import PackageNotFoundError8from typing import TYPE_CHECKING, Any, Literal, get_type_hints9from unittest.mock import patch1011import pytest12from langsmith.env import get_langchain_env_var_metadata13from pydantic import model_validator14from typing_extensions import Self, override1516from langchain_core._api import LangChainDeprecationWarning17from langchain_core.callbacks import (18 AsyncCallbackManagerForLLMRun,19 BaseCallbackHandler,20 CallbackManagerForLLMRun,21)22from langchain_core.language_models import (23 BaseChatModel,24 FakeListChatModel,25 ParrotFakeChatModel,26)27from langchain_core.language_models._utils import (28 _filter_invocation_params_for_tracing,29 _normalize_messages,30)31from langchain_core.language_models.base import _get_langchain_version32from langchain_core.language_models.chat_models import (33 SimpleChatModel,34 _generate_response_from_error,35)36from langchain_core.language_models.fake_chat_models import (37 FakeListChatModelError,38 GenericFakeChatModel,39)40from langchain_core.language_models.model_profile import ModelProfile41from langchain_core.messages import (42 AIMessage,43 AIMessageChunk,44 BaseMessage,45 HumanMessage,46 SystemMessage,47)48from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult49from langchain_core.outputs.llm_result import LLMResult50from langchain_core.tracers import LogStreamCallbackHandler51from langchain_core.tracers._streaming import _V2StreamingCallbackHandler52from langchain_core.tracers.base import BaseTracer53from langchain_core.tracers.context import collect_runs54from langchain_core.tracers.event_stream import _AstreamEventsCallbackHandler55from langchain_core.tracers.langchain import LangChainTracer56from langchain_core.tracers.schemas import Run57from langchain_core.version import VERSION58from tests.unit_tests.fake.callbacks import (59 BaseFakeCallbackHandler,60 FakeAsyncCallbackHandler,61 FakeCallbackHandler,62)63from tests.unit_tests.stubs import _any_id_ai_message, _any_id_ai_message_chunk6465if TYPE_CHECKING:66 from langchain_core.outputs.llm_result import LLMResult67 from langchain_core.runnables.config import RunnableConfig686970def _content_blocks_equal_ignore_id(71 actual: str | list[Any], expected: str | list[Any]72) -> bool:73 """Compare content blocks, ignoring auto-generated `id` fields.7475 Args:76 actual: Actual content from response (string or list of content blocks).77 expected: Expected content to compare against (string or list of blocks).7879 Returns:80 True if content matches (excluding `id` fields), `False` otherwise.8182 """83 if isinstance(actual, str) or isinstance(expected, str):84 return actual == expected8586 if len(actual) != len(expected):87 return False88 for actual_block, expected_block in zip(actual, expected, strict=False):89 actual_without_id = (90 {k: v for k, v in actual_block.items() if k != "id"}91 if isinstance(actual_block, dict) and "id" in actual_block92 else actual_block93 )9495 if actual_without_id != expected_block:96 return False9798 return True99100101def test_asdict_replaces_deprecated_dict() -> None:102 model = FakeListChatModel(responses=["foo"])103104 expected = {"responses": ["foo"], "_type": "fake-list-chat-model"}105 assert model.asdict() == expected106 with pytest.warns(LangChainDeprecationWarning, match="asdict"):107 assert model.dict() == expected108109110def test_base_chat_model_type_hints_resolve() -> None:111 assert get_type_hints(BaseChatModel.asdict)["return"] == dict[str, Any]112113114def test_invoke_preserves_deprecated_dict_override() -> None:115 """Invoking should preserve `dict()` overrides until `dict()` is removed."""116117 class CustomDictChatModel(FakeListChatModel):118 @override119 def dict(self, **kwargs: Any) -> dict[str, Any]:120 data = super().dict(**kwargs)121 data["custom_trace_param"] = "custom"122 return data123124 model = CustomDictChatModel(responses=["foo"])125 with warnings.catch_warnings():126 warnings.simplefilter("error", LangChainDeprecationWarning)127 with collect_runs() as cb:128 assert model.invoke("hello").content == "foo"129130 assert cb.traced_runs[0].extra is not None131 assert cb.traced_runs[0].extra["invocation_params"]["custom_trace_param"] == (132 "custom"133 )134135136@pytest.fixture137def messages() -> list[BaseMessage]:138 return [139 SystemMessage(content="You are a test user."),140 HumanMessage(content="Hello, I am a test user."),141 ]142143144@pytest.fixture145def messages_2() -> list[BaseMessage]:146 return [147 SystemMessage(content="You are a test user."),148 HumanMessage(content="Hello, I not a test user."),149 ]150151152def test_batch_size(messages: list[BaseMessage], messages_2: list[BaseMessage]) -> None:153 # The base endpoint doesn't support native batching,154 # so we expect batch_size to always be 1155 llm = FakeListChatModel(responses=[str(i) for i in range(100)])156 with collect_runs() as cb:157 llm.batch([messages, messages_2], {"callbacks": [cb]})158 assert len(cb.traced_runs) == 2159 assert all((r.extra or {}).get("batch_size") == 1 for r in cb.traced_runs)160 with collect_runs() as cb:161 llm.batch([messages], {"callbacks": [cb]})162 assert all((r.extra or {}).get("batch_size") == 1 for r in cb.traced_runs)163 assert len(cb.traced_runs) == 1164165 with collect_runs() as cb:166 llm.invoke(messages)167 assert len(cb.traced_runs) == 1168 assert (cb.traced_runs[0].extra or {}).get("batch_size") == 1169170 with collect_runs() as cb:171 list(llm.stream(messages))172 assert len(cb.traced_runs) == 1173 assert (cb.traced_runs[0].extra or {}).get("batch_size") == 1174175176async def test_async_batch_size(177 messages: list[BaseMessage], messages_2: list[BaseMessage]178) -> None:179 llm = FakeListChatModel(responses=[str(i) for i in range(100)])180 # The base endpoint doesn't support native batching,181 # so we expect batch_size to always be 1182 with collect_runs() as cb:183 await llm.abatch([messages, messages_2], {"callbacks": [cb]})184 assert all((r.extra or {}).get("batch_size") == 1 for r in cb.traced_runs)185 assert len(cb.traced_runs) == 2186 with collect_runs() as cb:187 await llm.abatch([messages], {"callbacks": [cb]})188 assert all((r.extra or {}).get("batch_size") == 1 for r in cb.traced_runs)189 assert len(cb.traced_runs) == 1190191 with collect_runs() as cb:192 await llm.ainvoke(messages)193 assert len(cb.traced_runs) == 1194 assert (cb.traced_runs[0].extra or {}).get("batch_size") == 1195196 with collect_runs() as cb:197 async for _ in llm.astream(messages):198 pass199 assert len(cb.traced_runs) == 1200 assert (cb.traced_runs[0].extra or {}).get("batch_size") == 1201202203@pytest.mark.xfail(reason="This test is failing due to a bug in the testing code")204async def test_stream_error_callback() -> None:205 message = "test"206207 def eval_response(callback: BaseFakeCallbackHandler, i: int) -> None:208 assert callback.errors == 1209 assert len(callback.errors_args) == 1210 llm_result: LLMResult = callback.errors_args[0]["kwargs"]["response"]211 if i == 0:212 assert llm_result.generations == []213 else:214 assert llm_result.generations[0][0].text == message[:i]215216 for i in range(len(message)):217 llm = FakeListChatModel(218 responses=[message],219 error_on_chunk_number=i,220 )221 cb_async = FakeAsyncCallbackHandler()222 llm_astream = llm.astream("Dummy message", config={"callbacks": [cb_async]})223 for _ in range(i):224 await anext(llm_astream)225 with pytest.raises(FakeListChatModelError):226 await anext(llm_astream)227 eval_response(cb_async, i)228229 cb_sync = FakeCallbackHandler()230 llm_stream = llm.stream("Dumy message", config={"callbacks": [cb_sync]})231 for _ in range(i):232 next(llm_stream)233 with pytest.raises(FakeListChatModelError):234 next(llm_stream)235 eval_response(cb_sync, i)236237238async def test_astream_fallback_to_ainvoke() -> None:239 """Test `astream()` uses appropriate implementation."""240241 class ModelWithGenerate(BaseChatModel):242 @override243 def _generate(244 self,245 messages: list[BaseMessage],246 stop: list[str] | None = None,247 run_manager: CallbackManagerForLLMRun | None = None,248 **kwargs: Any,249 ) -> ChatResult:250 """Top Level call."""251 message = AIMessage(content="hello")252 generation = ChatGeneration(message=message)253 return ChatResult(generations=[generation])254255 @property256 def _llm_type(self) -> str:257 return "fake-chat-model"258259 model = ModelWithGenerate()260 chunks = list(model.stream("anything"))261 # BaseChatModel.stream is typed to return Iterator[BaseMessageChunk].262 # When streaming is disabled, it returns Iterator[BaseMessage], so the type hint263 # is not strictly correct.264 # LangChain documents a pattern of adding BaseMessageChunks to accumulate a stream.265 # This may be better done with `reduce(operator.add, chunks)`.266 assert chunks == [_any_id_ai_message(content="hello")]267268 chunks = [chunk async for chunk in model.astream("anything")]269 assert chunks == [_any_id_ai_message(content="hello")]270271272async def test_astream_implementation_fallback_to_stream() -> None:273 """Test astream uses appropriate implementation."""274275 class ModelWithSyncStream(BaseChatModel):276 def _generate(277 self,278 messages: list[BaseMessage],279 stop: list[str] | None = None,280 run_manager: CallbackManagerForLLMRun | None = None,281 **kwargs: Any,282 ) -> ChatResult:283 """Top Level call."""284 raise NotImplementedError285286 @override287 def _stream(288 self,289 messages: list[BaseMessage],290 stop: list[str] | None = None,291 run_manager: CallbackManagerForLLMRun | None = None,292 **kwargs: Any,293 ) -> Iterator[ChatGenerationChunk]:294 """Stream the output of the model."""295 yield ChatGenerationChunk(message=AIMessageChunk(content="a"))296 yield ChatGenerationChunk(297 message=AIMessageChunk(content="b", chunk_position="last")298 )299300 @property301 def _llm_type(self) -> str:302 return "fake-chat-model"303304 model = ModelWithSyncStream()305 chunks = list(model.stream("anything"))306 assert chunks == [307 _any_id_ai_message_chunk(308 content="a",309 ),310 _any_id_ai_message_chunk(content="b", chunk_position="last"),311 ]312 assert len({chunk.id for chunk in chunks}) == 1313 assert type(model)._astream == BaseChatModel._astream314 astream_chunks = [chunk async for chunk in model.astream("anything")]315 assert astream_chunks == [316 _any_id_ai_message_chunk(317 content="a",318 ),319 _any_id_ai_message_chunk(content="b", chunk_position="last"),320 ]321 assert len({chunk.id for chunk in astream_chunks}) == 1322323324async def test_astream_implementation_uses_astream() -> None:325 """Test astream uses appropriate implementation."""326327 class ModelWithAsyncStream(BaseChatModel):328 def _generate(329 self,330 messages: list[BaseMessage],331 stop: list[str] | None = None,332 run_manager: CallbackManagerForLLMRun | None = None,333 **kwargs: Any,334 ) -> ChatResult:335 """Top Level call."""336 raise NotImplementedError337338 @override339 async def _astream(340 self,341 messages: list[BaseMessage],342 stop: list[str] | None = None,343 run_manager: CallbackManagerForLLMRun | None = None, # type: ignore[override]344 **kwargs: Any,345 ) -> AsyncIterator[ChatGenerationChunk]:346 """Stream the output of the model."""347 yield ChatGenerationChunk(message=AIMessageChunk(content="a"))348 yield ChatGenerationChunk(349 message=AIMessageChunk(content="b", chunk_position="last")350 )351352 @property353 def _llm_type(self) -> str:354 return "fake-chat-model"355356 model = ModelWithAsyncStream()357 chunks = [chunk async for chunk in model.astream("anything")]358 assert chunks == [359 _any_id_ai_message_chunk(360 content="a",361 ),362 _any_id_ai_message_chunk(content="b", chunk_position="last"),363 ]364 assert len({chunk.id for chunk in chunks}) == 1365366367class FakeTracer(BaseTracer):368 def __init__(self) -> None:369 super().__init__()370 self.traced_run_ids: list[uuid.UUID] = []371372 def _persist_run(self, run: Run) -> None:373 """Persist a run."""374 self.traced_run_ids.append(run.id)375376377class LangChainTracerRunCollector:378 def __init__(self) -> None:379 self.tracer = LangChainTracer()380 self.runs: list[Run] = []381382 @contextmanager383 def tracing_callback(self) -> Iterator[LangChainTracer]:384 def collect_tracer_run(_: LangChainTracer, run: Run) -> None:385 self.runs.append(run)386387 with patch.object(LangChainTracer, "_persist_run", new=collect_tracer_run):388 yield self.tracer389390391def test_pass_run_id() -> None:392 llm = FakeListChatModel(responses=["a", "b", "c"])393 cb = FakeTracer()394 uid1 = uuid.uuid4()395 llm.invoke("Dummy message", {"callbacks": [cb], "run_id": uid1})396 assert cb.traced_run_ids == [uid1]397 uid2 = uuid.uuid4()398 list(llm.stream("Dummy message", {"callbacks": [cb], "run_id": uid2}))399 assert cb.traced_run_ids == [uid1, uid2]400 uid3 = uuid.uuid4()401 llm.batch([["Dummy message"]], {"callbacks": [cb], "run_id": uid3})402 assert cb.traced_run_ids == [uid1, uid2, uid3]403404405async def test_async_pass_run_id() -> None:406 llm = FakeListChatModel(responses=["a", "b", "c"])407 cb = FakeTracer()408 uid1 = uuid.uuid4()409 await llm.ainvoke("Dummy message", {"callbacks": [cb], "run_id": uid1})410 assert cb.traced_run_ids == [uid1]411 uid2 = uuid.uuid4()412 async for _ in llm.astream("Dummy message", {"callbacks": [cb], "run_id": uid2}):413 pass414 assert cb.traced_run_ids == [uid1, uid2]415416 uid3 = uuid.uuid4()417 await llm.abatch([["Dummy message"]], {"callbacks": [cb], "run_id": uid3})418 assert cb.traced_run_ids == [uid1, uid2, uid3]419420421class NoStreamingModel(BaseChatModel):422 @override423 def _generate(424 self,425 messages: list[BaseMessage],426 stop: list[str] | None = None,427 run_manager: CallbackManagerForLLMRun | None = None,428 **kwargs: Any,429 ) -> ChatResult:430 return ChatResult(generations=[ChatGeneration(message=AIMessage("invoke"))])431432 @property433 def _llm_type(self) -> str:434 return "model1"435436437class StreamingModel(NoStreamingModel):438 streaming: bool = False439440 @override441 def _stream(442 self,443 messages: list[BaseMessage],444 stop: list[str] | None = None,445 run_manager: CallbackManagerForLLMRun | None = None,446 **kwargs: Any,447 ) -> Iterator[ChatGenerationChunk]:448 yield ChatGenerationChunk(message=AIMessageChunk(content="stream"))449450451@pytest.mark.parametrize("disable_streaming", [True, False, "tool_calling"])452def test_disable_streaming(453 *,454 disable_streaming: bool | Literal["tool_calling"],455) -> None:456 model = StreamingModel(disable_streaming=disable_streaming)457 assert model.invoke([]).content == "invoke"458459 expected = "invoke" if disable_streaming is True else "stream"460 assert next(model.stream([])).content == expected461 assert (462 model.invoke([], config={"callbacks": [LogStreamCallbackHandler()]}).content463 == expected464 )465466 expected = "invoke" if disable_streaming in {"tool_calling", True} else "stream"467 assert next(model.stream([], tools=[{"type": "function"}])).content == expected468 assert (469 model.invoke(470 [], config={"callbacks": [LogStreamCallbackHandler()]}, tools=[{}]471 ).content472 == expected473 )474475476@pytest.mark.parametrize("disable_streaming", [True, False, "tool_calling"])477async def test_disable_streaming_async(478 *,479 disable_streaming: bool | Literal["tool_calling"],480) -> None:481 model = StreamingModel(disable_streaming=disable_streaming)482 assert (await model.ainvoke([])).content == "invoke"483484 expected = "invoke" if disable_streaming is True else "stream"485 async for c in model.astream([]):486 assert c.content == expected487 break488 assert (489 await model.ainvoke([], config={"callbacks": [_AstreamEventsCallbackHandler()]})490 ).content == expected491492 expected = "invoke" if disable_streaming in {"tool_calling", True} else "stream"493 async for c in model.astream([], tools=[{}]):494 assert c.content == expected495 break496 assert (497 await model.ainvoke(498 [], config={"callbacks": [_AstreamEventsCallbackHandler()]}, tools=[{}]499 )500 ).content == expected501502503async def test_streaming_attribute_overrides_streaming_callback() -> None:504 model = StreamingModel(streaming=False)505 assert (506 await model.ainvoke([], config={"callbacks": [_AstreamEventsCallbackHandler()]})507 ).content == "invoke"508509510class _FakeV2Handler(BaseCallbackHandler, _V2StreamingCallbackHandler):511 """Minimal v2 handler marker for routing tests; records nothing."""512513514async def test_streaming_attribute_overrides_v2_callback() -> None:515 """`self.streaming=False` must opt out of the v2 event path too.516517 `_should_use_protocol_streaming` shares the `_streaming_disabled` opt-outs with518 `_should_stream`, so an instance-level `streaming=False` takes519 precedence over an attached `_V2StreamingCallbackHandler`.520 """521 model = StreamingModel(streaming=False)522 assert (523 await model.ainvoke([], config={"callbacks": [_FakeV2Handler()]})524 ).content == "invoke"525 assert (526 model.invoke([], config={"callbacks": [_FakeV2Handler()]})527 ).content == "invoke"528529530@pytest.mark.parametrize("disable_streaming", [True, False, "tool_calling"])531def test_disable_streaming_no_streaming_model(532 *,533 disable_streaming: bool | Literal["tool_calling"],534) -> None:535 model = NoStreamingModel(disable_streaming=disable_streaming)536 assert model.invoke([]).content == "invoke"537 assert next(model.stream([])).content == "invoke"538 assert (539 model.invoke([], config={"callbacks": [LogStreamCallbackHandler()]}).content540 == "invoke"541 )542 assert next(model.stream([], tools=[{}])).content == "invoke"543544545@pytest.mark.parametrize("disable_streaming", [True, False, "tool_calling"])546async def test_disable_streaming_no_streaming_model_async(547 *,548 disable_streaming: bool | Literal["tool_calling"],549) -> None:550 model = NoStreamingModel(disable_streaming=disable_streaming)551 assert (await model.ainvoke([])).content == "invoke"552 async for c in model.astream([]):553 assert c.content == "invoke"554 break555 assert (556 await model.ainvoke([], config={"callbacks": [_AstreamEventsCallbackHandler()]})557 ).content == "invoke"558 async for c in model.astream([], tools=[{}]):559 assert c.content == "invoke"560 break561562563class FakeChatModelStartTracer(FakeTracer):564 def __init__(self) -> None:565 super().__init__()566 self.messages: list[list[list[BaseMessage]]] = []567568 def on_chat_model_start(self, *args: Any, **kwargs: Any) -> Run:569 _, messages = args570 self.messages.append(messages)571 return super().on_chat_model_start(572 *args,573 **kwargs,574 )575576577def test_trace_images_in_openai_format() -> None:578 """Test that images are traced in OpenAI Chat Completions format."""579 llm = ParrotFakeChatModel()580 messages = [581 {582 "role": "user",583 # v0 format584 "content": [585 {586 "type": "image",587 "source_type": "url",588 "url": "https://example.com/image.png",589 }590 ],591 }592 ]593 tracer = FakeChatModelStartTracer()594 llm.invoke(messages, config={"callbacks": [tracer]})595 assert tracer.messages == [596 [597 [598 HumanMessage(599 content=[600 {601 "type": "image_url",602 "image_url": {"url": "https://example.com/image.png"},603 }604 ]605 )606 ]607 ]608 ]609610611def test_trace_pdfs() -> None:612 # For backward compat613 llm = ParrotFakeChatModel()614 messages = [615 {616 "role": "user",617 "content": [618 {619 "type": "file",620 "mime_type": "application/pdf",621 "base64": "<base64 string>",622 }623 ],624 }625 ]626 tracer = FakeChatModelStartTracer()627628 with warnings.catch_warnings():629 warnings.simplefilter("error")630 llm.invoke(messages, config={"callbacks": [tracer]})631632 assert tracer.messages == [633 [634 [635 HumanMessage(636 content=[637 {638 "type": "file",639 "mime_type": "application/pdf",640 "source_type": "base64",641 "data": "<base64 string>",642 }643 ]644 )645 ]646 ]647 ]648649650def test_content_block_transformation_v0_to_v1_image() -> None:651 """Test that v0 format image content blocks are transformed to v1 format."""652 # Create a message with v0 format image content653 image_message = AIMessage(654 content=[655 {656 "type": "image",657 "source_type": "url",658 "url": "https://example.com/image.png",659 }660 ]661 )662663 llm = GenericFakeChatModel(messages=iter([image_message]), output_version="v1")664 response = llm.invoke("test")665666 # With v1 output_version, .content should be transformed667 # Check structure, ignoring auto-generated IDs668 assert len(response.content) == 1669 content_block = response.content[0]670 if isinstance(content_block, dict) and "id" in content_block:671 # Remove auto-generated id for comparison672 content_without_id = {k: v for k, v in content_block.items() if k != "id"}673 expected_content = {674 "type": "image",675 "url": "https://example.com/image.png",676 }677 assert content_without_id == expected_content678 else:679 assert content_block == {680 "type": "image",681 "url": "https://example.com/image.png",682 }683684685@pytest.mark.parametrize("output_version", ["v0", "v1"])686def test_trace_content_blocks_with_no_type_key(output_version: str) -> None:687 """Test behavior of content blocks that don't have a `type` key.688689 Only for blocks with one key, in which case, the name of the key is used as `type`.690691 """692 llm = ParrotFakeChatModel(output_version=output_version)693 messages = [694 {695 "role": "user",696 "content": [697 {698 "type": "text",699 "text": "Hello",700 },701 {702 "cachePoint": {"type": "default"},703 },704 ],705 }706 ]707 tracer = FakeChatModelStartTracer()708 response = llm.invoke(messages, config={"callbacks": [tracer]})709 assert tracer.messages == [710 [711 [712 HumanMessage(713 [714 {715 "type": "text",716 "text": "Hello",717 },718 {719 "type": "cachePoint",720 "cachePoint": {"type": "default"},721 },722 ]723 )724 ]725 ]726 ]727728 if output_version == "v0":729 assert response.content == [730 {731 "type": "text",732 "text": "Hello",733 },734 {735 "cachePoint": {"type": "default"},736 },737 ]738 else:739 assert response.content == [740 {741 "type": "text",742 "text": "Hello",743 },744 {745 "type": "non_standard",746 "value": {747 "cachePoint": {"type": "default"},748 },749 },750 ]751752 assert response.content_blocks == [753 {754 "type": "text",755 "text": "Hello",756 },757 {758 "type": "non_standard",759 "value": {760 "cachePoint": {"type": "default"},761 },762 },763 ]764765766def test_extend_support_to_openai_multimodal_formats() -> None:767 """Test normalizing OpenAI audio, image, and file inputs to v1."""768 # Audio and file only (chat model default)769 messages = HumanMessage(770 content=[771 {"type": "text", "text": "Hello"},772 { # audio-base64773 "type": "input_audio",774 "input_audio": {775 "format": "wav",776 "data": "<base64 string>",777 },778 },779 { # file-base64780 "type": "file",781 "file": {782 "filename": "draconomicon.pdf",783 "file_data": "data:application/pdf;base64,<base64 string>",784 },785 },786 { # file-id787 "type": "file",788 "file": {"file_id": "<file id>"},789 },790 ]791 )792793 expected_content_messages = HumanMessage(794 content=[795 {"type": "text", "text": "Hello"}, # TextContentBlock796 { # AudioContentBlock797 "type": "audio",798 "base64": "<base64 string>",799 "mime_type": "audio/wav",800 },801 { # FileContentBlock802 "type": "file",803 "base64": "<base64 string>",804 "mime_type": "application/pdf",805 "extras": {"filename": "draconomicon.pdf"},806 },807 { # ...808 "type": "file",809 "file_id": "<file id>",810 },811 ]812 )813814 normalized_content = _normalize_messages([messages])815816 # Check structure, ignoring auto-generated IDs817 assert len(normalized_content) == 1818 normalized_message = normalized_content[0]819 assert len(normalized_message.content) == len(expected_content_messages.content)820821 assert _content_blocks_equal_ignore_id(822 normalized_message.content, expected_content_messages.content823 )824825 messages = HumanMessage(826 content=[827 {"type": "text", "text": "Hello"},828 { # image-url829 "type": "image_url",830 "image_url": {"url": "https://example.com/image.png"},831 },832 { # image-base64833 "type": "image_url",834 "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."},835 },836 { # audio-base64837 "type": "input_audio",838 "input_audio": {839 "format": "wav",840 "data": "<base64 string>",841 },842 },843 { # file-base64844 "type": "file",845 "file": {846 "filename": "draconomicon.pdf",847 "file_data": "data:application/pdf;base64,<base64 string>",848 },849 },850 { # file-id851 "type": "file",852 "file": {"file_id": "<file id>"},853 },854 ]855 )856857 expected_content_messages = HumanMessage(858 content=[859 {"type": "text", "text": "Hello"}, # TextContentBlock860 { # image-url passes through861 "type": "image_url",862 "image_url": {"url": "https://example.com/image.png"},863 },864 { # image-url passes through with inline data865 "type": "image_url",866 "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."},867 },868 { # AudioContentBlock869 "type": "audio",870 "base64": "<base64 string>",871 "mime_type": "audio/wav",872 },873 { # FileContentBlock874 "type": "file",875 "base64": "<base64 string>",876 "mime_type": "application/pdf",877 "extras": {"filename": "draconomicon.pdf"},878 },879 { # ...880 "type": "file",881 "file_id": "<file id>",882 },883 ]884 )885886 normalized_content = _normalize_messages([messages])887888 # Check structure, ignoring auto-generated IDs889 assert len(normalized_content) == 1890 normalized_message = normalized_content[0]891 assert len(normalized_message.content) == len(expected_content_messages.content)892893 assert _content_blocks_equal_ignore_id(894 normalized_message.content, expected_content_messages.content895 )896897898def test_normalize_messages_edge_cases() -> None:899 # Test behavior of malformed/unrecognized content blocks900901 messages = [902 HumanMessage(903 content=[904 {905 "type": "input_image", # Responses API type; not handled906 "image_url": "uri",907 },908 {909 # Standard OpenAI Chat Completions type but malformed structure910 "type": "input_audio",911 "input_audio": "uri", # Should be nested in `audio`912 },913 {914 "type": "file",915 "file": "uri", # `file` should be a dict for Chat Completions916 },917 {918 "type": "input_file", # Responses API type; not handled919 "file_data": "uri",920 "filename": "file-name",921 },922 ]923 )924 ]925926 assert messages == _normalize_messages(messages)927928929def test_normalize_messages_v1_content_blocks_unchanged() -> None:930 """Test passing v1 content blocks to `_normalize_messages()` leaves unchanged."""931 input_messages = [932 HumanMessage(933 content=[934 {935 "type": "text",936 "text": "Hello world",937 },938 {939 "type": "image",940 "url": "https://example.com/image.png",941 "mime_type": "image/png",942 },943 {944 "type": "audio",945 "base64": "base64encodedaudiodata",946 "mime_type": "audio/wav",947 },948 {949 "type": "file",950 "id": "file_123",951 },952 {953 "type": "reasoning",954 "reasoning": "Let me think about this...",955 },956 ]957 )958 ]959960 result = _normalize_messages(input_messages)961962 # Verify the result is identical to the input (message should not be copied)963 assert len(result) == 1964 assert result[0] is input_messages[0]965 assert result[0].content == input_messages[0].content966967968def test_output_version_invoke(monkeypatch: Any) -> None:969 messages = [AIMessage("hello")]970971 llm = GenericFakeChatModel(messages=iter(messages), output_version="v1")972 response = llm.invoke("hello")973 assert response.content == [{"type": "text", "text": "hello"}]974 assert response.response_metadata["output_version"] == "v1"975976 llm = GenericFakeChatModel(messages=iter(messages))977 response = llm.invoke("hello")978 assert response.content == "hello"979980 monkeypatch.setenv("LC_OUTPUT_VERSION", "v1")981 llm = GenericFakeChatModel(messages=iter(messages))982 response = llm.invoke("hello")983 assert response.content == [{"type": "text", "text": "hello"}]984 assert response.response_metadata["output_version"] == "v1"985986987# -- v1 output version tests --988989990async def test_output_version_ainvoke(monkeypatch: Any) -> None:991 messages = [AIMessage("hello")]992993 # v0994 llm = GenericFakeChatModel(messages=iter(messages))995 response = await llm.ainvoke("hello")996 assert response.content == "hello"997998 # v1999 llm = GenericFakeChatModel(messages=iter(messages), output_version="v1")1000 response = await llm.ainvoke("hello")1001 assert response.content == [{"type": "text", "text": "hello"}]1002 assert response.response_metadata["output_version"] == "v1"10031004 # v1 from env var1005 monkeypatch.setenv("LC_OUTPUT_VERSION", "v1")1006 llm = GenericFakeChatModel(messages=iter(messages))1007 response = await llm.ainvoke("hello")1008 assert response.content == [{"type": "text", "text": "hello"}]1009 assert response.response_metadata["output_version"] == "v1"101010111012class _AnotherFakeChatModel(BaseChatModel):1013 responses: Iterator[AIMessage]1014 """Responses for _generate."""10151016 chunks: Iterator[AIMessageChunk]1017 """Responses for _stream."""10181019 @property1020 def _llm_type(self) -> str:1021 return "another-fake-chat-model"10221023 def _generate(1024 self,1025 *_args: Any,1026 **_kwargs: Any,1027 ) -> ChatResult:1028 return ChatResult(generations=[ChatGeneration(message=next(self.responses))])10291030 async def _agenerate(1031 self,1032 *_args: Any,1033 **_kwargs: Any,1034 ) -> ChatResult:1035 return ChatResult(generations=[ChatGeneration(message=next(self.responses))])10361037 def _stream(1038 self,1039 *_args: Any,1040 **_kwargs: Any,1041 ) -> Iterator[ChatGenerationChunk]:1042 for chunk in self.chunks:1043 yield ChatGenerationChunk(message=chunk)10441045 async def _astream(1046 self,1047 *_args: Any,1048 **_kwargs: Any,1049 ) -> AsyncIterator[ChatGenerationChunk]:1050 for chunk in self.chunks:1051 yield ChatGenerationChunk(message=chunk)105210531054def test_output_version_stream(monkeypatch: Any) -> None:1055 messages = [AIMessage("foo bar")]10561057 # v01058 llm = GenericFakeChatModel(messages=iter(messages))1059 full = None1060 for chunk in llm.stream("hello"):1061 assert isinstance(chunk, AIMessageChunk)1062 assert isinstance(chunk.content, str)1063 assert chunk.content1064 full = chunk if full is None else full + chunk1065 assert isinstance(full, AIMessageChunk)1066 assert full.content == "foo bar"10671068 # v11069 llm = GenericFakeChatModel(messages=iter(messages), output_version="v1")1070 full_v1: AIMessageChunk | None = None1071 for chunk in llm.stream("hello"):1072 assert isinstance(chunk, AIMessageChunk)1073 assert isinstance(chunk.content, list)1074 assert len(chunk.content) == 11075 block = chunk.content[0]1076 assert isinstance(block, dict)1077 assert block["type"] == "text"1078 assert block["text"]1079 full_v1 = chunk if full_v1 is None else full_v1 + chunk1080 assert isinstance(full_v1, AIMessageChunk)1081 assert full_v1.response_metadata["output_version"] == "v1"10821083 assert full_v1.content == [{"type": "text", "text": "foo bar", "index": 0}]10841085 # Test text blocks1086 llm_with_rich_content = _AnotherFakeChatModel(1087 responses=iter([]),1088 chunks=iter(1089 [1090 AIMessageChunk(content="foo "),1091 AIMessageChunk(content="bar"),1092 ]1093 ),1094 output_version="v1",1095 )1096 full_v1 = None1097 for chunk in llm_with_rich_content.stream("hello"):1098 full_v1 = chunk if full_v1 is None else full_v1 + chunk1099 assert isinstance(full_v1, AIMessageChunk)1100 assert full_v1.content_blocks == [{"type": "text", "text": "foo bar", "index": 0}]11011102 # Test content blocks of different types1103 chunks = [1104 AIMessageChunk(content="", additional_kwargs={"reasoning_content": "<rea"}),1105 AIMessageChunk(content="", additional_kwargs={"reasoning_content": "soning>"}),1106 AIMessageChunk(content="<some "),1107 AIMessageChunk(content="text>"),1108 ]1109 llm_with_rich_content = _AnotherFakeChatModel(1110 responses=iter([]),1111 chunks=iter(chunks),1112 output_version="v1",1113 )1114 full_v1 = None1115 for chunk in llm_with_rich_content.stream("hello"):1116 full_v1 = chunk if full_v1 is None else full_v1 + chunk1117 assert isinstance(full_v1, AIMessageChunk)1118 assert full_v1.content_blocks == [1119 {"type": "reasoning", "reasoning": "<reasoning>", "index": 0},1120 {"type": "text", "text": "<some text>", "index": 1},1121 ]11221123 # Test invoke with stream=True1124 llm_with_rich_content = _AnotherFakeChatModel(1125 responses=iter([]),1126 chunks=iter(chunks),1127 output_version="v1",1128 )1129 response_v1 = llm_with_rich_content.invoke("hello", stream=True)1130 assert response_v1.content_blocks == [1131 {"type": "reasoning", "reasoning": "<reasoning>", "index": 0},1132 {"type": "text", "text": "<some text>", "index": 1},1133 ]11341135 # v1 from env var1136 monkeypatch.setenv("LC_OUTPUT_VERSION", "v1")1137 llm = GenericFakeChatModel(messages=iter(messages))1138 full_env = None1139 for chunk in llm.stream("hello"):1140 assert isinstance(chunk, AIMessageChunk)1141 assert isinstance(chunk.content, list)1142 assert len(chunk.content) == 11143 block = chunk.content[0]1144 assert isinstance(block, dict)1145 assert block["type"] == "text"1146 assert block["text"]1147 full_env = chunk if full_env is None else full_env + chunk1148 assert isinstance(full_env, AIMessageChunk)1149 assert full_env.response_metadata["output_version"] == "v1"115011511152async def test_output_version_astream(monkeypatch: Any) -> None:1153 messages = [AIMessage("foo bar")]11541155 # v01156 llm = GenericFakeChatModel(messages=iter(messages))1157 full = None1158 async for chunk in llm.astream("hello"):1159 assert isinstance(chunk, AIMessageChunk)1160 assert isinstance(chunk.content, str)1161 assert chunk.content1162 full = chunk if full is None else full + chunk1163 assert isinstance(full, AIMessageChunk)1164 assert full.content == "foo bar"11651166 # v11167 llm = GenericFakeChatModel(messages=iter(messages), output_version="v1")1168 full_v1: AIMessageChunk | None = None1169 async for chunk in llm.astream("hello"):1170 assert isinstance(chunk, AIMessageChunk)1171 assert isinstance(chunk.content, list)1172 assert len(chunk.content) == 11173 block = chunk.content[0]1174 assert isinstance(block, dict)1175 assert block["type"] == "text"1176 assert block["text"]1177 full_v1 = chunk if full_v1 is None else full_v1 + chunk1178 assert isinstance(full_v1, AIMessageChunk)1179 assert full_v1.response_metadata["output_version"] == "v1"11801181 assert full_v1.content == [{"type": "text", "text": "foo bar", "index": 0}]11821183 # Test text blocks1184 llm_with_rich_content = _AnotherFakeChatModel(1185 responses=iter([]),1186 chunks=iter(1187 [1188 AIMessageChunk(content="foo "),1189 AIMessageChunk(content="bar"),1190 ]1191 ),1192 output_version="v1",1193 )1194 full_v1 = None1195 async for chunk in llm_with_rich_content.astream("hello"):1196 full_v1 = chunk if full_v1 is None else full_v1 + chunk1197 assert isinstance(full_v1, AIMessageChunk)1198 assert full_v1.content_blocks == [{"type": "text", "text": "foo bar", "index": 0}]11991200 # Test content blocks of different types1201 chunks = [1202 AIMessageChunk(content="", additional_kwargs={"reasoning_content": "<rea"}),1203 AIMessageChunk(content="", additional_kwargs={"reasoning_content": "soning>"}),1204 AIMessageChunk(content="<some "),1205 AIMessageChunk(content="text>"),1206 ]1207 llm_with_rich_content = _AnotherFakeChatModel(1208 responses=iter([]),1209 chunks=iter(chunks),1210 output_version="v1",1211 )1212 full_v1 = None1213 async for chunk in llm_with_rich_content.astream("hello"):1214 full_v1 = chunk if full_v1 is None else full_v1 + chunk1215 assert isinstance(full_v1, AIMessageChunk)1216 assert full_v1.content_blocks == [1217 {"type": "reasoning", "reasoning": "<reasoning>", "index": 0},1218 {"type": "text", "text": "<some text>", "index": 1},1219 ]12201221 # Test invoke with stream=True1222 llm_with_rich_content = _AnotherFakeChatModel(1223 responses=iter([]),1224 chunks=iter(chunks),1225 output_version="v1",1226 )1227 response_v1 = await llm_with_rich_content.ainvoke("hello", stream=True)1228 assert response_v1.content_blocks == [1229 {"type": "reasoning", "reasoning": "<reasoning>", "index": 0},1230 {"type": "text", "text": "<some text>", "index": 1},1231 ]12321233 # v1 from env var1234 monkeypatch.setenv("LC_OUTPUT_VERSION", "v1")1235 llm = GenericFakeChatModel(messages=iter(messages))1236 full_env = None1237 async for chunk in llm.astream("hello"):1238 assert isinstance(chunk, AIMessageChunk)1239 assert isinstance(chunk.content, list)1240 assert len(chunk.content) == 11241 block = chunk.content[0]1242 assert isinstance(block, dict)1243 assert block["type"] == "text"1244 assert block["text"]1245 full_env = chunk if full_env is None else full_env + chunk1246 assert isinstance(full_env, AIMessageChunk)1247 assert full_env.response_metadata["output_version"] == "v1"1248 assert messages == _normalize_messages(messages)124912501251def test_get_ls_params() -> None:1252 class LSParamsModel(BaseChatModel):1253 model: str = "foo"1254 temperature: float = 0.11255 max_tokens: int = 102412561257 def _generate(1258 self,1259 messages: list[BaseMessage],1260 stop: list[str] | None = None,1261 run_manager: CallbackManagerForLLMRun | None = None,1262 **kwargs: Any,1263 ) -> ChatResult:1264 raise NotImplementedError12651266 @override1267 def _stream(1268 self,1269 messages: list[BaseMessage],1270 stop: list[str] | None = None,1271 run_manager: CallbackManagerForLLMRun | None = None,1272 **kwargs: Any,1273 ) -> Iterator[ChatGenerationChunk]:1274 raise NotImplementedError12751276 @property1277 def _llm_type(self) -> str:1278 return "fake-chat-model"12791280 llm = LSParamsModel()12811282 # Test standard tracing params1283 ls_params = llm._get_ls_params()1284 assert ls_params == {1285 "ls_provider": "lsparamsmodel",1286 "ls_model_type": "chat",1287 "ls_model_name": "foo",1288 "ls_temperature": 0.1,1289 "ls_max_tokens": 1024,1290 }12911292 ls_params = llm._get_ls_params(model="bar")1293 assert ls_params["ls_model_name"] == "bar"12941295 ls_params = llm._get_ls_params(temperature=0.2)1296 assert ls_params["ls_temperature"] == 0.212971298 # Test integer temperature values (regression test for issue #35300)1299 ls_params = llm._get_ls_params(temperature=0)1300 assert ls_params["ls_temperature"] == 013011302 ls_params = llm._get_ls_params(temperature=1)1303 assert ls_params["ls_temperature"] == 113041305 ls_params = llm._get_ls_params(max_tokens=2048)1306 assert ls_params["ls_max_tokens"] == 204813071308 ls_params = llm._get_ls_params(stop=["stop"])1309 assert ls_params["ls_stop"] == ["stop"]131013111312class _VersionedFakeModel(FakeListChatModel):1313 """Fake model that adds a version via `_add_version`."""13141315 def model_post_init(self, _context: Any, /) -> None:1316 super().model_post_init(_context)1317 self._add_version("langchain-fake", "0.1.0")131813191320def test_user_lc_versions_metadata_survives_merge() -> None:1321 """User-provided `lc_versions` metadata should merge with model versions."""1322 llm = _VersionedFakeModel(responses=["hello"])1323 user_config: RunnableConfig = {"metadata": {"lc_versions": {"my-app": "2.0"}}}13241325 with collect_runs() as cb:1326 llm.invoke([HumanMessage(content="hi")], config=user_config)1327 assert len(cb.traced_runs) == 11328 run_metadata = cb.traced_runs[0].extra["metadata"]1329 assert "my-app" in run_metadata["lc_versions"]1330 assert run_metadata["lc_versions"]["my-app"] == "2.0"1331 assert "langchain-fake" in run_metadata["lc_versions"]1332 assert "langchain-core" in run_metadata["lc_versions"]133313341335def test_user_versions_metadata_remains_user_owned() -> None:1336 """User-owned `versions` metadata is not used for package version tracking."""1337 llm = _VersionedFakeModel(responses=["hello"])1338 user_config: RunnableConfig = {"metadata": {"versions": {"my-app": "2.0"}}}13391340 with collect_runs() as cb:1341 llm.invoke([HumanMessage(content="hi")], config=user_config)1342 assert len(cb.traced_runs) == 11343 run_metadata = cb.traced_runs[0].extra["metadata"]1344 assert run_metadata["versions"] == {"my-app": "2.0"}1345 assert "langchain-fake" not in run_metadata["versions"]1346 assert "langchain-core" not in run_metadata["versions"]1347 assert "langchain-fake" in run_metadata["lc_versions"]1348 assert "langchain-core" in run_metadata["lc_versions"]134913501351async def test_user_lc_versions_metadata_survives_merge_async() -> None:1352 """Async variant: user-provided `lc_versions` metadata merges with model's."""1353 llm = _VersionedFakeModel(responses=["hello"])1354 user_config: RunnableConfig = {"metadata": {"lc_versions": {"my-app": "2.0"}}}13551356 with collect_runs() as cb:1357 await llm.ainvoke([HumanMessage(content="hi")], config=user_config)1358 assert len(cb.traced_runs) == 11359 run_metadata = cb.traced_runs[0].extra["metadata"]1360 assert "my-app" in run_metadata["lc_versions"]1361 assert "langchain-fake" in run_metadata["lc_versions"]1362 assert "langchain-core" in run_metadata["lc_versions"]136313641365def test_user_lc_versions_metadata_survives_merge_stream() -> None:1366 """Stream variant: user-provided `lc_versions` metadata merges with model's."""1367 llm = _VersionedFakeModel(responses=["hello"])1368 user_config: RunnableConfig = {"metadata": {"lc_versions": {"my-app": "2.0"}}}13691370 with collect_runs() as cb:1371 for _ in llm.stream([HumanMessage(content="hi")], config=user_config):1372 pass1373 assert len(cb.traced_runs) == 11374 run_metadata = cb.traced_runs[0].extra["metadata"]1375 assert "my-app" in run_metadata["lc_versions"]1376 assert "langchain-fake" in run_metadata["lc_versions"]1377 assert "langchain-core" in run_metadata["lc_versions"]137813791380async def test_user_lc_versions_metadata_survives_merge_astream() -> None:1381 """Async stream variant: user-provided `lc_versions` metadata merges."""1382 llm = _VersionedFakeModel(responses=["hello"])1383 user_config: RunnableConfig = {"metadata": {"lc_versions": {"my-app": "2.0"}}}13841385 with collect_runs() as cb:1386 async for _ in llm.astream([HumanMessage(content="hi")], config=user_config):1387 pass1388 assert len(cb.traced_runs) == 11389 run_metadata = cb.traced_runs[0].extra["metadata"]1390 assert "my-app" in run_metadata["lc_versions"]1391 assert "langchain-fake" in run_metadata["lc_versions"]1392 assert "langchain-core" in run_metadata["lc_versions"]139313941395def test_add_version_with_none_metadata() -> None:1396 """Model constructed with metadata=None should still get `lc_versions`."""1397 llm = FakeListChatModel(responses=["x"], metadata=None)1398 assert llm.metadata is not None1399 assert "langchain-core" in llm.metadata["lc_versions"]140014011402def test_add_version_with_non_dict_lc_versions() -> None:1403 """Non-dict `lc_versions` value is replaced with a warning."""1404 with warnings.catch_warnings(record=True) as w:1405 warnings.simplefilter("always")1406 llm = FakeListChatModel(responses=["x"], metadata={"lc_versions": "garbage"})1407 assert any("expected a dict" in str(warning.message) for warning in w)1408 assert llm.metadata is not None1409 assert isinstance(llm.metadata["lc_versions"], dict)1410 assert "langchain-core" in llm.metadata["lc_versions"]141114121413def test_langchain_version_in_metadata(monkeypatch: pytest.MonkeyPatch) -> None:1414 """When `langchain` is installed, its version appears in metadata."""14151416 def _fake_version(pkg: str) -> str:1417 if pkg == "langchain":1418 return "1.2.13"1419 raise PackageNotFoundError(pkg)14201421 monkeypatch.setattr("importlib.metadata.version", _fake_version)1422 _get_langchain_version.cache_clear()1423 try:1424 llm = FakeListChatModel(responses=["x"])1425 assert llm.metadata is not None1426 assert llm.metadata["lc_versions"]["langchain"] == "1.2.13"1427 assert "langchain-core" in llm.metadata["lc_versions"]1428 finally:1429 _get_langchain_version.cache_clear()143014311432def test_langchain_version_missing_when_not_installed(1433 monkeypatch: pytest.MonkeyPatch,1434) -> None:1435 """When `langchain` is not installed, metadata.lc_versions has no entry.14361437 The lookup raises `PackageNotFoundError` (the real not-installed signal), which1438 `_get_langchain_version` must swallow without dropping the rest of the dict.1439 """14401441 def _raise_not_found(pkg: str) -> str:1442 raise PackageNotFoundError(pkg)14431444 monkeypatch.setattr("importlib.metadata.version", _raise_not_found)1445 _get_langchain_version.cache_clear()1446 try:1447 llm = FakeListChatModel(responses=["x"])1448 assert llm.metadata is not None1449 assert "langchain" not in llm.metadata["lc_versions"]1450 assert "langchain-core" in llm.metadata["lc_versions"]1451 finally:1452 _get_langchain_version.cache_clear()145314541455def test_version_validator_coexists_with_core_seed() -> None:1456 """A `model_validator(mode="after")` calling `_add_version` keeps the core seed.14571458 Mirrors the real partner pattern (a validator, not a `model_post_init`1459 override) and confirms the validator-added entry and the core-seeded1460 `langchain-core` entry accumulate rather than clobber one another.1461 """14621463 class _ValidatorVersionedModel(FakeListChatModel):1464 @model_validator(mode="after")1465 def _set_fake_version(self) -> Self:1466 self._add_version("langchain-fake", "0.1.0")1467 return self14681469 llm = _ValidatorVersionedModel(responses=["x"])1470 assert llm.metadata is not None1471 versions = llm.metadata["lc_versions"]1472 assert versions["langchain-core"] == VERSION1473 assert versions["langchain-fake"] == "0.1.0"147414751476def test_subclass_unique_validator_names_accumulate() -> None:1477 """Parent and child uniquely-named validators must both contribute entries.14781479 Regression guard for the documented Pydantic footgun: same-named1480 `model_validator` methods *replace* rather than chain across a class1481 hierarchy. As long as each layer uses a unique validator name, a subclass1482 must not silently drop its parent's version entry.1483 """14841485 class _ParentModel(FakeListChatModel):1486 @model_validator(mode="after")1487 def _set_parent_version(self) -> Self:1488 self._add_version("langchain-parent", "1.0.0")1489 return self14901491 class _ChildModel(_ParentModel):1492 @model_validator(mode="after")1493 def _set_child_version(self) -> Self:1494 self._add_version("langchain-child", "2.0.0")1495 return self14961497 llm = _ChildModel(responses=["x"])1498 assert llm.metadata is not None1499 versions = llm.metadata["lc_versions"]1500 assert versions["langchain-core"] == VERSION1501 assert versions["langchain-parent"] == "1.0.0"1502 assert versions["langchain-child"] == "2.0.0"150315041505def test_model_profiles() -> None:1506 model = GenericFakeChatModel(messages=iter([]))1507 assert model.profile is None15081509 model_with_profile = GenericFakeChatModel(1510 messages=iter([]), profile={"max_input_tokens": 100}1511 )1512 assert model_with_profile.profile == {"max_input_tokens": 100}151315141515def test_resolve_model_profile_hook_populates_profile() -> None:1516 """_resolve_model_profile is called when profile is None."""15171518 class ResolverModel(GenericFakeChatModel):1519 def _resolve_model_profile(self) -> ModelProfile | None:1520 return {"max_input_tokens": 500}15211522 model = ResolverModel(messages=iter([]))1523 assert model.profile == {"max_input_tokens": 500}152415251526def test_resolve_model_profile_hook_skipped_when_explicit() -> None:1527 """_resolve_model_profile is NOT called when profile is set explicitly."""15281529 class ResolverModel(GenericFakeChatModel):1530 def _resolve_model_profile(self) -> ModelProfile | None:1531 return {"max_input_tokens": 500}15321533 model = ResolverModel(messages=iter([]), profile={"max_input_tokens": 999})1534 assert model.profile is not None1535 assert model.profile["max_input_tokens"] == 999153615371538def test_resolve_model_profile_hook_exception_is_caught() -> None:1539 """Model is still usable if _resolve_model_profile raises."""15401541 class BrokenProfileModel(GenericFakeChatModel):1542 def _resolve_model_profile(self) -> ModelProfile | None:1543 msg = "profile file not found"1544 raise RuntimeError(msg)15451546 with warnings.catch_warnings(record=True):1547 warnings.simplefilter("always")1548 model = BrokenProfileModel(messages=iter([]))15491550 assert model.profile is None155115521553def test_check_profile_keys_runs_despite_partner_override() -> None:1554 """Verify _check_profile_keys fires even when _set_model_profile is overridden.15551556 Because _check_profile_keys has a distinct validator name from1557 _set_model_profile, a partner override of the latter does not suppress1558 the key-checking validator.1559 """15601561 class PartnerModel(GenericFakeChatModel):1562 """Simulates a partner that overrides _set_model_profile."""15631564 @model_validator(mode="after")1565 def _set_model_profile(self) -> Self:1566 if self.profile is None:1567 profile: dict[str, Any] = {1568 "max_input_tokens": 100,1569 "partner_only_field": True,1570 }1571 self.profile = profile # type: ignore[assignment]1572 return self15731574 with warnings.catch_warnings(record=True) as w:1575 warnings.simplefilter("always")1576 model = PartnerModel(messages=iter([]))15771578 assert model.profile is not None1579 assert model.profile.get("partner_only_field") is True1580 profile_warnings = [x for x in w if "Unrecognized keys" in str(x.message)]1581 assert len(profile_warnings) == 11582 assert "partner_only_field" in str(profile_warnings[0].message)158315841585class MockResponse:1586 """Mock response for testing _generate_response_from_error."""15871588 def __init__(1589 self,1590 status_code: int = 400,1591 headers: dict[str, str] | None = None,1592 json_data: dict[str, Any] | None = None,1593 json_raises: type[Exception] | None = None,1594 text_raises: type[Exception] | None = None,1595 ):1596 self.status_code = status_code1597 self.headers = headers or {}1598 self._json_data = json_data1599 self._json_raises = json_raises1600 self._text_raises = text_raises16011602 def json(self) -> dict[str, Any]:1603 if self._json_raises:1604 msg = "JSON parsing failed"1605 raise self._json_raises(msg)1606 return self._json_data or {}16071608 @property1609 def text(self) -> str:1610 if self._text_raises:1611 msg = "Text access failed"1612 raise self._text_raises(msg)1613 return ""161416151616class MockAPIError(Exception):1617 """Mock API error with response attribute."""16181619 def __init__(self, message: str, response: MockResponse | None = None):1620 super().__init__(message)1621 self.message = message1622 if response is not None:1623 self.response = response162416251626def test_generate_response_from_error_with_valid_json() -> None:1627 """Test `_generate_response_from_error` with valid JSON response."""1628 response = MockResponse(1629 status_code=400,1630 headers={"content-type": "application/json"},1631 json_data={"error": {"message": "Bad request", "type": "invalid_request"}},1632 )1633 error = MockAPIError("API Error", response=response)16341635 generations = _generate_response_from_error(error)16361637 assert len(generations) == 11638 generation = generations[0]1639 assert isinstance(generation, ChatGeneration)1640 assert isinstance(generation.message, AIMessage)1641 assert generation.message.content == ""16421643 metadata = generation.message.response_metadata1644 assert metadata["body"] == {1645 "error": {"message": "Bad request", "type": "invalid_request"}1646 }1647 assert metadata["headers"] == {"content-type": "application/json"}1648 assert metadata["status_code"] == 400164916501651def test_generate_response_from_error_handles_streaming_response_failure() -> None:1652 # Simulates scenario where accessing response.json() or response.text1653 # raises ResponseNotRead on streaming responses1654 response = MockResponse(1655 status_code=400,1656 headers={"content-type": "application/json"},1657 json_raises=Exception, # Simulates ResponseNotRead or similar1658 text_raises=Exception,1659 )1660 error = MockAPIError("API Error", response=response)16611662 # This should NOT raise an exception, but should handle it gracefully1663 generations = _generate_response_from_error(error)16641665 assert len(generations) == 11666 generation = generations[0]1667 metadata = generation.message.response_metadata16681669 # When both fail, body should be None instead of raising an exception1670 assert metadata["body"] is None1671 assert metadata["headers"] == {"content-type": "application/json"}1672 assert metadata["status_code"] == 400167316741675def test_filter_invocation_params_for_tracing() -> None:1676 """Test that large fields are filtered from invocation params for tracing."""1677 params = {1678 "temperature": 0.7,1679 "tools": [{"name": "test_tool"}],1680 "functions": [{"name": "test_function"}],1681 "messages": [{"role": "system", "content": "test"}],1682 "response_format": {"type": "json_object"},1683 }1684 filtered = _filter_invocation_params_for_tracing(params)16851686 # Should include temperature1687 assert "temperature" in filtered1688 assert filtered["temperature"] == 0.716891690 # Should exclude these large fields1691 assert "tools" not in filtered1692 assert "functions" not in filtered1693 assert "messages" not in filtered1694 assert "response_format" not in filtered169516961697class FakeChatModelWithInvocationParams(SimpleChatModel):1698 """Fake chat model with invocation params for testing tracing."""16991700 temperature: float = 0.717011702 @property1703 @override1704 def _llm_type(self) -> str:1705 return "fake-chat-model-with-invocation-params"17061707 @property1708 @override1709 def _identifying_params(self) -> dict[str, Any]:1710 return {1711 "temperature": self.temperature,1712 "tools": [{"name": "test_tool"}],1713 "functions": [{"name": "test_function"}],1714 "messages": [{"role": "system", "content": "test"}],1715 "response_format": {"type": "json_object"},1716 }17171718 @override1719 def _call(1720 self,1721 messages: list[BaseMessage],1722 stop: list[str] | None = None,1723 run_manager: CallbackManagerForLLMRun | None = None,1724 **kwargs: Any,1725 ) -> str:1726 return "test response"172717281729class FakeStreamingChatModelWithInvocationParams(FakeChatModelWithInvocationParams):1730 """Streaming counterpart for tracer metadata tests."""17311732 @override1733 def _stream(1734 self,1735 messages: list[BaseMessage],1736 stop: list[str] | None = None,1737 run_manager: CallbackManagerForLLMRun | None = None,1738 **kwargs: Any,1739 ) -> Iterator[ChatGenerationChunk]:1740 del messages, stop, run_manager, kwargs1741 yield ChatGenerationChunk(message=AIMessageChunk(content="test response"))17421743 @override1744 async def _astream(1745 self,1746 messages: list[BaseMessage],1747 stop: list[str] | None = None,1748 run_manager: AsyncCallbackManagerForLLMRun | None = None,1749 **kwargs: Any,1750 ) -> AsyncIterator[ChatGenerationChunk]:1751 del messages, stop, run_manager, kwargs1752 yield ChatGenerationChunk(message=AIMessageChunk(content="test response"))175317541755def test_invocation_params_passed_to_tracer_metadata() -> None:1756 """Test that invocation params are passed to tracer metadata."""1757 llm = FakeChatModelWithInvocationParams()1758 collector = LangChainTracerRunCollector()17591760 with collector.tracing_callback() as tracer:1761 llm.invoke([HumanMessage(content="Hello")], config={"callbacks": [tracer]})17621763 assert len(collector.runs) == 11764 run = collector.runs[0]17651766 # LangSmith injects environment-derived keys (e.g. `revision_id`,1767 # `LANGCHAIN_TESTS_USER_AGENT`, `LANGSMITH_*`) into run metadata. These vary1768 # by environment and are not the subject of this test, so strip them before1769 # the exact-equality comparison.1770 for env_key in get_langchain_env_var_metadata():1771 run.extra["metadata"].pop(env_key, None)17721773 assert run.extra == {1774 "batch_size": 1,1775 "invocation_params": {1776 "_type": "fake-chat-model-with-invocation-params",1777 "functions": [{"name": "test_function"}],1778 "messages": [{"content": "test", "role": "system"}],1779 "response_format": {"type": "json_object"},1780 "stop": None,1781 "temperature": 0.7,1782 "tools": [{"name": "test_tool"}],1783 },1784 "metadata": {1785 "_type": "fake-chat-model-with-invocation-params",1786 "ls_integration": "langchain_chat_model",1787 "ls_model_type": "chat",1788 "ls_provider": "fakechatmodelwithinvocationparams",1789 "ls_temperature": 0.7,1790 "stop": None,1791 "temperature": 0.7,1792 "lc_versions": {"langchain-core": VERSION},1793 },1794 "options": {"stop": None},1795 "runtime": run.extra["runtime"],1796 }1797 assert run.metadata == run.extra["metadata"]179817991800def test_stream_events_v3_invocation_params_passed_to_tracer_metadata() -> None:1801 """`stream_events(version="v3")` preserves filtered invocation params."""1802 llm = FakeStreamingChatModelWithInvocationParams()1803 collector = LangChainTracerRunCollector()18041805 with collector.tracing_callback() as tracer:1806 _ = llm.stream_events(1807 [HumanMessage(content="Hello")],1808 config={"callbacks": [tracer]},1809 stop=["done"],1810 version="v3",1811 ).output18121813 assert len(collector.runs) == 11814 metadata = collector.runs[0].extra["metadata"]18151816 assert metadata["_type"] == "fake-chat-model-with-invocation-params"1817 assert metadata["stop"] == ["done"]1818 assert metadata["temperature"] == 0.7181918201821async def test_astream_events_v3_invocation_params_passed_to_tracer_metadata() -> None:1822 """`astream_events(version="v3")` preserves filtered invocation params."""1823 llm = FakeStreamingChatModelWithInvocationParams()1824 collector = LangChainTracerRunCollector()18251826 with collector.tracing_callback() as tracer:1827 stream = await llm.astream_events(1828 [HumanMessage(content="Hello")],1829 config={"callbacks": [tracer]},1830 stop=["done"],1831 version="v3",1832 )1833 _ = await stream18341835 assert len(collector.runs) == 11836 metadata = collector.runs[0].extra["metadata"]18371838 assert metadata["_type"] == "fake-chat-model-with-invocation-params"1839 assert metadata["stop"] == ["done"]1840 assert metadata["temperature"] == 0.7
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.