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)28from langchain_core.messages import content as types29from langchain_core.messages.ai import UsageMetadata30from langchain_core.messages.block_translators.openai import (31 _convert_from_v03_ai_message,32)33from langchain_core.outputs import ChatGeneration, ChatResult34from langchain_core.runnables import RunnableLambda35from langchain_core.runnables.base import RunnableBinding, RunnableSequence36from langchain_core.tracers.base import BaseTracer37from langchain_core.tracers.schemas import Run38from openai.types.responses import ResponseOutputMessage, ResponseReasoningItem39from openai.types.responses.response import IncompleteDetails, Response40from openai.types.responses.response_error import ResponseError41from openai.types.responses.response_file_search_tool_call import (42 ResponseFileSearchToolCall,43 Result,44)45from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall46from openai.types.responses.response_function_web_search import (47 ActionSearch,48 ResponseFunctionWebSearch,49)50from openai.types.responses.response_output_refusal import ResponseOutputRefusal51from openai.types.responses.response_output_text import ResponseOutputText52from openai.types.responses.response_reasoning_item import Summary53from openai.types.responses.response_usage import (54 InputTokensDetails,55 OutputTokensDetails,56 ResponseUsage,57)58from pydantic import BaseModel, Field, SecretStr59from typing_extensions import Self, TypedDict6061from langchain_openai import ChatOpenAI62from langchain_openai.chat_models._compat import (63 _FUNCTION_CALL_IDS_MAP_KEY,64 _convert_from_v1_to_chat_completions,65 _convert_from_v1_to_responses,66 _convert_to_v03_ai_message,67)68from langchain_openai.chat_models.base import (69 OpenAIRefusalError,70 _construct_lc_result_from_responses_api,71 _construct_responses_api_input,72 _convert_dict_to_message,73 _convert_message_to_dict,74 _convert_to_openai_response_format,75 _create_usage_metadata,76 _create_usage_metadata_responses,77 _format_message_content,78 _get_last_messages,79 _make_computer_call_output_from_message,80 _model_prefers_responses_api,81 _oai_structured_outputs_parser,82 _resize,83)848586def test_openai_model_param() -> None:87 llm = ChatOpenAI(model="foo")88 assert llm.model_name == "foo"89 assert llm.model == "foo"90 llm = ChatOpenAI(model_name="foo") # type: ignore[call-arg]91 assert llm.model_name == "foo"92 assert llm.model == "foo"9394 llm = ChatOpenAI(max_tokens=10) # type: ignore[call-arg]95 assert llm.max_tokens == 1096 llm = ChatOpenAI(max_completion_tokens=10)97 assert llm.max_tokens == 109899100@pytest.mark.parametrize("async_api", [True, False])101def test_streaming_attribute_should_stream(async_api: bool) -> None:102 llm = ChatOpenAI(model="foo", streaming=True)103 assert llm._should_stream(async_api=async_api)104105106def test_openai_client_caching() -> None:107 """Test that the OpenAI client is cached."""108 llm1 = ChatOpenAI(model="gpt-4.1-mini")109 llm2 = ChatOpenAI(model="gpt-4.1-mini")110 assert llm1.root_client._client is llm2.root_client._client111112 llm3 = ChatOpenAI(model="gpt-4.1-mini", base_url="foo")113 assert llm1.root_client._client is not llm3.root_client._client114115 llm4 = ChatOpenAI(model="gpt-4.1-mini", timeout=None)116 assert llm1.root_client._client is llm4.root_client._client117118 llm5 = ChatOpenAI(model="gpt-4.1-mini", timeout=3)119 assert llm1.root_client._client is not llm5.root_client._client120121 llm6 = ChatOpenAI(122 model="gpt-4.1-mini", timeout=httpx.Timeout(timeout=60.0, connect=5.0)123 )124 assert llm1.root_client._client is not llm6.root_client._client125126 llm7 = ChatOpenAI(model="gpt-4.1-mini", timeout=(5, 1))127 assert llm1.root_client._client is not llm7.root_client._client128129130def test_profile() -> None:131 model = ChatOpenAI(model="gpt-4")132 assert model.profile133 assert not model.profile["structured_output"]134135 model = ChatOpenAI(model="gpt-5")136 assert model.profile137 assert model.profile["structured_output"]138 assert model.profile["tool_calling"]139140 # Test overwriting a field141 model.profile["tool_calling"] = False142 assert not model.profile["tool_calling"]143144 # Test we didn't mutate145 model = ChatOpenAI(model="gpt-5")146 assert model.profile147 assert model.profile["tool_calling"]148149 # Test passing in profile150 model = ChatOpenAI(model="gpt-5", profile={"tool_calling": False})151 assert model.profile == {"tool_calling": False}152153 # Test overrides for gpt-5 input tokens154 model = ChatOpenAI(model="gpt-5")155 assert model.profile["max_input_tokens"] == 272_000156157158def test_openai_o1_temperature() -> None:159 llm = ChatOpenAI(model="o1-preview")160 assert llm.temperature == 1161 llm = ChatOpenAI(model_name="o1-mini") # type: ignore[call-arg]162 assert llm.temperature == 1163164165def test_function_message_dict_to_function_message() -> None:166 content = json.dumps({"result": "Example #1"})167 name = "test_function"168 result = _convert_dict_to_message(169 {"role": "function", "name": name, "content": content}170 )171 assert isinstance(result, FunctionMessage)172 assert result.name == name173 assert result.content == content174175176def test__convert_dict_to_message_human() -> None:177 message = {"role": "user", "content": "foo"}178 result = _convert_dict_to_message(message)179 expected_output = HumanMessage(content="foo")180 assert result == expected_output181 assert _convert_message_to_dict(expected_output) == message182183184def test__convert_dict_to_message_human_with_name() -> None:185 message = {"role": "user", "content": "foo", "name": "test"}186 result = _convert_dict_to_message(message)187 expected_output = HumanMessage(content="foo", name="test")188 assert result == expected_output189 assert _convert_message_to_dict(expected_output) == message190191192def test__convert_dict_to_message_ai() -> None:193 message = {"role": "assistant", "content": "foo"}194 result = _convert_dict_to_message(message)195 expected_output = AIMessage(content="foo")196 assert result == expected_output197 assert _convert_message_to_dict(expected_output) == message198199200def test__convert_dict_to_message_ai_with_name() -> None:201 message = {"role": "assistant", "content": "foo", "name": "test"}202 result = _convert_dict_to_message(message)203 expected_output = AIMessage(content="foo", name="test")204 assert result == expected_output205 assert _convert_message_to_dict(expected_output) == message206207208def test__convert_dict_to_message_system() -> None:209 message = {"role": "system", "content": "foo"}210 result = _convert_dict_to_message(message)211 expected_output = SystemMessage(content="foo")212 assert result == expected_output213 assert _convert_message_to_dict(expected_output) == message214215216def test__convert_dict_to_message_developer() -> None:217 message = {"role": "developer", "content": "foo"}218 result = _convert_dict_to_message(message)219 expected_output = SystemMessage(220 content="foo", additional_kwargs={"__openai_role__": "developer"}221 )222 assert result == expected_output223 assert _convert_message_to_dict(expected_output) == message224225226def test__convert_dict_to_message_system_with_name() -> None:227 message = {"role": "system", "content": "foo", "name": "test"}228 result = _convert_dict_to_message(message)229 expected_output = SystemMessage(content="foo", name="test")230 assert result == expected_output231 assert _convert_message_to_dict(expected_output) == message232233234def test__convert_dict_to_message_tool() -> None:235 message = {"role": "tool", "content": "foo", "tool_call_id": "bar"}236 result = _convert_dict_to_message(message)237 expected_output = ToolMessage(content="foo", tool_call_id="bar")238 assert result == expected_output239 assert _convert_message_to_dict(expected_output) == message240241242def test__convert_dict_to_message_tool_call() -> None:243 raw_tool_call = {244 "id": "call_wm0JY6CdwOMZ4eTxHWUThDNz",245 "function": {246 "arguments": '{"name": "Sally", "hair_color": "green"}',247 "name": "GenerateUsername",248 },249 "type": "function",250 }251 message = {"role": "assistant", "content": None, "tool_calls": [raw_tool_call]}252 result = _convert_dict_to_message(message)253 expected_output = AIMessage(254 content="",255 tool_calls=[256 ToolCall(257 name="GenerateUsername",258 args={"name": "Sally", "hair_color": "green"},259 id="call_wm0JY6CdwOMZ4eTxHWUThDNz",260 type="tool_call",261 )262 ],263 )264 assert result == expected_output265 assert _convert_message_to_dict(expected_output) == message266267 # Test malformed tool call268 raw_tool_calls: list = [269 {270 "id": "call_wm0JY6CdwOMZ4eTxHWUThDNz",271 "function": {"arguments": "oops", "name": "GenerateUsername"},272 "type": "function",273 },274 {275 "id": "call_abc123",276 "function": {277 "arguments": '{"name": "Sally", "hair_color": "green"}',278 "name": "GenerateUsername",279 },280 "type": "function",281 },282 ]283 raw_tool_calls = sorted(raw_tool_calls, key=lambda x: x["id"])284 message = {"role": "assistant", "content": None, "tool_calls": raw_tool_calls}285 result = _convert_dict_to_message(message)286 expected_output = AIMessage(287 content="",288 invalid_tool_calls=[289 InvalidToolCall(290 name="GenerateUsername",291 args="oops",292 id="call_wm0JY6CdwOMZ4eTxHWUThDNz",293 error=(294 "Function GenerateUsername arguments:\n\noops\n\nare not "295 "valid JSON. Received JSONDecodeError Expecting value: line 1 "296 "column 1 (char 0)\nFor troubleshooting, visit: https://docs"297 ".langchain.com/oss/python/langchain/errors/OUTPUT_PARSING_FAILURE "298 ),299 type="invalid_tool_call",300 )301 ],302 tool_calls=[303 ToolCall(304 name="GenerateUsername",305 args={"name": "Sally", "hair_color": "green"},306 id="call_abc123",307 type="tool_call",308 )309 ],310 )311 assert result == expected_output312 reverted_message_dict = _convert_message_to_dict(expected_output)313 reverted_message_dict["tool_calls"] = sorted(314 reverted_message_dict["tool_calls"], key=lambda x: x["id"]315 )316 assert reverted_message_dict == message317318319class MockAsyncContextManager:320 def __init__(self, chunk_list: list) -> None:321 self.current_chunk = 0322 self.chunk_list = chunk_list323 self.chunk_num = len(chunk_list)324325 async def __aenter__(self) -> Self:326 return self327328 async def __aexit__(329 self,330 exc_type: type[BaseException] | None,331 exc: BaseException | None,332 tb: TracebackType | None,333 ) -> None:334 pass335336 def __aiter__(self) -> MockAsyncContextManager:337 return self338339 async def __anext__(self) -> dict:340 if self.current_chunk < self.chunk_num:341 chunk = self.chunk_list[self.current_chunk]342 self.current_chunk += 1343 return chunk344 raise StopAsyncIteration345346347class MockSyncContextManager:348 def __init__(self, chunk_list: list) -> None:349 self.current_chunk = 0350 self.chunk_list = chunk_list351 self.chunk_num = len(chunk_list)352353 def __enter__(self) -> Self:354 return self355356 def __exit__(357 self,358 exc_type: type[BaseException] | None,359 exc: BaseException | None,360 tb: TracebackType | None,361 ) -> None:362 pass363364 def __iter__(self) -> MockSyncContextManager:365 return self366367 def __next__(self) -> dict:368 if self.current_chunk < self.chunk_num:369 chunk = self.chunk_list[self.current_chunk]370 self.current_chunk += 1371 return chunk372 raise StopIteration373374375GLM4_STREAM_META = """{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u4eba\u5de5\u667a\u80fd"}}]}376{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u52a9\u624b"}}]}377{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":","}}]}378{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u4f60\u53ef\u4ee5"}}]}379{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u53eb\u6211"}}]}380{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"AI"}}]}381{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u52a9\u624b"}}]}382{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"。"}}]}383{"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}}384[DONE]""" # noqa: E501385386387@pytest.fixture388def mock_glm4_completion() -> list:389 list_chunk_data = GLM4_STREAM_META.split("\n")390 result_list = []391 for msg in list_chunk_data:392 if msg != "[DONE]":393 result_list.append(json.loads(msg))394395 return result_list396397398async def test_glm4_astream(mock_glm4_completion: list) -> None:399 llm_name = "glm-4"400 llm = ChatOpenAI(model=llm_name, stream_usage=True)401 mock_client = AsyncMock()402403 async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:404 return MockAsyncContextManager(mock_glm4_completion)405406 mock_client.create = mock_create407 usage_chunk = mock_glm4_completion[-1]408409 usage_metadata: UsageMetadata | None = None410 with patch.object(llm, "async_client", mock_client):411 async for chunk in llm.astream("你的名字叫什么?只回答名字"):412 assert isinstance(chunk, AIMessageChunk)413 if chunk.usage_metadata is not None:414 usage_metadata = chunk.usage_metadata415416 assert usage_metadata is not None417418 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]419 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]420 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]421422423def test_glm4_stream(mock_glm4_completion: list) -> None:424 llm_name = "glm-4"425 llm = ChatOpenAI(model=llm_name, stream_usage=True)426 mock_client = MagicMock()427428 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:429 return MockSyncContextManager(mock_glm4_completion)430431 mock_client.create = mock_create432 usage_chunk = mock_glm4_completion[-1]433434 usage_metadata: UsageMetadata | None = None435 with patch.object(llm, "client", mock_client):436 for chunk in llm.stream("你的名字叫什么?只回答名字"):437 assert isinstance(chunk, AIMessageChunk)438 if chunk.usage_metadata is not None:439 usage_metadata = chunk.usage_metadata440441 assert usage_metadata is not None442443 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]444 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]445 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]446447448DEEPSEEK_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}449{"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}450{"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}451{"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}452{"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}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":"一个","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":"由","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":"深度","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":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}}465[DONE]""" # noqa: E501466467468@pytest.fixture469def mock_deepseek_completion() -> list[dict]:470 list_chunk_data = DEEPSEEK_STREAM_DATA.split("\n")471 result_list = []472 for msg in list_chunk_data:473 if msg != "[DONE]":474 result_list.append(json.loads(msg))475476 return result_list477478479async def test_deepseek_astream(mock_deepseek_completion: list) -> None:480 llm_name = "deepseek-chat"481 llm = ChatOpenAI(model=llm_name, stream_usage=True)482 mock_client = AsyncMock()483484 async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:485 return MockAsyncContextManager(mock_deepseek_completion)486487 mock_client.create = mock_create488 usage_chunk = mock_deepseek_completion[-1]489 usage_metadata: UsageMetadata | None = None490 with patch.object(llm, "async_client", mock_client):491 async for chunk in llm.astream("你的名字叫什么?只回答名字"):492 assert isinstance(chunk, AIMessageChunk)493 if chunk.usage_metadata is not None:494 usage_metadata = chunk.usage_metadata495496 assert usage_metadata is not None497498 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]499 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]500 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]501502503def test_deepseek_stream(mock_deepseek_completion: list) -> None:504 llm_name = "deepseek-chat"505 llm = ChatOpenAI(model=llm_name, stream_usage=True)506 mock_client = MagicMock()507508 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:509 return MockSyncContextManager(mock_deepseek_completion)510511 mock_client.create = mock_create512 usage_chunk = mock_deepseek_completion[-1]513 usage_metadata: UsageMetadata | None = None514 with patch.object(llm, "client", mock_client):515 for chunk in llm.stream("你的名字叫什么?只回答名字"):516 assert isinstance(chunk, AIMessageChunk)517 if chunk.usage_metadata is not None:518 usage_metadata = chunk.usage_metadata519520 assert usage_metadata is not None521522 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]523 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]524 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]525526527OPENAI_STREAM_DATA = """{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":null}528{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"content":"我是"},"logprobs":null,"finish_reason":null}],"usage":null}529{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"content":"助手"},"logprobs":null,"finish_reason":null}],"usage":null}530{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{"content":"。"},"logprobs":null,"finish_reason":null}],"usage":null}531{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_18cc0f1fa0","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null}532{"id":"chatcmpl-9nhARrdUiJWEMd5plwV1Gc9NCjb9M","object":"chat.completion.chunk","created":1721631035,"model":"gpt-4o-2024-05-13","system_fingerprint":"fp_18cc0f1fa0","choices":[],"usage":{"prompt_tokens":14,"completion_tokens":3,"total_tokens":17}}533[DONE]""" # noqa: E501534535536@pytest.fixture537def mock_openai_completion() -> list[dict]:538 list_chunk_data = OPENAI_STREAM_DATA.split("\n")539 result_list = []540 for msg in list_chunk_data:541 if msg != "[DONE]":542 result_list.append(json.loads(msg))543544 return result_list545546547async def test_openai_astream(mock_openai_completion: list) -> None:548 llm_name = "gpt-4o"549 llm = ChatOpenAI(model=llm_name)550 assert llm.stream_usage551 mock_client = AsyncMock()552553 async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:554 return MockAsyncContextManager(mock_openai_completion)555556 mock_client.create = mock_create557 usage_chunk = mock_openai_completion[-1]558 usage_metadata: UsageMetadata | None = None559 with patch.object(llm, "async_client", mock_client):560 async for chunk in llm.astream("你的名字叫什么?只回答名字"):561 assert isinstance(chunk, AIMessageChunk)562 if chunk.usage_metadata is not None:563 usage_metadata = chunk.usage_metadata564565 assert usage_metadata is not None566567 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]568 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]569 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]570571572def test_openai_stream(mock_openai_completion: list) -> None:573 llm_name = "gpt-4o"574 llm = ChatOpenAI(model=llm_name)575 assert llm.stream_usage576 mock_client = MagicMock()577578 call_kwargs = []579580 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:581 call_kwargs.append(kwargs)582 return MockSyncContextManager(mock_openai_completion)583584 mock_client.create = mock_create585 usage_chunk = mock_openai_completion[-1]586 usage_metadata: UsageMetadata | None = None587 with patch.object(llm, "client", mock_client):588 for chunk in llm.stream("你的名字叫什么?只回答名字"):589 assert isinstance(chunk, AIMessageChunk)590 if chunk.usage_metadata is not None:591 usage_metadata = chunk.usage_metadata592593 assert call_kwargs[-1]["stream_options"] == {"include_usage": True}594 assert usage_metadata is not None595 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]596 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]597 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]598599 # Verify no streaming outside of default base URL or clients600 for param, value in {601 "stream_usage": False,602 "openai_proxy": "http://localhost:7890",603 "openai_api_base": "https://example.com/v1",604 "base_url": "https://example.com/v1",605 "client": mock_client,606 "root_client": mock_client,607 "async_client": mock_client,608 "root_async_client": mock_client,609 "http_client": httpx.Client(),610 "http_async_client": httpx.AsyncClient(),611 }.items():612 llm = ChatOpenAI(model=llm_name, **{param: value}) # type: ignore[arg-type]613 assert not llm.stream_usage614 with patch.object(llm, "client", mock_client):615 _ = list(llm.stream("..."))616 assert "stream_options" not in call_kwargs[-1]617618619def test_openai_stream_events_v3_lifecycle(mock_openai_completion: list) -> None:620 """`stream_events(version="v3")` on chat completions emits a valid lifecycle."""621 from langchain_tests.utils.stream_lifecycle import assert_valid_event_stream622623 llm = ChatOpenAI(model="gpt-4o")624 mock_client = MagicMock()625626 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:627 return MockSyncContextManager(mock_openai_completion)628629 mock_client.create = mock_create630 with patch.object(llm, "client", mock_client):631 events = list(llm.stream_events("你的名字叫什么?只回答名字", version="v3"))632633 assert_valid_event_stream(events)634 # At minimum, a text block with the accumulated answer.635 finishes = [e for e in events if e["event"] == "content-block-finish"]636 assert len(finishes) >= 1637 text_finishes = [f for f in finishes if f["content"]["type"] == "text"]638 assert len(text_finishes) == 1639640641@pytest.fixture642def mock_completion() -> dict:643 return {644 "id": "chatcmpl-7fcZavknQda3SQ",645 "object": "chat.completion",646 "created": 1689989000,647 "model": "gpt-3.5-turbo-0613",648 "choices": [649 {650 "index": 0,651 "message": {"role": "assistant", "content": "Bar Baz", "name": "Erick"},652 "finish_reason": "stop",653 }654 ],655 }656657658@pytest.fixture659def mock_client(mock_completion: dict) -> MagicMock:660 rtn = MagicMock()661662 mock_create = MagicMock()663664 mock_resp = MagicMock()665 mock_resp.headers = {"content-type": "application/json"}666 mock_resp.parse.return_value = mock_completion667 mock_create.return_value = mock_resp668669 rtn.with_raw_response.create = mock_create670 rtn.create.return_value = mock_completion671 return rtn672673674@pytest.fixture675def mock_async_client(mock_completion: dict) -> AsyncMock:676 rtn = AsyncMock()677678 mock_create = AsyncMock()679 mock_resp = MagicMock()680 mock_resp.parse.return_value = mock_completion681 mock_create.return_value = mock_resp682683 rtn.with_raw_response.create = mock_create684 rtn.create.return_value = mock_completion685 return rtn686687688def test_openai_invoke(mock_client: MagicMock) -> None:689 llm = ChatOpenAI()690691 with patch.object(llm, "client", mock_client):692 res = llm.invoke("bar")693 assert res.content == "Bar Baz"694695 # headers are not in response_metadata if include_response_headers not set696 assert "headers" not in res.response_metadata697 assert mock_client.with_raw_response.create.called698699700async def test_openai_ainvoke(mock_async_client: AsyncMock) -> None:701 llm = ChatOpenAI()702703 with patch.object(llm, "async_client", mock_async_client):704 res = await llm.ainvoke("bar")705 assert res.content == "Bar Baz"706707 # headers are not in response_metadata if include_response_headers not set708 assert "headers" not in res.response_metadata709 assert mock_async_client.with_raw_response.create.called710711712@pytest.mark.parametrize(713 "model",714 [715 "gpt-3.5-turbo",716 "gpt-4",717 "gpt-3.5-0125",718 "gpt-4-0125-preview",719 "gpt-4-turbo-preview",720 "gpt-4-vision-preview",721 ],722)723def test__get_encoding_model(model: str) -> None:724 ChatOpenAI(model=model)._get_encoding_model()725726727def test_openai_invoke_name(mock_client: MagicMock) -> None:728 llm = ChatOpenAI()729730 with patch.object(llm, "client", mock_client):731 messages = [HumanMessage(content="Foo", name="Katie")]732 res = llm.invoke(messages)733 call_args, call_kwargs = mock_client.with_raw_response.create.call_args734 assert len(call_args) == 0 # no positional args735 call_messages = call_kwargs["messages"]736 assert len(call_messages) == 1737 assert call_messages[0]["role"] == "user"738 assert call_messages[0]["content"] == "Foo"739 assert call_messages[0]["name"] == "Katie"740741 # check return type has name742 assert res.content == "Bar Baz"743 assert res.name == "Erick"744745746def test_function_calls_with_tool_calls(mock_client: MagicMock) -> None:747 # Test that we ignore function calls if tool_calls are present748 llm = ChatOpenAI(model="gpt-4.1-mini")749 tool_call_message = AIMessage(750 content="",751 additional_kwargs={752 "function_call": {753 "name": "get_weather",754 "arguments": '{"location": "Boston"}',755 }756 },757 tool_calls=[758 {759 "name": "get_weather",760 "args": {"location": "Boston"},761 "id": "abc123",762 "type": "tool_call",763 }764 ],765 )766 messages = [767 HumanMessage("What's the weather in Boston?"),768 tool_call_message,769 ToolMessage(content="It's sunny.", name="get_weather", tool_call_id="abc123"),770 ]771 with patch.object(llm, "client", mock_client):772 _ = llm.invoke(messages)773 _, call_kwargs = mock_client.with_raw_response.create.call_args774 call_messages = call_kwargs["messages"]775 tool_call_message_payload = call_messages[1]776 assert "tool_calls" in tool_call_message_payload777 assert "function_call" not in tool_call_message_payload778779 # Test we don't ignore function calls if tool_calls are not present780 cast(AIMessage, messages[1]).tool_calls = []781 with patch.object(llm, "client", mock_client):782 _ = llm.invoke(messages)783 _, call_kwargs = mock_client.with_raw_response.create.call_args784 call_messages = call_kwargs["messages"]785 tool_call_message_payload = call_messages[1]786 assert "function_call" in tool_call_message_payload787 assert "tool_calls" not in tool_call_message_payload788789790def test_custom_token_counting() -> None:791 def token_encoder(text: str) -> list[int]:792 return [1, 2, 3]793794 llm = ChatOpenAI(custom_get_token_ids=token_encoder)795 assert llm.get_token_ids("foo") == [1, 2, 3]796797798def test_format_message_content() -> None:799 content: Any = "hello"800 assert content == _format_message_content(content)801802 content = None803 assert content == _format_message_content(content)804805 content = []806 assert content == _format_message_content(content)807808 content = [809 {"type": "text", "text": "What is in this image?"},810 {"type": "image_url", "image_url": {"url": "url.com"}},811 ]812 assert content == _format_message_content(content)813814 content = [815 {"type": "text", "text": "hello"},816 {817 "type": "tool_use",818 "id": "toolu_01A09q90qw90lq917835lq9",819 "name": "get_weather",820 "input": {"location": "San Francisco, CA", "unit": "celsius"},821 },822 ]823 assert _format_message_content(content) == [{"type": "text", "text": "hello"}]824825 # Standard multi-modal inputs826 contents = [827 {"type": "image", "source_type": "url", "url": "https://..."}, # v0828 {"type": "image", "url": "https://..."}, # v1829 ]830 expected = [{"type": "image_url", "image_url": {"url": "https://..."}}]831 for content in contents:832 assert expected == _format_message_content([content])833834 contents = [835 {836 "type": "image",837 "source_type": "base64",838 "data": "<base64 data>",839 "mime_type": "image/png",840 },841 {"type": "image", "base64": "<base64 data>", "mime_type": "image/png"},842 ]843 expected = [844 {845 "type": "image_url",846 "image_url": {"url": "data:image/png;base64,<base64 data>"},847 }848 ]849 for content in contents:850 assert expected == _format_message_content([content])851852 contents = [853 {854 "type": "file",855 "source_type": "base64",856 "data": "<base64 data>",857 "mime_type": "application/pdf",858 "filename": "my_file",859 },860 {861 "type": "file",862 "base64": "<base64 data>",863 "mime_type": "application/pdf",864 "filename": "my_file",865 },866 ]867 expected = [868 {869 "type": "file",870 "file": {871 "filename": "my_file",872 "file_data": "data:application/pdf;base64,<base64 data>",873 },874 }875 ]876 for content in contents:877 assert expected == _format_message_content([content])878879 # Test warn if PDF is missing a filename and that we add a default filename880 pdf_block = {881 "type": "file",882 "base64": "<base64 data>",883 "mime_type": "application/pdf",884 }885 expected = [886 {887 "type": "file",888 "file": {889 "file_data": "data:application/pdf;base64,<base64 data>",890 "filename": "LC_AUTOGENERATED",891 },892 }893 ]894 with pytest.warns(match="filename"):895 assert expected == _format_message_content([pdf_block])896897 contents = [898 {"type": "file", "source_type": "id", "id": "file-abc123"},899 {"type": "file", "file_id": "file-abc123"},900 ]901 expected = [{"type": "file", "file": {"file_id": "file-abc123"}}]902 for content in contents:903 assert expected == _format_message_content([content])904905906class GenerateUsername(BaseModel):907 "Get a username based on someone's name and hair color."908909 name: str910 hair_color: str911912913class MakeASandwich(BaseModel):914 "Make a sandwich given a list of ingredients."915916 bread_type: str917 cheese_type: str918 condiments: list[str]919 vegetables: list[str]920921922@pytest.mark.parametrize(923 "tool_choice",924 [925 "any",926 "none",927 "auto",928 "required",929 "GenerateUsername",930 {"type": "function", "function": {"name": "MakeASandwich"}},931 False,932 None,933 ],934)935@pytest.mark.parametrize("strict", [True, False, None])936def test_bind_tools_tool_choice(tool_choice: Any, strict: bool | None) -> None:937 """Test passing in manually construct tool call message."""938 llm = ChatOpenAI(model="gpt-3.5-turbo-0125", temperature=0)939 llm.bind_tools(940 tools=[GenerateUsername, MakeASandwich], tool_choice=tool_choice, strict=strict941 )942943944@pytest.mark.parametrize(945 "schema", [GenerateUsername, GenerateUsername.model_json_schema()]946)947@pytest.mark.parametrize("method", ["json_schema", "function_calling", "json_mode"])948@pytest.mark.parametrize("include_raw", [True, False])949@pytest.mark.parametrize("strict", [True, False, None])950def test_with_structured_output(951 schema: type | dict[str, Any] | None,952 method: Literal["function_calling", "json_mode", "json_schema"],953 include_raw: bool,954 strict: bool | None,955) -> None:956 """Test passing in manually construct tool call message."""957 if method == "json_mode":958 strict = None959 llm = ChatOpenAI(model="gpt-3.5-turbo-0125", temperature=0)960 llm.with_structured_output(961 schema, method=method, strict=strict, include_raw=include_raw962 )963964965def test_get_num_tokens_from_messages() -> None:966 llm = ChatOpenAI(model="gpt-4o")967 messages = [968 SystemMessage("you're a good assistant"),969 HumanMessage("how are you"),970 HumanMessage(971 [972 {"type": "text", "text": "what's in this image"},973 {"type": "image_url", "image_url": {"url": "https://foobar.com"}},974 {975 "type": "image_url",976 "image_url": {"url": "https://foobar.com", "detail": "low"},977 },978 ]979 ),980 AIMessage("a nice bird"),981 AIMessage(982 "",983 tool_calls=[984 ToolCall(id="foo", name="bar", args={"arg1": "arg1"}, type="tool_call")985 ],986 ),987 AIMessage(988 "",989 additional_kwargs={990 "function_call": {991 "arguments": json.dumps({"arg1": "arg1"}),992 "name": "fun",993 }994 },995 ),996 AIMessage(997 "text",998 tool_calls=[999 ToolCall(id="foo", name="bar", args={"arg1": "arg1"}, type="tool_call")1000 ],1001 ),1002 ToolMessage("foobar", tool_call_id="foo"),1003 ]1004 expected = 431 # Updated to match token count with mocked 100x100 image10051006 # Mock _url_to_size to avoid PIL dependency in unit tests1007 with patch("langchain_openai.chat_models.base._url_to_size") as mock_url_to_size:1008 mock_url_to_size.return_value = (100, 100) # 100x100 pixel image1009 actual = llm.get_num_tokens_from_messages(messages)10101011 assert expected == actual10121013 # Test file inputs1014 messages = [1015 HumanMessage(1016 [1017 "Summarize this document.",1018 {1019 "type": "file",1020 "file": {1021 "filename": "my file",1022 "file_data": "data:application/pdf;base64,<data>",1023 },1024 },1025 ]1026 )1027 ]1028 actual = 01029 with pytest.warns(match="file inputs are not supported"):1030 actual = llm.get_num_tokens_from_messages(messages)1031 assert actual == 1310321033 # Test Responses1034 messages = [1035 AIMessage(1036 [1037 {1038 "type": "function_call",1039 "name": "multiply",1040 "arguments": '{"x":5,"y":4}',1041 "call_id": "call_abc123",1042 "id": "fc_abc123",1043 "status": "completed",1044 },1045 ],1046 tool_calls=[1047 {1048 "type": "tool_call",1049 "name": "multiply",1050 "args": {"x": 5, "y": 4},1051 "id": "call_abc123",1052 }1053 ],1054 )1055 ]1056 actual = llm.get_num_tokens_from_messages(messages)1057 assert actual105810591060class Foo(BaseModel):1061 bar: int106210631064# class FooV1(BaseModelV1):1065# bar: int106610671068@pytest.mark.parametrize(1069 "schema",1070 [1071 Foo1072 # FooV11073 ],1074)1075def test_schema_from_with_structured_output(schema: type) -> None:1076 """Test schema from with_structured_output."""10771078 llm = ChatOpenAI(model="gpt-4o")10791080 structured_llm = llm.with_structured_output(1081 schema, method="json_schema", strict=True1082 )10831084 expected = {1085 "properties": {"bar": {"title": "Bar", "type": "integer"}},1086 "required": ["bar"],1087 "title": schema.__name__,1088 "type": "object",1089 }1090 actual = structured_llm.get_output_schema().model_json_schema()1091 assert actual == expected109210931094def test__create_usage_metadata() -> None:1095 usage_metadata = {1096 "completion_tokens": 15,1097 "prompt_tokens_details": None,1098 "completion_tokens_details": None,1099 "prompt_tokens": 11,1100 "total_tokens": 26,1101 }1102 result = _create_usage_metadata(usage_metadata)1103 assert result == UsageMetadata(1104 output_tokens=15,1105 input_tokens=11,1106 total_tokens=26,1107 input_token_details={},1108 output_token_details={},1109 )111011111112def test__create_usage_metadata_zero_total_tokens() -> None:1113 """Test that explicit total_tokens=0 is preserved, not replaced by sum."""1114 usage_metadata = {1115 "prompt_tokens": 10,1116 "completion_tokens": 5,1117 "total_tokens": 0,1118 "prompt_tokens_details": None,1119 "completion_tokens_details": None,1120 }1121 result = _create_usage_metadata(usage_metadata)1122 assert result["total_tokens"] == 0112311241125def test__create_usage_metadata_responses() -> None:1126 response_usage_metadata = {1127 "input_tokens": 100,1128 "input_tokens_details": {"cached_tokens": 50},1129 "output_tokens": 50,1130 "output_tokens_details": {"reasoning_tokens": 10},1131 "total_tokens": 150,1132 }1133 result = _create_usage_metadata_responses(response_usage_metadata)11341135 assert result == UsageMetadata(1136 output_tokens=50,1137 input_tokens=100,1138 total_tokens=150,1139 input_token_details={"cache_read": 50},1140 output_token_details={"reasoning": 10},1141 )114211431144def test__resize_caps_dimensions_preserving_ratio() -> None:1145 """Larger side capped at 2048 then smaller at 768 keeping aspect ratio."""1146 assert _resize(2048, 4096) == (768, 1536)1147 assert _resize(4096, 2048) == (1536, 768)114811491150def test__convert_to_openai_response_format() -> None:1151 # Test response formats that aren't tool-like.1152 response_format: dict = {1153 "type": "json_schema",1154 "json_schema": {1155 "name": "math_reasoning",1156 "schema": {1157 "type": "object",1158 "properties": {1159 "steps": {1160 "type": "array",1161 "items": {1162 "type": "object",1163 "properties": {1164 "explanation": {"type": "string"},1165 "output": {"type": "string"},1166 },1167 "required": ["explanation", "output"],1168 "additionalProperties": False,1169 },1170 },1171 "final_answer": {"type": "string"},1172 },1173 "required": ["steps", "final_answer"],1174 "additionalProperties": False,1175 },1176 "strict": True,1177 },1178 }11791180 actual = _convert_to_openai_response_format(response_format)1181 assert actual == response_format11821183 actual = _convert_to_openai_response_format(response_format["json_schema"])1184 assert actual == response_format11851186 actual = _convert_to_openai_response_format(response_format, strict=True)1187 assert actual == response_format11881189 with pytest.raises(ValueError):1190 _convert_to_openai_response_format(response_format, strict=False)119111921193@pytest.mark.parametrize("method", ["function_calling", "json_schema"])1194@pytest.mark.parametrize("strict", [True, None])1195def test_structured_output_strict(1196 method: Literal["function_calling", "json_schema"], strict: bool | None1197) -> None:1198 """Test to verify structured output with strict=True."""11991200 llm = ChatOpenAI(model="gpt-4o-2024-08-06")12011202 class Joke(BaseModel):1203 """Joke to tell user."""12041205 setup: str = Field(description="question to set up a joke")1206 punchline: str = Field(description="answer to resolve the joke")12071208 llm.with_structured_output(Joke, method=method, strict=strict)1209 # Schema1210 llm.with_structured_output(Joke.model_json_schema(), method=method, strict=strict)121112121213def test_nested_structured_output_strict() -> None:1214 """Test to verify structured output with strict=True for nested object."""12151216 llm = ChatOpenAI(model="gpt-4o-2024-08-06")12171218 class SelfEvaluation(TypedDict):1219 score: int1220 text: str12211222 class JokeWithEvaluation(TypedDict):1223 """Joke to tell user."""12241225 setup: str1226 punchline: str1227 _evaluation: SelfEvaluation12281229 llm.with_structured_output(JokeWithEvaluation, method="json_schema")123012311232def test__get_request_payload() -> None:1233 llm = ChatOpenAI(model="gpt-4o-2024-08-06")1234 messages: list = [1235 SystemMessage("hello"),1236 SystemMessage("bye", additional_kwargs={"__openai_role__": "developer"}),1237 SystemMessage(content=[{"type": "text", "text": "hello!"}]),1238 {"role": "human", "content": "how are you"},1239 {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1240 ]1241 expected = {1242 "messages": [1243 {"role": "system", "content": "hello"},1244 {"role": "developer", "content": "bye"},1245 {"role": "system", "content": [{"type": "text", "text": "hello!"}]},1246 {"role": "user", "content": "how are you"},1247 {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1248 ],1249 "model": "gpt-4o-2024-08-06",1250 "stream": False,1251 }1252 payload = llm._get_request_payload(messages)1253 assert payload == expected12541255 # Test we coerce to developer role for o-series models1256 llm = ChatOpenAI(model="o3-mini")1257 payload = llm._get_request_payload(messages)1258 expected = {1259 "messages": [1260 {"role": "developer", "content": "hello"},1261 {"role": "developer", "content": "bye"},1262 {"role": "developer", "content": [{"type": "text", "text": "hello!"}]},1263 {"role": "user", "content": "how are you"},1264 {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1265 ],1266 "model": "o3-mini",1267 "stream": False,1268 }1269 assert payload == expected12701271 # Test we ignore reasoning blocks from other providers1272 reasoning_messages: list = [1273 {1274 "role": "user",1275 "content": [1276 {"type": "reasoning_content", "reasoning_content": "reasoning..."},1277 {"type": "text", "text": "reasoned response"},1278 ],1279 },1280 {1281 "role": "user",1282 "content": [1283 {"type": "thinking", "thinking": "thinking..."},1284 {"type": "text", "text": "thoughtful response"},1285 ],1286 },1287 ]1288 expected = {1289 "messages": [1290 {1291 "role": "user",1292 "content": [{"type": "text", "text": "reasoned response"}],1293 },1294 {1295 "role": "user",1296 "content": [{"type": "text", "text": "thoughtful response"}],1297 },1298 ],1299 "model": "o3-mini",1300 "stream": False,1301 }1302 payload = llm._get_request_payload(reasoning_messages)1303 assert payload == expected130413051306def test_sanitize_chat_completions_text_blocks() -> None:1307 messages = [1308 ToolMessage(1309 content=[{"type": "text", "text": "foo", "id": "lc_abc123"}],1310 tool_call_id="def456",1311 ),1312 ]1313 payload = ChatOpenAI(model="gpt-5.2")._get_request_payload(messages)1314 assert payload["messages"] == [1315 {1316 "content": [{"type": "text", "text": "foo"}],1317 "role": "tool",1318 "tool_call_id": "def456",1319 }1320 ]132113221323def test_init_o1() -> None:1324 with warnings.catch_warnings(record=True) as record:1325 warnings.simplefilter("error") # Treat warnings as errors1326 ChatOpenAI(model="o1", reasoning_effort="medium")13271328 assert len(record) == 0132913301331def test_init_minimal_reasoning_effort() -> None:1332 with warnings.catch_warnings(record=True) as record:1333 warnings.simplefilter("error")1334 ChatOpenAI(model="gpt-5", reasoning_effort="minimal")13351336 assert len(record) == 0133713381339@pytest.mark.parametrize("use_responses_api", [False, True])1340@pytest.mark.parametrize("use_max_completion_tokens", [True, False])1341def test_minimal_reasoning_effort_payload(1342 use_max_completion_tokens: bool, use_responses_api: bool1343) -> None:1344 """Test that minimal reasoning effort is included in request payload."""1345 if use_max_completion_tokens:1346 kwargs = {"max_completion_tokens": 100}1347 else:1348 kwargs = {"max_tokens": 100}13491350 init_kwargs: dict[str, Any] = {1351 "model": "gpt-5",1352 "reasoning_effort": "minimal",1353 "use_responses_api": use_responses_api,1354 **kwargs,1355 }13561357 llm = ChatOpenAI(**init_kwargs)13581359 messages = [1360 {"role": "developer", "content": "respond with just 'test'"},1361 {"role": "user", "content": "hello"},1362 ]13631364 payload = llm._get_request_payload(messages, stop=None)13651366 # When using responses API, reasoning_effort becomes reasoning.effort1367 if use_responses_api:1368 assert "reasoning" in payload1369 assert payload["reasoning"]["effort"] == "minimal"1370 # For responses API, tokens param becomes max_output_tokens1371 assert payload["max_output_tokens"] == 1001372 else:1373 # For non-responses API, reasoning_effort remains as is1374 assert payload["reasoning_effort"] == "minimal"1375 if use_max_completion_tokens:1376 assert payload["max_completion_tokens"] == 1001377 else:1378 # max_tokens gets converted to max_completion_tokens in non-responses API1379 assert payload["max_completion_tokens"] == 100138013811382def test_output_version_compat() -> None:1383 llm = ChatOpenAI(model="gpt-5", output_version="responses/v1")1384 assert llm._use_responses_api({}) is True138513861387def test_verbosity_parameter_payload() -> None:1388 """Test verbosity parameter is included in request payload for Responses API."""1389 llm = ChatOpenAI(model="gpt-5", verbosity="high", use_responses_api=True)13901391 messages = [{"role": "user", "content": "hello"}]1392 payload = llm._get_request_payload(messages, stop=None)13931394 assert payload["text"]["verbosity"] == "high"139513961397def test_structured_output_old_model() -> None:1398 class Output(TypedDict):1399 """output."""14001401 foo: str14021403 with pytest.warns(match="Cannot use method='json_schema'"):1404 llm = ChatOpenAI(model="gpt-4").with_structured_output(Output)1405 # assert tool calling was used instead of json_schema1406 assert "tools" in llm.steps[0].kwargs # type: ignore1407 assert "response_format" not in llm.steps[0].kwargs # type: ignore140814091410def test_structured_outputs_parser() -> None:1411 parsed_response = GenerateUsername(name="alice", hair_color="black")1412 llm_output = ChatGeneration(1413 message=AIMessage(1414 content='{"name": "alice", "hair_color": "black"}',1415 additional_kwargs={"parsed": parsed_response},1416 )1417 )1418 output_parser = RunnableLambda(1419 partial(_oai_structured_outputs_parser, schema=GenerateUsername)1420 )1421 serialized = dumps(llm_output)1422 deserialized = loads(serialized, allowed_objects=[ChatGeneration, AIMessage])1423 assert isinstance(deserialized, ChatGeneration)1424 result = output_parser.invoke(cast(AIMessage, deserialized.message))1425 assert result == parsed_response142614271428def test_create_chat_result_avoids_parsed_model_dump_warning() -> None:1429 class ModelOutput(BaseModel):1430 output: str14311432 class MockParsedMessage(openai.BaseModel):1433 role: Literal["assistant"] = "assistant"1434 content: str = '{"output": "Paris"}'1435 parsed: None = None1436 refusal: str | None = None14371438 class MockChoice(openai.BaseModel):1439 index: int = 01440 finish_reason: Literal["stop"] = "stop"1441 message: MockParsedMessage14421443 class MockChatCompletion(openai.BaseModel):1444 id: str = "chatcmpl-1"1445 object: str = "chat.completion"1446 created: int = 01447 model: str = "gpt-4o-mini"1448 choices: list[MockChoice]1449 usage: dict[str, int] | None = None14501451 parsed_response = ModelOutput(output="Paris")1452 response = MockChatCompletion.model_construct(1453 choices=[1454 MockChoice.model_construct(1455 message=MockParsedMessage.model_construct(parsed=parsed_response)1456 )1457 ],1458 usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},1459 )14601461 llm = ChatOpenAI(model="gpt-4o-mini")1462 with warnings.catch_warnings(record=True) as caught_warnings:1463 warnings.simplefilter("always")1464 result = llm._create_chat_result(response)14651466 warning_messages = [str(warning.message) for warning in caught_warnings]1467 assert not any("field_name='parsed'" in message for message in warning_messages)1468 assert result.generations[0].message.additional_kwargs["parsed"] == parsed_response146914701471def test_structured_outputs_parser_valid_falsy_response() -> None:1472 class LunchBox(BaseModel):1473 sandwiches: list[str]14741475 def __len__(self) -> int:1476 return len(self.sandwiches)14771478 # prepare a valid *but falsy* response object, an empty LunchBox1479 parsed_response = LunchBox(sandwiches=[])1480 assert len(parsed_response) == 01481 llm_output = AIMessage(1482 content='{"sandwiches": []}', additional_kwargs={"parsed": parsed_response}1483 )1484 output_parser = RunnableLambda(1485 partial(_oai_structured_outputs_parser, schema=LunchBox)1486 )1487 result = output_parser.invoke(llm_output)1488 assert result == parsed_response148914901491def test__construct_lc_result_from_responses_api_error_handling() -> None:1492 """Test that errors in the response are properly raised."""1493 response = Response(1494 id="resp_123",1495 created_at=1234567890,1496 model="gpt-4o",1497 object="response",1498 error=ResponseError(message="Test error", code="server_error"),1499 parallel_tool_calls=True,1500 tools=[],1501 tool_choice="auto",1502 output=[],1503 )15041505 with pytest.raises(ValueError) as excinfo:1506 _construct_lc_result_from_responses_api(response)15071508 assert "Test error" in str(excinfo.value)150915101511def test__construct_lc_result_from_responses_api_basic_text_response() -> None:1512 """Test a basic text response with no tools or special features."""1513 response = Response(1514 id="resp_123",1515 created_at=1234567890,1516 model="gpt-4o",1517 object="response",1518 parallel_tool_calls=True,1519 tools=[],1520 tool_choice="auto",1521 output=[1522 ResponseOutputMessage(1523 type="message",1524 id="msg_123",1525 content=[1526 ResponseOutputText(1527 type="output_text", text="Hello, world!", annotations=[]1528 )1529 ],1530 role="assistant",1531 status="completed",1532 )1533 ],1534 usage=ResponseUsage(1535 input_tokens=10,1536 output_tokens=3,1537 total_tokens=13,1538 input_tokens_details=InputTokensDetails(cached_tokens=0),1539 output_tokens_details=OutputTokensDetails(reasoning_tokens=0),1540 ),1541 )15421543 # v01544 result = _construct_lc_result_from_responses_api(response, output_version="v0")15451546 assert isinstance(result, ChatResult)1547 assert len(result.generations) == 11548 assert isinstance(result.generations[0], ChatGeneration)1549 assert isinstance(result.generations[0].message, AIMessage)1550 assert result.generations[0].message.content == [1551 {"type": "text", "text": "Hello, world!", "annotations": []}1552 ]1553 assert result.generations[0].message.id == "msg_123"1554 assert result.generations[0].message.usage_metadata1555 assert result.generations[0].message.usage_metadata["input_tokens"] == 101556 assert result.generations[0].message.usage_metadata["output_tokens"] == 31557 assert result.generations[0].message.usage_metadata["total_tokens"] == 131558 assert result.generations[0].message.response_metadata["id"] == "resp_123"1559 assert result.generations[0].message.response_metadata["model_name"] == "gpt-4o"15601561 # responses/v11562 result = _construct_lc_result_from_responses_api(response)1563 assert result.generations[0].message.content == [1564 {"type": "text", "text": "Hello, world!", "annotations": [], "id": "msg_123"}1565 ]1566 assert result.generations[0].message.id == "resp_123"1567 assert result.generations[0].message.response_metadata["id"] == "resp_123"156815691570def test__construct_lc_result_from_responses_api_multiple_text_blocks() -> None:1571 """Test a response with multiple text blocks."""1572 response = Response(1573 id="resp_123",1574 created_at=1234567890,1575 model="gpt-4o",1576 object="response",1577 parallel_tool_calls=True,1578 tools=[],1579 tool_choice="auto",1580 output=[1581 ResponseOutputMessage(1582 type="message",1583 id="msg_123",1584 content=[1585 ResponseOutputText(1586 type="output_text", text="First part", annotations=[]1587 ),1588 ResponseOutputText(1589 type="output_text", text="Second part", annotations=[]1590 ),1591 ],1592 role="assistant",1593 status="completed",1594 )1595 ],1596 )15971598 result = _construct_lc_result_from_responses_api(response, output_version="v0")15991600 assert len(result.generations[0].message.content) == 21601 assert result.generations[0].message.content == [1602 {"type": "text", "text": "First part", "annotations": []},1603 {"type": "text", "text": "Second part", "annotations": []},1604 ]160516061607def test__construct_lc_result_from_responses_api_multiple_messages() -> None:1608 """Test a response with multiple text blocks."""1609 response = Response(1610 id="resp_123",1611 created_at=1234567890,1612 model="gpt-4o",1613 object="response",1614 parallel_tool_calls=True,1615 tools=[],1616 tool_choice="auto",1617 output=[1618 ResponseOutputMessage(1619 type="message",1620 id="msg_123",1621 content=[1622 ResponseOutputText(type="output_text", text="foo", annotations=[])1623 ],1624 role="assistant",1625 status="completed",1626 ),1627 ResponseReasoningItem(1628 type="reasoning",1629 id="rs_123",1630 summary=[Summary(type="summary_text", text="reasoning foo")],1631 ),1632 ResponseOutputMessage(1633 type="message",1634 id="msg_234",1635 content=[1636 ResponseOutputText(type="output_text", text="bar", annotations=[])1637 ],1638 role="assistant",1639 status="completed",1640 ),1641 ],1642 )16431644 # v01645 result = _construct_lc_result_from_responses_api(response, output_version="v0")16461647 assert result.generations[0].message.content == [1648 {"type": "text", "text": "foo", "annotations": []},1649 {"type": "text", "text": "bar", "annotations": []},1650 ]1651 assert result.generations[0].message.additional_kwargs == {1652 "reasoning": {1653 "type": "reasoning",1654 "summary": [{"type": "summary_text", "text": "reasoning foo"}],1655 "id": "rs_123",1656 }1657 }1658 assert result.generations[0].message.id == "msg_234"16591660 # responses/v11661 result = _construct_lc_result_from_responses_api(response)16621663 assert result.generations[0].message.content == [1664 {"type": "text", "text": "foo", "annotations": [], "id": "msg_123"},1665 {1666 "type": "reasoning",1667 "summary": [{"type": "summary_text", "text": "reasoning foo"}],1668 "id": "rs_123",1669 },1670 {"type": "text", "text": "bar", "annotations": [], "id": "msg_234"},1671 ]1672 assert result.generations[0].message.id == "resp_123"167316741675def test__construct_lc_result_from_responses_api_refusal_response() -> None:1676 """Test a response with a refusal."""1677 response = Response(1678 id="resp_123",1679 created_at=1234567890,1680 model="gpt-4o",1681 object="response",1682 parallel_tool_calls=True,1683 tools=[],1684 tool_choice="auto",1685 output=[1686 ResponseOutputMessage(1687 type="message",1688 id="msg_123",1689 content=[1690 ResponseOutputRefusal(1691 type="refusal", refusal="I cannot assist with that request."1692 )1693 ],1694 role="assistant",1695 status="completed",1696 )1697 ],1698 )16991700 # v01701 result = _construct_lc_result_from_responses_api(response, output_version="v0")17021703 assert result.generations[0].message.additional_kwargs["refusal"] == (1704 "I cannot assist with that request."1705 )17061707 # responses/v11708 result = _construct_lc_result_from_responses_api(response)1709 assert result.generations[0].message.content == [1710 {1711 "type": "refusal",1712 "refusal": "I cannot assist with that request.",1713 "id": "msg_123",1714 }1715 ]171617171718def test__construct_lc_result_from_responses_api_function_call_valid_json() -> None:1719 """Test a response with a valid function call."""1720 response = Response(1721 id="resp_123",1722 created_at=1234567890,1723 model="gpt-4o",1724 object="response",1725 parallel_tool_calls=True,1726 tools=[],1727 tool_choice="auto",1728 output=[1729 ResponseFunctionToolCall(1730 type="function_call",1731 id="func_123",1732 call_id="call_123",1733 name="get_weather",1734 arguments='{"location": "New York", "unit": "celsius"}',1735 )1736 ],1737 )17381739 # v01740 result = _construct_lc_result_from_responses_api(response, output_version="v0")17411742 msg: AIMessage = cast(AIMessage, result.generations[0].message)1743 assert len(msg.tool_calls) == 11744 assert msg.tool_calls[0]["type"] == "tool_call"1745 assert msg.tool_calls[0]["name"] == "get_weather"1746 assert msg.tool_calls[0]["id"] == "call_123"1747 assert msg.tool_calls[0]["args"] == {"location": "New York", "unit": "celsius"}1748 assert _FUNCTION_CALL_IDS_MAP_KEY in result.generations[0].message.additional_kwargs1749 assert (1750 result.generations[0].message.additional_kwargs[_FUNCTION_CALL_IDS_MAP_KEY][1751 "call_123"1752 ]1753 == "func_123"1754 )17551756 # responses/v11757 result = _construct_lc_result_from_responses_api(response)1758 msg = cast(AIMessage, result.generations[0].message)1759 assert msg.tool_calls1760 assert msg.content == [1761 {1762 "type": "function_call",1763 "id": "func_123",1764 "name": "get_weather",1765 "arguments": '{"location": "New York", "unit": "celsius"}',1766 "call_id": "call_123",1767 }1768 ]176917701771def test__construct_lc_result_from_responses_api_function_call_invalid_json() -> None:1772 """Test a response with an invalid JSON function call."""1773 response = Response(1774 id="resp_123",1775 created_at=1234567890,1776 model="gpt-4o",1777 object="response",1778 parallel_tool_calls=True,1779 tools=[],1780 tool_choice="auto",1781 output=[1782 ResponseFunctionToolCall(1783 type="function_call",1784 id="func_123",1785 call_id="call_123",1786 name="get_weather",1787 arguments='{"location": "New York", "unit": "celsius"',1788 # Missing closing brace1789 )1790 ],1791 )17921793 result = _construct_lc_result_from_responses_api(response, output_version="v0")17941795 msg: AIMessage = cast(AIMessage, result.generations[0].message)1796 assert len(msg.invalid_tool_calls) == 11797 assert msg.invalid_tool_calls[0]["type"] == "invalid_tool_call"1798 assert msg.invalid_tool_calls[0]["name"] == "get_weather"1799 assert msg.invalid_tool_calls[0]["id"] == "call_123"1800 assert (1801 msg.invalid_tool_calls[0]["args"]1802 == '{"location": "New York", "unit": "celsius"'1803 )1804 assert "error" in msg.invalid_tool_calls[0]1805 assert _FUNCTION_CALL_IDS_MAP_KEY in result.generations[0].message.additional_kwargs180618071808def test__construct_lc_result_from_responses_api_complex_response() -> None:1809 """Test a complex response with multiple output types."""1810 response = Response(1811 id="resp_123",1812 created_at=1234567890,1813 model="gpt-4o",1814 object="response",1815 parallel_tool_calls=True,1816 tools=[],1817 tool_choice="auto",1818 output=[1819 ResponseOutputMessage(1820 type="message",1821 id="msg_123",1822 content=[1823 ResponseOutputText(1824 type="output_text",1825 text="Here's the information you requested:",1826 annotations=[],1827 )1828 ],1829 role="assistant",1830 status="completed",1831 ),1832 ResponseFunctionToolCall(1833 type="function_call",1834 id="func_123",1835 call_id="call_123",1836 name="get_weather",1837 arguments='{"location": "New York"}',1838 ),1839 ],1840 metadata={"key1": "value1", "key2": "value2"},1841 incomplete_details=IncompleteDetails(reason="max_output_tokens"),1842 status="completed",1843 user="user_123",1844 )18451846 # v01847 result = _construct_lc_result_from_responses_api(response, output_version="v0")18481849 # Check message content1850 assert result.generations[0].message.content == [1851 {1852 "type": "text",1853 "text": "Here's the information you requested:",1854 "annotations": [],1855 }1856 ]18571858 # Check tool calls1859 msg: AIMessage = cast(AIMessage, result.generations[0].message)1860 assert len(msg.tool_calls) == 11861 assert msg.tool_calls[0]["name"] == "get_weather"18621863 # Check metadata1864 assert result.generations[0].message.response_metadata["id"] == "resp_123"1865 assert result.generations[0].message.response_metadata["metadata"] == {1866 "key1": "value1",1867 "key2": "value2",1868 }1869 assert result.generations[0].message.response_metadata["incomplete_details"] == {1870 "reason": "max_output_tokens"1871 }1872 assert result.generations[0].message.response_metadata["status"] == "completed"1873 assert result.generations[0].message.response_metadata["user"] == "user_123"18741875 # responses/v11876 result = _construct_lc_result_from_responses_api(response)1877 msg = cast(AIMessage, result.generations[0].message)1878 assert msg.response_metadata["metadata"] == {"key1": "value1", "key2": "value2"}1879 assert msg.content == [1880 {1881 "type": "text",1882 "text": "Here's the information you requested:",1883 "annotations": [],1884 "id": "msg_123",1885 },1886 {1887 "type": "function_call",1888 "id": "func_123",1889 "call_id": "call_123",1890 "name": "get_weather",1891 "arguments": '{"location": "New York"}',1892 },1893 ]189418951896def test__construct_lc_result_from_responses_api_no_usage_metadata() -> None:1897 """Test a response without usage metadata."""1898 response = Response(1899 id="resp_123",1900 created_at=1234567890,1901 model="gpt-4o",1902 object="response",1903 parallel_tool_calls=True,1904 tools=[],1905 tool_choice="auto",1906 output=[1907 ResponseOutputMessage(1908 type="message",1909 id="msg_123",1910 content=[1911 ResponseOutputText(1912 type="output_text", text="Hello, world!", annotations=[]1913 )1914 ],1915 role="assistant",1916 status="completed",1917 )1918 ],1919 # No usage field1920 )19211922 result = _construct_lc_result_from_responses_api(response)19231924 assert cast(AIMessage, result.generations[0].message).usage_metadata is None192519261927def test__construct_lc_result_from_responses_api_web_search_response() -> None:1928 """Test a response with web search output."""1929 from openai.types.responses.response_function_web_search import (1930 ResponseFunctionWebSearch,1931 )19321933 response = Response(1934 id="resp_123",1935 created_at=1234567890,1936 model="gpt-4o",1937 object="response",1938 parallel_tool_calls=True,1939 tools=[],1940 tool_choice="auto",1941 output=[1942 ResponseFunctionWebSearch(1943 id="websearch_123",1944 type="web_search_call",1945 status="completed",1946 action=ActionSearch(type="search", query="search query"),1947 )1948 ],1949 )19501951 # v01952 result = _construct_lc_result_from_responses_api(response, output_version="v0")19531954 assert "tool_outputs" in result.generations[0].message.additional_kwargs1955 assert len(result.generations[0].message.additional_kwargs["tool_outputs"]) == 11956 assert (1957 result.generations[0].message.additional_kwargs["tool_outputs"][0]["type"]1958 == "web_search_call"1959 )1960 assert (1961 result.generations[0].message.additional_kwargs["tool_outputs"][0]["id"]1962 == "websearch_123"1963 )1964 assert (1965 result.generations[0].message.additional_kwargs["tool_outputs"][0]["status"]1966 == "completed"1967 )19681969 # responses/v11970 result = _construct_lc_result_from_responses_api(response)1971 assert result.generations[0].message.content == [1972 {1973 "type": "web_search_call",1974 "id": "websearch_123",1975 "status": "completed",1976 "action": {"query": "search query", "type": "search"},1977 }1978 ]197919801981def test__construct_lc_result_from_responses_api_file_search_response() -> None:1982 """Test a response with file search output."""1983 response = Response(1984 id="resp_123",1985 created_at=1234567890,1986 model="gpt-4o",1987 object="response",1988 parallel_tool_calls=True,1989 tools=[],1990 tool_choice="auto",1991 output=[1992 ResponseFileSearchToolCall(1993 id="filesearch_123",1994 type="file_search_call",1995 status="completed",1996 queries=["python code", "langchain"],1997 results=[1998 Result(1999 file_id="file_123",2000 filename="example.py",
Findings
✓ No findings reported for this file.