libs/partners/openai/tests/unit_tests/chat_models/test_base.py PYTHON 4,265 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 4,265.
1"""Test OpenAI Chat API wrapper."""23from __future__ import annotations45import json6import warnings7from functools import partial8from types import TracebackType9from typing import Any, Literal, cast10from unittest.mock import AsyncMock, MagicMock, patch1112import httpx13import openai14import pytest15from langchain_core.exceptions import ContextOverflowError16from langchain_core.load import dumps, loads17from langchain_core.messages import (18    AIMessage,19    AIMessageChunk,20    BaseMessage,21    FunctionMessage,22    HumanMessage,23    InvalidToolCall,24    SystemMessage,25    ToolCall,26    ToolMessage,27    message_chunk_to_message,28)29from langchain_core.messages import content as types30from langchain_core.messages.ai import UsageMetadata31from langchain_core.messages.block_translators.openai import (32    _convert_from_v03_ai_message,33)34from langchain_core.outputs import ChatGeneration, ChatResult35from langchain_core.runnables import RunnableLambda36from langchain_core.runnables.base import RunnableBinding, RunnableSequence37from langchain_core.tracers.base import BaseTracer38from langchain_core.tracers.schemas import Run39from openai.types.responses import (40    ResponseApplyPatchToolCall,41    ResponseApplyPatchToolCallOutput,42    ResponseOutputMessage,43    ResponseReasoningItem,44)45from openai.types.responses.response import IncompleteDetails, Response46from openai.types.responses.response_apply_patch_tool_call import OperationCreateFile47from openai.types.responses.response_error import ResponseError48from openai.types.responses.response_file_search_tool_call import (49    ResponseFileSearchToolCall,50    Result,51)52from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall53from openai.types.responses.response_function_web_search import (54    ActionSearch,55    ResponseFunctionWebSearch,56)57from openai.types.responses.response_output_refusal import ResponseOutputRefusal58from openai.types.responses.response_output_text import ResponseOutputText59from openai.types.responses.response_reasoning_item import Summary60from openai.types.responses.response_usage import (61    InputTokensDetails,62    OutputTokensDetails,63    ResponseUsage,64)65from pydantic import BaseModel, Field, SecretStr66from typing_extensions import Self, TypedDict6768from langchain_openai import ChatOpenAI69from langchain_openai.chat_models._compat import (70    _FUNCTION_CALL_IDS_MAP_KEY,71    _convert_from_v1_to_chat_completions,72    _convert_from_v1_to_responses,73    _convert_to_v03_ai_message,74)75from langchain_openai.chat_models.base import (76    OpenAIRefusalError,77    _construct_lc_result_from_responses_api,78    _construct_responses_api_input,79    _convert_dict_to_message,80    _convert_message_to_dict,81    _convert_responses_chunk_to_generation_chunk,82    _convert_to_openai_response_format,83    _create_usage_metadata,84    _create_usage_metadata_responses,85    _format_message_content,86    _get_last_messages,87    _make_computer_call_output_from_message,88    _model_prefers_responses_api,89    _oai_structured_outputs_parser,90    _resize,91)9293OPENAI_TEST_MODEL = "gpt-5.5"94OPENAI_TEMPERATURE_CAPABLE_TEST_MODEL = "gpt-4o-mini"959697def test_openai_model_param() -> None:98    llm = ChatOpenAI(model="foo")99    assert llm.model_name == "foo"100    assert llm.model == "foo"101    llm = ChatOpenAI(model_name="foo")  # type: ignore[call-arg]102    assert llm.model_name == "foo"103    assert llm.model == "foo"104105    llm = ChatOpenAI(max_tokens=10)  # type: ignore[call-arg]106    assert llm.max_tokens == 10107    llm = ChatOpenAI(max_completion_tokens=10)108    assert llm.max_tokens == 10109110111@pytest.mark.parametrize("async_api", [True, False])112def test_streaming_attribute_should_stream(async_api: bool) -> None:113    llm = ChatOpenAI(model="foo", streaming=True)114    assert llm._should_stream(async_api=async_api)115116117def test_openai_client_caching() -> None:118    """Test that the OpenAI client is cached."""119    llm1 = ChatOpenAI(model=OPENAI_TEST_MODEL)120    llm2 = ChatOpenAI(model=OPENAI_TEST_MODEL)121    assert llm1.root_client._client is llm2.root_client._client122123    llm3 = ChatOpenAI(model=OPENAI_TEST_MODEL, base_url="foo")124    assert llm1.root_client._client is not llm3.root_client._client125126    llm4 = ChatOpenAI(model=OPENAI_TEST_MODEL, timeout=None)127    assert llm1.root_client._client is llm4.root_client._client128129    llm5 = ChatOpenAI(model=OPENAI_TEST_MODEL, timeout=3)130    assert llm1.root_client._client is not llm5.root_client._client131132    llm6 = ChatOpenAI(133        model=OPENAI_TEST_MODEL, timeout=httpx.Timeout(timeout=60.0, connect=5.0)134    )135    assert llm1.root_client._client is not llm6.root_client._client136137    llm7 = ChatOpenAI(model=OPENAI_TEST_MODEL, timeout=(5, 1))138    assert llm1.root_client._client is not llm7.root_client._client139140141def test_profile() -> None:142    model = ChatOpenAI(model="gpt-5.2-pro")143    assert model.profile144    assert not model.profile["structured_output"]145146    model = ChatOpenAI(model="gpt-5")147    assert model.profile148    assert model.profile["structured_output"]149    assert model.profile["tool_calling"]150151    # Test overwriting a field152    model.profile["tool_calling"] = False153    assert not model.profile["tool_calling"]154155    # Test we didn't mutate156    model = ChatOpenAI(model="gpt-5")157    assert model.profile158    assert model.profile["tool_calling"]159160    # Test passing in profile161    model = ChatOpenAI(model="gpt-5", profile={"tool_calling": False})162    assert model.profile == {"tool_calling": False}163164    # Test overrides for gpt-5 input tokens165    model = ChatOpenAI(model="gpt-5")166    assert model.profile["max_input_tokens"] == 272_000167168169def test_function_message_dict_to_function_message() -> None:170    content = json.dumps({"result": "Example #1"})171    name = "test_function"172    result = _convert_dict_to_message(173        {"role": "function", "name": name, "content": content}174    )175    assert isinstance(result, FunctionMessage)176    assert result.name == name177    assert result.content == content178179180def test__convert_dict_to_message_human() -> None:181    message = {"role": "user", "content": "foo"}182    result = _convert_dict_to_message(message)183    expected_output = HumanMessage(content="foo")184    assert result == expected_output185    assert _convert_message_to_dict(expected_output) == message186187188def test__convert_dict_to_message_human_with_name() -> None:189    message = {"role": "user", "content": "foo", "name": "test"}190    result = _convert_dict_to_message(message)191    expected_output = HumanMessage(content="foo", name="test")192    assert result == expected_output193    assert _convert_message_to_dict(expected_output) == message194195196def test__convert_dict_to_message_ai() -> None:197    message = {"role": "assistant", "content": "foo"}198    result = _convert_dict_to_message(message)199    expected_output = AIMessage(content="foo")200    assert result == expected_output201    assert _convert_message_to_dict(expected_output) == message202203204def test__convert_dict_to_message_ai_with_name() -> None:205    message = {"role": "assistant", "content": "foo", "name": "test"}206    result = _convert_dict_to_message(message)207    expected_output = AIMessage(content="foo", name="test")208    assert result == expected_output209    assert _convert_message_to_dict(expected_output) == message210211212def test__convert_dict_to_message_system() -> None:213    message = {"role": "system", "content": "foo"}214    result = _convert_dict_to_message(message)215    expected_output = SystemMessage(content="foo")216    assert result == expected_output217    assert _convert_message_to_dict(expected_output) == message218219220def test__convert_dict_to_message_developer() -> None:221    message = {"role": "developer", "content": "foo"}222    result = _convert_dict_to_message(message)223    expected_output = SystemMessage(224        content="foo", additional_kwargs={"__openai_role__": "developer"}225    )226    assert result == expected_output227    assert _convert_message_to_dict(expected_output) == message228229230def test__convert_dict_to_message_system_with_name() -> None:231    message = {"role": "system", "content": "foo", "name": "test"}232    result = _convert_dict_to_message(message)233    expected_output = SystemMessage(content="foo", name="test")234    assert result == expected_output235    assert _convert_message_to_dict(expected_output) == message236237238def test__convert_dict_to_message_tool() -> None:239    message = {"role": "tool", "content": "foo", "tool_call_id": "bar"}240    result = _convert_dict_to_message(message)241    expected_output = ToolMessage(content="foo", tool_call_id="bar")242    assert result == expected_output243    assert _convert_message_to_dict(expected_output) == message244245246def test__convert_dict_to_message_tool_call() -> None:247    raw_tool_call = {248        "id": "call_wm0JY6CdwOMZ4eTxHWUThDNz",249        "function": {250            "arguments": '{"name": "Sally", "hair_color": "green"}',251            "name": "GenerateUsername",252        },253        "type": "function",254    }255    message = {"role": "assistant", "content": None, "tool_calls": [raw_tool_call]}256    result = _convert_dict_to_message(message)257    expected_output = AIMessage(258        content="",259        tool_calls=[260            ToolCall(261                name="GenerateUsername",262                args={"name": "Sally", "hair_color": "green"},263                id="call_wm0JY6CdwOMZ4eTxHWUThDNz",264                type="tool_call",265            )266        ],267    )268    assert result == expected_output269    assert _convert_message_to_dict(expected_output) == message270271    # Test malformed tool call272    raw_tool_calls: list = [273        {274            "id": "call_wm0JY6CdwOMZ4eTxHWUThDNz",275            "function": {"arguments": "oops", "name": "GenerateUsername"},276            "type": "function",277        },278        {279            "id": "call_abc123",280            "function": {281                "arguments": '{"name": "Sally", "hair_color": "green"}',282                "name": "GenerateUsername",283            },284            "type": "function",285        },286    ]287    raw_tool_calls = sorted(raw_tool_calls, key=lambda x: x["id"])288    message = {"role": "assistant", "content": None, "tool_calls": raw_tool_calls}289    result = _convert_dict_to_message(message)290    expected_output = AIMessage(291        content="",292        invalid_tool_calls=[293            InvalidToolCall(294                name="GenerateUsername",295                args="oops",296                id="call_wm0JY6CdwOMZ4eTxHWUThDNz",297                error=(298                    "Function GenerateUsername arguments:\n\noops\n\nare not "299                    "valid JSON. Received JSONDecodeError Expecting value: line 1 "300                    "column 1 (char 0)\nFor troubleshooting, visit: https://docs"301                    ".langchain.com/oss/python/langchain/errors/OUTPUT_PARSING_FAILURE "302                ),303                type="invalid_tool_call",304            )305        ],306        tool_calls=[307            ToolCall(308                name="GenerateUsername",309                args={"name": "Sally", "hair_color": "green"},310                id="call_abc123",311                type="tool_call",312            )313        ],314    )315    assert result == expected_output316    reverted_message_dict = _convert_message_to_dict(expected_output)317    reverted_message_dict["tool_calls"] = sorted(318        reverted_message_dict["tool_calls"], key=lambda x: x["id"]319    )320    assert reverted_message_dict == message321322323class MockAsyncContextManager:324    def __init__(self, chunk_list: list) -> None:325        self.current_chunk = 0326        self.chunk_list = chunk_list327        self.chunk_num = len(chunk_list)328329    async def __aenter__(self) -> Self:330        return self331332    async def __aexit__(333        self,334        exc_type: type[BaseException] | None,335        exc: BaseException | None,336        tb: TracebackType | None,337    ) -> None:338        pass339340    def __aiter__(self) -> MockAsyncContextManager:341        return self342343    async def __anext__(self) -> dict:344        if self.current_chunk < self.chunk_num:345            chunk = self.chunk_list[self.current_chunk]346            self.current_chunk += 1347            return chunk348        raise StopAsyncIteration349350351class MockSyncContextManager:352    def __init__(self, chunk_list: list) -> None:353        self.current_chunk = 0354        self.chunk_list = chunk_list355        self.chunk_num = len(chunk_list)356357    def __enter__(self) -> Self:358        return self359360    def __exit__(361        self,362        exc_type: type[BaseException] | None,363        exc: BaseException | None,364        tb: TracebackType | None,365    ) -> None:366        pass367368    def __iter__(self) -> MockSyncContextManager:369        return self370371    def __next__(self) -> dict:372        if self.current_chunk < self.chunk_num:373            chunk = self.chunk_list[self.current_chunk]374            self.current_chunk += 1375            return chunk376        raise StopIteration377378379GLM4_STREAM_META = """{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u4eba\u5de5\u667a\u80fd"}}]}380{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u52a9\u624b"}}]}381{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":","}}]}382{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u4f60\u53ef\u4ee5"}}]}383{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u53eb\u6211"}}]}384{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"AI"}}]}385{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u52a9\u624b"}}]}386{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"。"}}]}387{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":13,"completion_tokens":10,"total_tokens":23}}388[DONE]"""  # noqa: E501389390391@pytest.fixture392def mock_glm4_completion() -> list:393    list_chunk_data = GLM4_STREAM_META.split("\n")394    result_list = []395    for msg in list_chunk_data:396        if msg != "[DONE]":397            result_list.append(json.loads(msg))398399    return result_list400401402async def test_glm4_astream(mock_glm4_completion: list) -> None:403    llm_name = "glm-4"404    llm = ChatOpenAI(model=llm_name, stream_usage=True)405    mock_client = AsyncMock()406407    async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:408        return MockAsyncContextManager(mock_glm4_completion)409410    mock_client.create = mock_create411    usage_chunk = mock_glm4_completion[-1]412413    usage_metadata: UsageMetadata | None = None414    with patch.object(llm, "async_client", mock_client):415        async for chunk in llm.astream("你的名字叫什么?只回答名字"):416            assert isinstance(chunk, AIMessageChunk)417            if chunk.usage_metadata is not None:418                usage_metadata = chunk.usage_metadata419420    assert usage_metadata is not None421422    assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]423    assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]424    assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]425426427def test_glm4_stream(mock_glm4_completion: list) -> None:428    llm_name = "glm-4"429    llm = ChatOpenAI(model=llm_name, stream_usage=True)430    mock_client = MagicMock()431432    def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:433        return MockSyncContextManager(mock_glm4_completion)434435    mock_client.create = mock_create436    usage_chunk = mock_glm4_completion[-1]437438    usage_metadata: UsageMetadata | None = None439    with patch.object(llm, "client", mock_client):440        for chunk in llm.stream("你的名字叫什么?只回答名字"):441            assert isinstance(chunk, AIMessageChunk)442            if chunk.usage_metadata is not None:443                usage_metadata = chunk.usage_metadata444445    assert usage_metadata is not None446447    assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]448    assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]449    assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]450451452DEEPSEEK_STREAM_DATA = """{"id":"d3610c24e6b42518a7883ea57c3ea2c3","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":null,"logprobs":null}],"created":1721630271,"model":"deepseek-chat","system_fingerprint":"fp_7e0991cad4","object":"chat.completion.chunk","usage":null}453{"choices":[{"delta":{"content":"我是","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}454{"choices":[{"delta":{"content":"Deep","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}455{"choices":[{"delta":{"content":"Seek","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}456{"choices":[{"delta":{"content":" Chat","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}457{"choices":[{"delta":{"content":",","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}458{"choices":[{"delta":{"content":"一个","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}459{"choices":[{"delta":{"content":"由","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}460{"choices":[{"delta":{"content":"深度","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}461{"choices":[{"delta":{"content":"求","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}462{"choices":[{"delta":{"content":"索","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}463{"choices":[{"delta":{"content":"公司","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}464{"choices":[{"delta":{"content":"开发的","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}465{"choices":[{"delta":{"content":"智能","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}466{"choices":[{"delta":{"content":"助手","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}467{"choices":[{"delta":{"content":"。","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":null}468{"choices":[{"delta":{"content":"","role":null},"finish_reason":"stop","index":0,"logprobs":null}],"created":1721630271,"id":"d3610c24e6b42518a7883ea57c3ea2c3","model":"deepseek-chat","object":"chat.completion.chunk","system_fingerprint":"fp_7e0991cad4","usage":{"completion_tokens":15,"prompt_tokens":11,"total_tokens":26}}469[DONE]"""  # noqa: E501470471472@pytest.fixture473def mock_deepseek_completion() -> list[dict]:474    list_chunk_data = DEEPSEEK_STREAM_DATA.split("\n")475    result_list = []476    for msg in list_chunk_data:477        if msg != "[DONE]":478            result_list.append(json.loads(msg))479480    return result_list481482483async def test_deepseek_astream(mock_deepseek_completion: list) -> None:484    llm_name = "deepseek-chat"485    llm = ChatOpenAI(model=llm_name, stream_usage=True)486    mock_client = AsyncMock()487488    async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:489        return MockAsyncContextManager(mock_deepseek_completion)490491    mock_client.create = mock_create492    usage_chunk = mock_deepseek_completion[-1]493    usage_metadata: UsageMetadata | None = None494    with patch.object(llm, "async_client", mock_client):495        async for chunk in llm.astream("你的名字叫什么?只回答名字"):496            assert isinstance(chunk, AIMessageChunk)497            if chunk.usage_metadata is not None:498                usage_metadata = chunk.usage_metadata499500    assert usage_metadata is not None501502    assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]503    assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]504    assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]505506507def test_deepseek_stream(mock_deepseek_completion: list) -> None:508    llm_name = "deepseek-chat"509    llm = ChatOpenAI(model=llm_name, stream_usage=True)510    mock_client = MagicMock()511512    def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:513        return MockSyncContextManager(mock_deepseek_completion)514515    mock_client.create = mock_create516    usage_chunk = mock_deepseek_completion[-1]517    usage_metadata: UsageMetadata | None = None518    with patch.object(llm, "client", mock_client):519        for chunk in llm.stream("你的名字叫什么?只回答名字"):520            assert isinstance(chunk, AIMessageChunk)521            if chunk.usage_metadata is not None:522                usage_metadata = chunk.usage_metadata523524    assert usage_metadata is not None525526    assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]527    assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]528    assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]529530531OPENAI_STREAM_DATA = """{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-5.5","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":null}532{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-5.5","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"content":"我是"},"logprobs":null,"finish_reason":null}],"usage":null}533{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-5.5","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"content":"助手"},"logprobs":null,"finish_reason":null}],"usage":null}534{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-5.5","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"content":"。"},"logprobs":null,"finish_reason":null}],"usage":null}535{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-5.5","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null}536{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-5.5","system_fingerprint":"fp_18cc0f1fa0","choices":[],"usage":{"prompt_tokens":14,"completion_tokens":3,"total_tokens":17}}537[DONE]"""  # noqa: E501538539540@pytest.fixture541def mock_openai_completion() -> list[dict]:542    list_chunk_data = OPENAI_STREAM_DATA.split("\n")543    result_list = []544    for msg in list_chunk_data:545        if msg != "[DONE]":546            result_list.append(json.loads(msg))547548    return result_list549550551async def test_openai_astream(mock_openai_completion: list) -> None:552    llm_name = OPENAI_TEST_MODEL553    llm = ChatOpenAI(model=llm_name, stream_usage=True)554    assert llm.stream_usage555    mock_client = AsyncMock()556557    async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:558        return MockAsyncContextManager(mock_openai_completion)559560    mock_client.create = mock_create561    usage_chunk = mock_openai_completion[-1]562    usage_metadata: UsageMetadata | None = None563    with patch.object(llm, "async_client", mock_client):564        async for chunk in llm.astream("你的名字叫什么?只回答名字"):565            assert isinstance(chunk, AIMessageChunk)566            if chunk.usage_metadata is not None:567                usage_metadata = chunk.usage_metadata568569    assert usage_metadata is not None570571    assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]572    assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]573    assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]574575576def test_openai_stream(mock_openai_completion: list) -> None:577    llm_name = OPENAI_TEST_MODEL578    llm = ChatOpenAI(model=llm_name, stream_usage=True)579    assert llm.stream_usage580    mock_client = MagicMock()581582    call_kwargs = []583584    def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:585        call_kwargs.append(kwargs)586        return MockSyncContextManager(mock_openai_completion)587588    mock_client.create = mock_create589    usage_chunk = mock_openai_completion[-1]590    usage_metadata: UsageMetadata | None = None591    with patch.object(llm, "client", mock_client):592        for chunk in llm.stream("你的名字叫什么?只回答名字"):593            assert isinstance(chunk, AIMessageChunk)594            if chunk.usage_metadata is not None:595                usage_metadata = chunk.usage_metadata596597    assert call_kwargs[-1]["stream_options"] == {"include_usage": True}598    assert usage_metadata is not None599    assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]600    assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]601    assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]602603    # Verify no streaming outside of default base URL or clients604    for param, value in {605        "stream_usage": False,606        "openai_proxy": "http://localhost:7890",607        "openai_api_base": "https://example.com/v1",608        "base_url": "https://example.com/v1",609        "client": mock_client,610        "root_client": mock_client,611        "async_client": mock_client,612        "root_async_client": mock_client,613        "http_client": httpx.Client(),614        "http_async_client": httpx.AsyncClient(),615    }.items():616        llm = ChatOpenAI(model=llm_name, **{param: value})  # type: ignore[arg-type]617        assert not llm.stream_usage618        with patch.object(llm, "client", mock_client):619            _ = list(llm.stream("..."))620        assert "stream_options" not in call_kwargs[-1]621622623def test_openai_stream_events_v3_lifecycle(mock_openai_completion: list) -> None:624    """`stream_events(version="v3")` on chat completions emits a valid lifecycle."""625    from langchain_tests.utils.stream_lifecycle import assert_valid_event_stream626627    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)628    mock_client = MagicMock()629630    def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:631        return MockSyncContextManager(mock_openai_completion)632633    mock_client.create = mock_create634    with patch.object(llm, "client", mock_client):635        events = list(llm.stream_events("你的名字叫什么?只回答名字", version="v3"))636637    assert_valid_event_stream(events)638    # At minimum, a text block with the accumulated answer.639    finishes = [e for e in events if e["event"] == "content-block-finish"]640    assert len(finishes) >= 1641    text_finishes = [f for f in finishes if f["content"]["type"] == "text"]642    assert len(text_finishes) == 1643644645@pytest.fixture646def mock_completion() -> dict:647    return {648        "id": "chatcmpl-7fcZavknQda3SQ",649        "object": "chat.completion",650        "created": 1689989000,651        "model": OPENAI_TEST_MODEL,652        "choices": [653            {654                "index": 0,655                "message": {"role": "assistant", "content": "Bar Baz", "name": "Erick"},656                "finish_reason": "stop",657            }658        ],659    }660661662@pytest.fixture663def mock_client(mock_completion: dict) -> MagicMock:664    rtn = MagicMock()665666    mock_create = MagicMock()667668    mock_resp = MagicMock()669    mock_resp.headers = {"content-type": "application/json"}670    mock_resp.parse.return_value = mock_completion671    mock_create.return_value = mock_resp672673    rtn.with_raw_response.create = mock_create674    rtn.create.return_value = mock_completion675    return rtn676677678@pytest.fixture679def mock_async_client(mock_completion: dict) -> AsyncMock:680    rtn = AsyncMock()681682    mock_create = AsyncMock()683    mock_resp = MagicMock()684    mock_resp.parse.return_value = mock_completion685    mock_create.return_value = mock_resp686687    rtn.with_raw_response.create = mock_create688    rtn.create.return_value = mock_completion689    return rtn690691692def test_openai_invoke(mock_client: MagicMock) -> None:693    llm = ChatOpenAI()694695    with patch.object(llm, "client", mock_client):696        res = llm.invoke("bar")697        assert res.content == "Bar Baz"698699        # headers are not in response_metadata if include_response_headers not set700        assert "headers" not in res.response_metadata701    assert mock_client.with_raw_response.create.called702703704async def test_openai_ainvoke(mock_async_client: AsyncMock) -> None:705    llm = ChatOpenAI()706707    with patch.object(llm, "async_client", mock_async_client):708        res = await llm.ainvoke("bar")709        assert res.content == "Bar Baz"710711        # headers are not in response_metadata if include_response_headers not set712        assert "headers" not in res.response_metadata713    assert mock_async_client.with_raw_response.create.called714715716@pytest.mark.parametrize(717    "model",718    [719        OPENAI_TEST_MODEL,720        "gpt-5-nano",721        "o3",722        "gpt-5.2",723    ],724)725def test__get_encoding_model(model: str) -> None:726    ChatOpenAI(model=model)._get_encoding_model()727728729def test_openai_invoke_name(mock_client: MagicMock) -> None:730    llm = ChatOpenAI()731732    with patch.object(llm, "client", mock_client):733        messages = [HumanMessage(content="Foo", name="Katie")]734        res = llm.invoke(messages)735        call_args, call_kwargs = mock_client.with_raw_response.create.call_args736        assert len(call_args) == 0  # no positional args737        call_messages = call_kwargs["messages"]738        assert len(call_messages) == 1739        assert call_messages[0]["role"] == "user"740        assert call_messages[0]["content"] == "Foo"741        assert call_messages[0]["name"] == "Katie"742743        # check return type has name744        assert res.content == "Bar Baz"745        assert res.name == "Erick"746747748def test_function_calls_with_tool_calls(mock_client: MagicMock) -> None:749    # Test that we ignore function calls if tool_calls are present750    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)751    tool_call_message = AIMessage(752        content="",753        additional_kwargs={754            "function_call": {755                "name": "get_weather",756                "arguments": '{"location": "Boston"}',757            }758        },759        tool_calls=[760            {761                "name": "get_weather",762                "args": {"location": "Boston"},763                "id": "abc123",764                "type": "tool_call",765            }766        ],767    )768    messages = [769        HumanMessage("What's the weather in Boston?"),770        tool_call_message,771        ToolMessage(content="It's sunny.", name="get_weather", tool_call_id="abc123"),772    ]773    with patch.object(llm, "client", mock_client):774        _ = llm.invoke(messages)775        _, call_kwargs = mock_client.with_raw_response.create.call_args776        call_messages = call_kwargs["messages"]777        tool_call_message_payload = call_messages[1]778        assert "tool_calls" in tool_call_message_payload779        assert "function_call" not in tool_call_message_payload780781    # Test we don't ignore function calls if tool_calls are not present782    cast(AIMessage, messages[1]).tool_calls = []783    with patch.object(llm, "client", mock_client):784        _ = llm.invoke(messages)785        _, call_kwargs = mock_client.with_raw_response.create.call_args786        call_messages = call_kwargs["messages"]787        tool_call_message_payload = call_messages[1]788        assert "function_call" in tool_call_message_payload789        assert "tool_calls" not in tool_call_message_payload790791792def test_custom_token_counting() -> None:793    def token_encoder(text: str) -> list[int]:794        return [1, 2, 3]795796    llm = ChatOpenAI(custom_get_token_ids=token_encoder)797    assert llm.get_token_ids("foo") == [1, 2, 3]798799800def test_format_message_content() -> None:801    content: Any = "hello"802    assert content == _format_message_content(content)803804    content = None805    assert content == _format_message_content(content)806807    content = []808    assert content == _format_message_content(content)809810    content = [811        {"type": "text", "text": "What is in this image?"},812        {"type": "image_url", "image_url": {"url": "url.com"}},813    ]814    assert content == _format_message_content(content)815816    content = [817        {"type": "text", "text": "hello"},818        {819            "type": "tool_use",820            "id": "toolu_01A09q90qw90lq917835lq9",821            "name": "get_weather",822            "input": {"location": "San Francisco, CA", "unit": "celsius"},823        },824    ]825    assert _format_message_content(content) == [{"type": "text", "text": "hello"}]826827    # Standard multi-modal inputs828    contents = [829        {"type": "image", "source_type": "url", "url": "https://..."},  # v0830        {"type": "image", "url": "https://..."},  # v1831    ]832    expected = [{"type": "image_url", "image_url": {"url": "https://..."}}]833    for content in contents:834        assert expected == _format_message_content([content])835836    contents = [837        {838            "type": "image",839            "source_type": "base64",840            "data": "<base64 data>",841            "mime_type": "image/png",842        },843        {"type": "image", "base64": "<base64 data>", "mime_type": "image/png"},844    ]845    expected = [846        {847            "type": "image_url",848            "image_url": {"url": "data:image/png;base64,<base64 data>"},849        }850    ]851    for content in contents:852        assert expected == _format_message_content([content])853854    contents = [855        {856            "type": "file",857            "source_type": "base64",858            "data": "<base64 data>",859            "mime_type": "application/pdf",860            "filename": "my_file",861        },862        {863            "type": "file",864            "base64": "<base64 data>",865            "mime_type": "application/pdf",866            "filename": "my_file",867        },868    ]869    expected = [870        {871            "type": "file",872            "file": {873                "filename": "my_file",874                "file_data": "data:application/pdf;base64,<base64 data>",875            },876        }877    ]878    for content in contents:879        assert expected == _format_message_content([content])880881    # Test warn if PDF is missing a filename and that we add a default filename882    pdf_block = {883        "type": "file",884        "base64": "<base64 data>",885        "mime_type": "application/pdf",886    }887    expected = [888        {889            "type": "file",890            "file": {891                "file_data": "data:application/pdf;base64,<base64 data>",892                "filename": "LC_AUTOGENERATED",893            },894        }895    ]896    with pytest.warns(match="filename"):897        assert expected == _format_message_content([pdf_block])898899    contents = [900        {"type": "file", "source_type": "id", "id": "file-abc123"},901        {"type": "file", "file_id": "file-abc123"},902    ]903    expected = [{"type": "file", "file": {"file_id": "file-abc123"}}]904    for content in contents:905        assert expected == _format_message_content([content])906907908class GenerateUsername(BaseModel):909    "Get a username based on someone's name and hair color."910911    name: str912    hair_color: str913914915class MakeASandwich(BaseModel):916    "Make a sandwich given a list of ingredients."917918    bread_type: str919    cheese_type: str920    condiments: list[str]921    vegetables: list[str]922923924@pytest.mark.parametrize(925    "tool_choice",926    [927        "any",928        "none",929        "auto",930        "required",931        "GenerateUsername",932        {"type": "function", "function": {"name": "MakeASandwich"}},933        False,934        None,935    ],936)937@pytest.mark.parametrize("strict", [True, False, None])938def test_bind_tools_tool_choice(tool_choice: Any, strict: bool | None) -> None:939    """Test passing in manually construct tool call message."""940    llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0)941    llm.bind_tools(942        tools=[GenerateUsername, MakeASandwich], tool_choice=tool_choice, strict=strict943    )944945946@pytest.mark.parametrize(947    "schema", [GenerateUsername, GenerateUsername.model_json_schema()]948)949@pytest.mark.parametrize("method", ["json_schema", "function_calling", "json_mode"])950@pytest.mark.parametrize("include_raw", [True, False])951@pytest.mark.parametrize("strict", [True, False, None])952def test_with_structured_output(953    schema: type | dict[str, Any] | None,954    method: Literal["function_calling", "json_mode", "json_schema"],955    include_raw: bool,956    strict: bool | None,957) -> None:958    """Test passing in manually construct tool call message."""959    if method == "json_mode":960        strict = None961    llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0)962    llm.with_structured_output(963        schema, method=method, strict=strict, include_raw=include_raw964    )965966967def test_get_num_tokens_from_messages() -> None:968    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)969    messages = [970        SystemMessage("you're a good assistant"),971        HumanMessage("how are you"),972        HumanMessage(973            [974                {"type": "text", "text": "what's in this image"},975                {"type": "image_url", "image_url": {"url": "https://foobar.com"}},976                {977                    "type": "image_url",978                    "image_url": {"url": "https://foobar.com", "detail": "low"},979                },980            ]981        ),982        AIMessage("a nice bird"),983        AIMessage(984            "",985            tool_calls=[986                ToolCall(id="foo", name="bar", args={"arg1": "arg1"}, type="tool_call")987            ],988        ),989        AIMessage(990            "",991            additional_kwargs={992                "function_call": {993                    "arguments": json.dumps({"arg1": "arg1"}),994                    "name": "fun",995                }996            },997        ),998        AIMessage(999            "text",1000            tool_calls=[1001                ToolCall(id="foo", name="bar", args={"arg1": "arg1"}, type="tool_call")1002            ],1003        ),1004        ToolMessage("foobar", tool_call_id="foo"),1005    ]1006    expected = 431  # Updated to match token count with mocked 100x100 image10071008    # Mock _url_to_size to avoid PIL dependency in unit tests1009    with patch("langchain_openai.chat_models.base._url_to_size") as mock_url_to_size:1010        mock_url_to_size.return_value = (100, 100)  # 100x100 pixel image1011        actual = llm.get_num_tokens_from_messages(messages)10121013    assert expected == actual10141015    # Test file inputs1016    messages = [1017        HumanMessage(1018            [1019                "Summarize this document.",1020                {1021                    "type": "file",1022                    "file": {1023                        "filename": "my file",1024                        "file_data": "data:application/pdf;base64,<data>",1025                    },1026                },1027            ]1028        )1029    ]1030    actual = 01031    with pytest.warns(match="file inputs are not supported"):1032        actual = llm.get_num_tokens_from_messages(messages)1033    assert actual == 1310341035    # Test Responses1036    messages = [1037        AIMessage(1038            [1039                {1040                    "type": "function_call",1041                    "name": "multiply",1042                    "arguments": '{"x":5,"y":4}',1043                    "call_id": "call_abc123",1044                    "id": "fc_abc123",1045                    "status": "completed",1046                },1047            ],1048            tool_calls=[1049                {1050                    "type": "tool_call",1051                    "name": "multiply",1052                    "args": {"x": 5, "y": 4},1053                    "id": "call_abc123",1054                }1055            ],1056        )1057    ]1058    actual = llm.get_num_tokens_from_messages(messages)1059    assert actual106010611062class Foo(BaseModel):1063    bar: int106410651066# class FooV1(BaseModelV1):1067#     bar: int106810691070@pytest.mark.parametrize(1071    "schema",1072    [1073        Foo1074        # FooV11075    ],1076)1077def test_schema_from_with_structured_output(schema: type) -> None:1078    """Test schema from with_structured_output."""10791080    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)10811082    structured_llm = llm.with_structured_output(1083        schema, method="json_schema", strict=True1084    )10851086    expected = {1087        "properties": {"bar": {"title": "Bar", "type": "integer"}},1088        "required": ["bar"],1089        "title": schema.__name__,1090        "type": "object",1091    }1092    output_schema = cast("type[BaseModel]", structured_llm.get_output_schema())1093    actual = output_schema.model_json_schema()1094    assert actual == expected109510961097def test__create_usage_metadata() -> None:1098    usage_metadata = {1099        "completion_tokens": 15,1100        "prompt_tokens_details": None,1101        "completion_tokens_details": None,1102        "prompt_tokens": 11,1103        "total_tokens": 26,1104    }1105    result = _create_usage_metadata(usage_metadata)1106    assert result == UsageMetadata(1107        output_tokens=15,1108        input_tokens=11,1109        total_tokens=26,1110        input_token_details={},1111        output_token_details={},1112    )111311141115def test__create_usage_metadata_zero_total_tokens() -> None:1116    """Test that explicit total_tokens=0 is preserved, not replaced by sum."""1117    usage_metadata = {1118        "prompt_tokens": 10,1119        "completion_tokens": 5,1120        "total_tokens": 0,1121        "prompt_tokens_details": None,1122        "completion_tokens_details": None,1123    }1124    result = _create_usage_metadata(usage_metadata)1125    assert result["total_tokens"] == 0112611271128def test__create_usage_metadata_responses() -> None:1129    response_usage_metadata = {1130        "input_tokens": 100,1131        "input_tokens_details": {"cached_tokens": 50},1132        "output_tokens": 50,1133        "output_tokens_details": {"reasoning_tokens": 10},1134        "total_tokens": 150,1135    }1136    result = _create_usage_metadata_responses(response_usage_metadata)11371138    assert result == UsageMetadata(1139        output_tokens=50,1140        input_tokens=100,1141        total_tokens=150,1142        input_token_details={"cache_read": 50},1143        output_token_details={"reasoning": 10},1144    )114511461147def test__resize_caps_dimensions_preserving_ratio() -> None:1148    """Larger side capped at 2048 then smaller at 768 keeping aspect ratio."""1149    assert _resize(2048, 4096) == (768, 1536)1150    assert _resize(4096, 2048) == (1536, 768)115111521153def test__convert_to_openai_response_format() -> None:1154    # Test response formats that aren't tool-like.1155    response_format: dict = {1156        "type": "json_schema",1157        "json_schema": {1158            "name": "math_reasoning",1159            "schema": {1160                "type": "object",1161                "properties": {1162                    "steps": {1163                        "type": "array",1164                        "items": {1165                            "type": "object",1166                            "properties": {1167                                "explanation": {"type": "string"},1168                                "output": {"type": "string"},1169                            },1170                            "required": ["explanation", "output"],1171                            "additionalProperties": False,1172                        },1173                    },1174                    "final_answer": {"type": "string"},1175                },1176                "required": ["steps", "final_answer"],1177                "additionalProperties": False,1178            },1179            "strict": True,1180        },1181    }11821183    actual = _convert_to_openai_response_format(response_format)1184    assert actual == response_format11851186    actual = _convert_to_openai_response_format(response_format["json_schema"])1187    assert actual == response_format11881189    actual = _convert_to_openai_response_format(response_format, strict=True)1190    assert actual == response_format11911192    with pytest.raises(ValueError):1193        _convert_to_openai_response_format(response_format, strict=False)119411951196@pytest.mark.parametrize("method", ["function_calling", "json_schema"])1197@pytest.mark.parametrize("strict", [True, None])1198def test_structured_output_strict(1199    method: Literal["function_calling", "json_schema"], strict: bool | None1200) -> None:1201    """Test to verify structured output with strict=True."""12021203    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)12041205    class Joke(BaseModel):1206        """Joke to tell user."""12071208        setup: str = Field(description="question to set up a joke")1209        punchline: str = Field(description="answer to resolve the joke")12101211    llm.with_structured_output(Joke, method=method, strict=strict)1212    # Schema1213    llm.with_structured_output(Joke.model_json_schema(), method=method, strict=strict)121412151216def test_nested_structured_output_strict() -> None:1217    """Test to verify structured output with strict=True for nested object."""12181219    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)12201221    class SelfEvaluation(TypedDict):1222        score: int1223        text: str12241225    class JokeWithEvaluation(TypedDict):1226        """Joke to tell user."""12271228        setup: str1229        punchline: str1230        _evaluation: SelfEvaluation12311232    llm.with_structured_output(JokeWithEvaluation, method="json_schema")123312341235def test__get_request_payload() -> None:1236    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)1237    messages: list = [1238        SystemMessage("hello"),1239        SystemMessage("bye", additional_kwargs={"__openai_role__": "developer"}),1240        SystemMessage(content=[{"type": "text", "text": "hello!"}]),1241        {"role": "human", "content": "how are you"},1242        {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1243    ]1244    expected = {1245        "messages": [1246            {"role": "system", "content": "hello"},1247            {"role": "developer", "content": "bye"},1248            {"role": "system", "content": [{"type": "text", "text": "hello!"}]},1249            {"role": "user", "content": "how are you"},1250            {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1251        ],1252        "model": OPENAI_TEST_MODEL,1253        "stream": False,1254    }1255    payload = llm._get_request_payload(messages)1256    assert payload == expected12571258    # Test we coerce to developer role for o-series models1259    llm = ChatOpenAI(model="o3")1260    payload = llm._get_request_payload(messages)1261    expected = {1262        "messages": [1263            {"role": "developer", "content": "hello"},1264            {"role": "developer", "content": "bye"},1265            {"role": "developer", "content": [{"type": "text", "text": "hello!"}]},1266            {"role": "user", "content": "how are you"},1267            {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1268        ],1269        "model": "o3",1270        "stream": False,1271    }1272    assert payload == expected12731274    # Test we ignore reasoning blocks from other providers1275    reasoning_messages: list = [1276        {1277            "role": "user",1278            "content": [1279                {"type": "reasoning_content", "reasoning_content": "reasoning..."},1280                {"type": "text", "text": "reasoned response"},1281            ],1282        },1283        {1284            "role": "user",1285            "content": [1286                {"type": "thinking", "thinking": "thinking..."},1287                {"type": "text", "text": "thoughtful response"},1288            ],1289        },1290    ]1291    expected = {1292        "messages": [1293            {1294                "role": "user",1295                "content": [{"type": "text", "text": "reasoned response"}],1296            },1297            {1298                "role": "user",1299                "content": [{"type": "text", "text": "thoughtful response"}],1300            },1301        ],1302        "model": "o3",1303        "stream": False,1304    }1305    payload = llm._get_request_payload(reasoning_messages)1306    assert payload == expected130713081309def test_sanitize_chat_completions_text_blocks() -> None:1310    messages = [1311        ToolMessage(1312            content=[{"type": "text", "text": "foo", "id": "lc_abc123"}],1313            tool_call_id="def456",1314        ),1315    ]1316    payload = ChatOpenAI(model="gpt-5.2")._get_request_payload(messages)1317    assert payload["messages"] == [1318        {1319            "content": [{"type": "text", "text": "foo"}],1320            "role": "tool",1321            "tool_call_id": "def456",1322        }1323    ]132413251326def test_init_o1() -> None:1327    with warnings.catch_warnings(record=True) as record:1328        warnings.simplefilter("error")  # Treat warnings as errors1329        ChatOpenAI(model=OPENAI_TEST_MODEL, reasoning_effort="medium")13301331    assert len(record) == 0133213331334def test_init_minimal_reasoning_effort() -> None:1335    with warnings.catch_warnings(record=True) as record:1336        warnings.simplefilter("error")1337        ChatOpenAI(model="gpt-5", reasoning_effort="minimal")13381339    assert len(record) == 0134013411342@pytest.mark.parametrize("use_responses_api", [False, True])1343@pytest.mark.parametrize("use_max_completion_tokens", [True, False])1344def test_minimal_reasoning_effort_payload(1345    use_max_completion_tokens: bool, use_responses_api: bool1346) -> None:1347    """Test that minimal reasoning effort is included in request payload."""1348    if use_max_completion_tokens:1349        kwargs = {"max_completion_tokens": 100}1350    else:1351        kwargs = {"max_tokens": 100}13521353    init_kwargs: dict[str, Any] = {1354        "model": "gpt-5",1355        "reasoning_effort": "minimal",1356        "use_responses_api": use_responses_api,1357        **kwargs,1358    }13591360    llm = ChatOpenAI(**init_kwargs)13611362    messages = [1363        {"role": "developer", "content": "respond with just 'test'"},1364        {"role": "user", "content": "hello"},1365    ]13661367    payload = llm._get_request_payload(messages, stop=None)13681369    # When using responses API, reasoning_effort becomes reasoning.effort1370    if use_responses_api:1371        assert "reasoning" in payload1372        assert payload["reasoning"]["effort"] == "minimal"1373        # For responses API, tokens param becomes max_output_tokens1374        assert payload["max_output_tokens"] == 1001375    else:1376        # For non-responses API, reasoning_effort remains as is1377        assert payload["reasoning_effort"] == "minimal"1378        if use_max_completion_tokens:1379            assert payload["max_completion_tokens"] == 1001380        else:1381            # max_tokens gets converted to max_completion_tokens in non-responses API1382            assert payload["max_completion_tokens"] == 100138313841385def test_output_version_compat() -> None:1386    llm = ChatOpenAI(model="gpt-5", output_version="responses/v1")1387    assert llm._use_responses_api({}) is True138813891390def test_convert_chunk_to_generation_chunk_v1_keeps_string_content() -> None:1391    """v1 streaming keeps content as '' (not []) and stamps output_version.13921393    Covers both the usage-only (empty-choices) chunk and a content-bearing1394    chunk carrying a tool-call delta; the latter pins the per-content-chunk1395    `output_version` propagation.1396    """1397    llm = ChatOpenAI(model="gpt-4o", output_version="v1")13981399    # Empty-choices chunk (usage-only)1400    empty_chunk: dict[str, Any] = {1401        "id": "chatcmpl-test",1402        "object": "chat.completion.chunk",1403        "created": 0,1404        "model": "gpt-4o",1405        "choices": [],1406        "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},1407    }1408    gen = llm._convert_chunk_to_generation_chunk(empty_chunk, AIMessageChunk, None)1409    assert gen is not None1410    assert gen.message.content == ""  # NOT []1411    assert gen.message.response_metadata.get("output_version") == "v1"14121413    # Content-bearing chunk with tool_call delta1414    tool_chunk: dict[str, Any] = {1415        "id": "chatcmpl-test",1416        "object": "chat.completion.chunk",1417        "created": 0,1418        "model": "gpt-4o",1419        "choices": [1420            {1421                "index": 0,1422                "delta": {1423                    "role": "assistant",1424                    "content": "",1425                    "tool_calls": [1426                        {1427                            "index": 0,1428                            "id": "call_abc",1429                            "function": {"name": "get_weather", "arguments": ""},1430                        }1431                    ],1432                },1433                "logprobs": None,1434                "finish_reason": None,1435            }1436        ],1437        "usage": None,1438    }1439    gen = llm._convert_chunk_to_generation_chunk(tool_chunk, AIMessageChunk, None)1440    assert gen is not None1441    assert isinstance(gen.message.content, str)1442    assert gen.message.response_metadata.get("output_version") == "v1"1443    assert gen.message.response_metadata.get("model_provider") == "openai"144414451446def test_v1_streaming_tool_calls_in_content_blocks() -> None:1447    """End-to-end: streaming chunks with tool calls produce correct content_blocks."""1448    stream_chunks: list[dict[str, Any]] = [1449        # Initial empty-choices chunk1450        {1451            "id": "chatcmpl-test",1452            "object": "chat.completion.chunk",1453            "created": 0,1454            "model": "gpt-4o",1455            "choices": [1456                {1457                    "index": 0,1458                    "delta": {"role": "assistant", "content": ""},1459                    "logprobs": None,1460                    "finish_reason": None,1461                }1462            ],1463            "usage": None,1464        },1465        # Text token streamed before the tool call1466        {1467            "id": "chatcmpl-test",1468            "object": "chat.completion.chunk",1469            "created": 0,1470            "model": "gpt-4o",1471            "choices": [1472                {1473                    "index": 0,1474                    "delta": {"content": "Let me check the weather."},1475                    "logprobs": None,1476                    "finish_reason": None,1477                }1478            ],1479            "usage": None,1480        },1481        # Tool call start1482        {1483            "id": "chatcmpl-test",1484            "object": "chat.completion.chunk",1485            "created": 0,1486            "model": "gpt-4o",1487            "choices": [1488                {1489                    "index": 0,1490                    "delta": {1491                        "tool_calls": [1492                            {1493                                "index": 0,1494                                "id": "call_abc",1495                                "function": {1496                                    "name": "get_weather",1497                                    "arguments": '{"loc',1498                                },1499                            }1500                        ]1501                    },1502                    "logprobs": None,1503                    "finish_reason": None,1504                }1505            ],1506            "usage": None,1507        },1508        # Tool call args continuation1509        {1510            "id": "chatcmpl-test",1511            "object": "chat.completion.chunk",1512            "created": 0,1513            "model": "gpt-4o",1514            "choices": [1515                {1516                    "index": 0,1517                    "delta": {1518                        "tool_calls": [1519                            {1520                                "index": 0,1521                                "function": {"arguments": 'ation": "SF"}'},1522                            }1523                        ]1524                    },1525                    "logprobs": None,1526                    "finish_reason": None,1527                }1528            ],1529            "usage": None,1530        },1531        # Finish1532        {1533            "id": "chatcmpl-test",1534            "object": "chat.completion.chunk",1535            "created": 0,1536            "model": "gpt-4o",1537            "choices": [1538                {1539                    "index": 0,1540                    "delta": {},1541                    "logprobs": None,1542                    "finish_reason": "tool_calls",1543                }1544            ],1545            "usage": None,1546        },1547        # Usage chunk1548        {1549            "id": "chatcmpl-test",1550            "object": "chat.completion.chunk",1551            "created": 0,1552            "model": "gpt-4o",1553            "choices": [],1554            "usage": {1555                "prompt_tokens": 10,1556                "completion_tokens": 5,1557                "total_tokens": 15,1558            },1559        },1560    ]15611562    llm = ChatOpenAI(model="gpt-4o", output_version="v1")15631564    aggregated: AIMessageChunk | None = None1565    for raw_chunk in stream_chunks:1566        gen = llm._convert_chunk_to_generation_chunk(raw_chunk, AIMessageChunk, None)1567        if gen is None:1568            continue1569        chunk = cast(AIMessageChunk, gen.message)1570        aggregated = chunk if aggregated is None else aggregated + chunk15711572    assert aggregated is not None1573    # Tool calls should be present1574    assert len(aggregated.tool_call_chunks) == 11575    assert aggregated.tool_call_chunks[0]["name"] == "get_weather"15761577    # While still a chunk, content_blocks should include both the streamed text1578    # and the in-progress tool_call_chunk (text deltas must survive alongside1579    # tool calls through the merge).1580    blocks = aggregated.content_blocks1581    block_types = {b["type"] for b in blocks}1582    assert "tool_call_chunk" in block_types1583    assert "text" in block_types15841585    # Once finalized into a non-chunk AIMessage, the in-progress tool_call_chunk1586    # resolves to a normalized v1 `tool_call` block with parsed args. This is1587    # the cross-provider view downstream consumers code against.1588    final = message_chunk_to_message(aggregated)1589    final_blocks = final.content_blocks1590    assert {"type": "text", "text": "Let me check the weather."} in final_blocks1591    assert {1592        "type": "tool_call",1593        "name": "get_weather",1594        "args": {"location": "SF"},1595        "id": "call_abc",1596    } in final_blocks159715981599def test_verbosity_parameter_payload() -> None:1600    """Test verbosity parameter is included in request payload for Responses API."""1601    llm = ChatOpenAI(model="gpt-5", verbosity="high", use_responses_api=True)16021603    messages = [{"role": "user", "content": "hello"}]1604    payload = llm._get_request_payload(messages, stop=None)16051606    assert payload["text"]["verbosity"] == "high"160716081609def test_structured_output_legacy_model() -> None:1610    class Output(TypedDict):1611        """output."""16121613        foo: str16141615    with pytest.warns(match="Cannot use method='json_schema'"):1616        llm = ChatOpenAI(model="gpt-3-legacy").with_structured_output(Output)1617    # assert tool calling was used instead of json_schema1618    assert "tools" in llm.steps[0].kwargs  # type: ignore1619    assert "response_format" not in llm.steps[0].kwargs  # type: ignore162016211622def test_structured_outputs_parser() -> None:1623    parsed_response = GenerateUsername(name="alice", hair_color="black")1624    llm_output = ChatGeneration(1625        message=AIMessage(1626            content='{"name": "alice", "hair_color": "black"}',1627            additional_kwargs={"parsed": parsed_response},1628        )1629    )1630    output_parser = RunnableLambda(1631        partial(_oai_structured_outputs_parser, schema=GenerateUsername)1632    )1633    serialized = dumps(llm_output)1634    deserialized = loads(serialized, allowed_objects=[ChatGeneration, AIMessage])1635    assert isinstance(deserialized, ChatGeneration)1636    result = output_parser.invoke(cast(AIMessage, deserialized.message))1637    assert result == parsed_response163816391640def test_create_chat_result_avoids_parsed_model_dump_warning() -> None:1641    class ModelOutput(BaseModel):1642        output: str16431644    class MockParsedMessage(openai.BaseModel):1645        role: Literal["assistant"] = "assistant"1646        content: str = '{"output": "Paris"}'1647        parsed: None = None1648        refusal: str | None = None16491650    class MockChoice(openai.BaseModel):1651        index: int = 01652        finish_reason: Literal["stop"] = "stop"1653        message: MockParsedMessage16541655    class MockChatCompletion(openai.BaseModel):1656        id: str = "chatcmpl-1"1657        object: str = "chat.completion"1658        created: int = 01659        model: str = OPENAI_TEST_MODEL1660        choices: list[MockChoice]1661        usage: dict[str, int] | None = None16621663    parsed_response = ModelOutput(output="Paris")1664    response = MockChatCompletion.model_construct(1665        choices=[1666            MockChoice.model_construct(1667                message=MockParsedMessage.model_construct(parsed=parsed_response)1668            )1669        ],1670        usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},1671    )16721673    llm = ChatOpenAI(model=OPENAI_TEST_MODEL)1674    with warnings.catch_warnings(record=True) as caught_warnings:1675        warnings.simplefilter("always")1676        result = llm._create_chat_result(response)16771678    warning_messages = [str(warning.message) for warning in caught_warnings]1679    assert not any("field_name='parsed'" in message for message in warning_messages)1680    assert result.generations[0].message.additional_kwargs["parsed"] == parsed_response168116821683def test_structured_outputs_parser_valid_falsy_response() -> None:1684    class LunchBox(BaseModel):1685        sandwiches: list[str]16861687        def __len__(self) -> int:1688            return len(self.sandwiches)16891690    # prepare a valid *but falsy* response object, an empty LunchBox1691    parsed_response = LunchBox(sandwiches=[])1692    assert len(parsed_response) == 01693    llm_output = AIMessage(1694        content='{"sandwiches": []}', additional_kwargs={"parsed": parsed_response}1695    )1696    output_parser = RunnableLambda(1697        partial(_oai_structured_outputs_parser, schema=LunchBox)1698    )1699    result = output_parser.invoke(llm_output)1700    assert result == parsed_response170117021703def test__construct_lc_result_from_responses_api_error_handling() -> None:1704    """Test that errors in the response are properly raised."""1705    response = Response(1706        id="resp_123",1707        created_at=1234567890,1708        model=OPENAI_TEST_MODEL,1709        object="response",1710        error=ResponseError(message="Test error", code="server_error"),1711        parallel_tool_calls=True,1712        tools=[],1713        tool_choice="auto",1714        output=[],1715    )17161717    with pytest.raises(ValueError) as excinfo:1718        _construct_lc_result_from_responses_api(response)17191720    assert "Test error" in str(excinfo.value)172117221723def test__construct_lc_result_from_responses_api_basic_text_response() -> None:1724    """Test a basic text response with no tools or special features."""1725    response = Response(1726        id="resp_123",1727        created_at=1234567890,1728        model=OPENAI_TEST_MODEL,1729        object="response",1730        parallel_tool_calls=True,1731        tools=[],1732        tool_choice="auto",1733        output=[1734            ResponseOutputMessage(1735                type="message",1736                id="msg_123",1737                content=[1738                    ResponseOutputText(1739                        type="output_text", text="Hello, world!", annotations=[]1740                    )1741                ],1742                role="assistant",1743                status="completed",1744            )1745        ],1746        usage=ResponseUsage(1747            input_tokens=10,1748            output_tokens=3,1749            total_tokens=13,1750            input_tokens_details=InputTokensDetails(cached_tokens=0),1751            output_tokens_details=OutputTokensDetails(reasoning_tokens=0),1752        ),1753    )17541755    # v01756    result = _construct_lc_result_from_responses_api(response, output_version="v0")17571758    assert isinstance(result, ChatResult)1759    assert len(result.generations) == 11760    assert isinstance(result.generations[0], ChatGeneration)1761    assert isinstance(result.generations[0].message, AIMessage)1762    assert result.generations[0].message.content == [1763        {"type": "text", "text": "Hello, world!", "annotations": []}1764    ]1765    assert result.generations[0].message.id == "msg_123"1766    assert result.generations[0].message.usage_metadata1767    assert result.generations[0].message.usage_metadata["input_tokens"] == 101768    assert result.generations[0].message.usage_metadata["output_tokens"] == 31769    assert result.generations[0].message.usage_metadata["total_tokens"] == 131770    assert result.generations[0].message.response_metadata["id"] == "resp_123"1771    assert (1772        result.generations[0].message.response_metadata["model_name"]1773        == OPENAI_TEST_MODEL1774    )17751776    # responses/v11777    result = _construct_lc_result_from_responses_api(response)1778    assert result.generations[0].message.content == [1779        {"type": "text", "text": "Hello, world!", "annotations": [], "id": "msg_123"}1780    ]1781    assert result.generations[0].message.id == "resp_123"1782    assert result.generations[0].message.response_metadata["id"] == "resp_123"178317841785def test__construct_lc_result_from_responses_api_multiple_text_blocks() -> None:1786    """Test a response with multiple text blocks."""1787    response = Response(1788        id="resp_123",1789        created_at=1234567890,1790        model=OPENAI_TEST_MODEL,1791        object="response",1792        parallel_tool_calls=True,1793        tools=[],1794        tool_choice="auto",1795        output=[1796            ResponseOutputMessage(1797                type="message",1798                id="msg_123",1799                content=[1800                    ResponseOutputText(1801                        type="output_text", text="First part", annotations=[]1802                    ),1803                    ResponseOutputText(1804                        type="output_text", text="Second part", annotations=[]1805                    ),1806                ],1807                role="assistant",1808                status="completed",1809            )1810        ],1811    )18121813    result = _construct_lc_result_from_responses_api(response, output_version="v0")18141815    assert len(result.generations[0].message.content) == 21816    assert result.generations[0].message.content == [1817        {"type": "text", "text": "First part", "annotations": []},1818        {"type": "text", "text": "Second part", "annotations": []},1819    ]182018211822def test__construct_lc_result_from_responses_api_multiple_messages() -> None:1823    """Test a response with multiple text blocks."""1824    response = Response(1825        id="resp_123",1826        created_at=1234567890,1827        model=OPENAI_TEST_MODEL,1828        object="response",1829        parallel_tool_calls=True,1830        tools=[],1831        tool_choice="auto",1832        output=[1833            ResponseOutputMessage(1834                type="message",1835                id="msg_123",1836                content=[1837                    ResponseOutputText(type="output_text", text="foo", annotations=[])1838                ],1839                role="assistant",1840                status="completed",1841            ),1842            ResponseReasoningItem(1843                type="reasoning",1844                id="rs_123",1845                summary=[Summary(type="summary_text", text="reasoning foo")],1846            ),1847            ResponseOutputMessage(1848                type="message",1849                id="msg_234",1850                content=[1851                    ResponseOutputText(type="output_text", text="bar", annotations=[])1852                ],1853                role="assistant",1854                status="completed",1855            ),1856        ],1857    )18581859    # v01860    result = _construct_lc_result_from_responses_api(response, output_version="v0")18611862    assert result.generations[0].message.content == [1863        {"type": "text", "text": "foo", "annotations": []},1864        {"type": "text", "text": "bar", "annotations": []},1865    ]1866    assert result.generations[0].message.additional_kwargs == {1867        "reasoning": {1868            "type": "reasoning",1869            "summary": [{"type": "summary_text", "text": "reasoning foo"}],1870            "id": "rs_123",1871        }1872    }1873    assert result.generations[0].message.id == "msg_234"18741875    # responses/v11876    result = _construct_lc_result_from_responses_api(response)18771878    assert result.generations[0].message.content == [1879        {"type": "text", "text": "foo", "annotations": [], "id": "msg_123"},1880        {1881            "type": "reasoning",1882            "summary": [{"type": "summary_text", "text": "reasoning foo"}],1883            "id": "rs_123",1884        },1885        {"type": "text", "text": "bar", "annotations": [], "id": "msg_234"},1886    ]1887    assert result.generations[0].message.id == "resp_123"188818891890def test__construct_lc_result_from_responses_api_refusal_response() -> None:1891    """Test a response with a refusal."""1892    response = Response(1893        id="resp_123",1894        created_at=1234567890,1895        model=OPENAI_TEST_MODEL,1896        object="response",1897        parallel_tool_calls=True,1898        tools=[],1899        tool_choice="auto",1900        output=[1901            ResponseOutputMessage(1902                type="message",1903                id="msg_123",1904                content=[1905                    ResponseOutputRefusal(1906                        type="refusal", refusal="I cannot assist with that request."1907                    )1908                ],1909                role="assistant",1910                status="completed",1911            )1912        ],1913    )19141915    # v01916    result = _construct_lc_result_from_responses_api(response, output_version="v0")19171918    assert result.generations[0].message.additional_kwargs["refusal"] == (1919        "I cannot assist with that request."1920    )19211922    # responses/v11923    result = _construct_lc_result_from_responses_api(response)1924    assert result.generations[0].message.content == [1925        {1926            "type": "refusal",1927            "refusal": "I cannot assist with that request.",1928            "id": "msg_123",1929        }1930    ]193119321933def test__construct_lc_result_from_responses_api_function_call_valid_json() -> None:1934    """Test a response with a valid function call."""1935    response = Response(1936        id="resp_123",1937        created_at=1234567890,1938        model=OPENAI_TEST_MODEL,1939        object="response",1940        parallel_tool_calls=True,1941        tools=[],1942        tool_choice="auto",1943        output=[1944            ResponseFunctionToolCall(1945                type="function_call",1946                id="func_123",1947                call_id="call_123",1948                name="get_weather",1949                arguments='{"location": "New York", "unit": "celsius"}',1950            )1951        ],1952    )19531954    # v01955    result = _construct_lc_result_from_responses_api(response, output_version="v0")19561957    msg: AIMessage = cast(AIMessage, result.generations[0].message)1958    assert len(msg.tool_calls) == 11959    assert msg.tool_calls[0]["type"] == "tool_call"1960    assert msg.tool_calls[0]["name"] == "get_weather"1961    assert msg.tool_calls[0]["id"] == "call_123"1962    assert msg.tool_calls[0]["args"] == {"location": "New York", "unit": "celsius"}1963    assert _FUNCTION_CALL_IDS_MAP_KEY in result.generations[0].message.additional_kwargs1964    assert (1965        result.generations[0].message.additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY][1966            "call_123"1967        ]1968        == "func_123"1969    )19701971    # responses/v11972    result = _construct_lc_result_from_responses_api(response)1973    msg = cast(AIMessage, result.generations[0].message)1974    assert msg.tool_calls1975    assert msg.content == [1976        {1977            "type": "function_call",1978            "id": "func_123",1979            "name": "get_weather",1980            "arguments": '{"location": "New York", "unit": "celsius"}',1981            "call_id": "call_123",1982        }1983    ]198419851986def test__construct_lc_result_from_responses_api_function_call_invalid_json() -> None:1987    """Test a response with an invalid JSON function call."""1988    response = Response(1989        id="resp_123",1990        created_at=1234567890,1991        model=OPENAI_TEST_MODEL,1992        object="response",1993        parallel_tool_calls=True,1994        tools=[],1995        tool_choice="auto",1996        output=[1997            ResponseFunctionToolCall(1998                type="function_call",1999                id="func_123",2000                call_id="call_123",

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.