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.