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 httpx214import openai15import pytest16from langchain_core.exceptions import (17 ContextOverflowError,18 ModelAPIError,19 ModelAuthenticationError,20 ModelConnectionError,21 ModelError,22 ModelInvalidRequestError,23 ModelNotFoundError,24 ModelPermissionDeniedError,25 ModelRateLimitError,26 ModelTimeoutError,27)28from langchain_core.load import dumps, loads29from langchain_core.messages import (30 AIMessage,31 AIMessageChunk,32 BaseMessage,33 FunctionMessage,34 HumanMessage,35 InvalidToolCall,36 SystemMessage,37 ToolCall,38 ToolMessage,39 message_chunk_to_message,40)41from langchain_core.messages import content as types42from langchain_core.messages.ai import UsageMetadata43from langchain_core.messages.block_translators.openai import (44 _convert_from_v03_ai_message,45)46from langchain_core.outputs import ChatGeneration, ChatResult47from langchain_core.runnables import RunnableLambda48from langchain_core.runnables.base import RunnableBinding, RunnableSequence49from langchain_core.tracers.base import BaseTracer50from langchain_core.tracers.schemas import Run51from langchain_core.utils._gateway import GATEWAY_METADATA_RESPONSE_KEY52from langchain_core.utils.pydantic import PYDANTIC_VERSION53from openai.types.responses import (54 ResponseApplyPatchToolCall,55 ResponseApplyPatchToolCallOutput,56 ResponseOutputMessage,57 ResponseReasoningItem,58 ResponseTextDeltaEvent,59)60from openai.types.responses.response import IncompleteDetails, Response61from openai.types.responses.response_apply_patch_tool_call import OperationCreateFile62from openai.types.responses.response_error import ResponseError63from openai.types.responses.response_file_search_tool_call import (64 ResponseFileSearchToolCall,65 Result,66)67from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall68from openai.types.responses.response_function_web_search import (69 ActionSearch,70 ResponseFunctionWebSearch,71)72from openai.types.responses.response_output_refusal import ResponseOutputRefusal73from openai.types.responses.response_output_text import ResponseOutputText74from openai.types.responses.response_reasoning_item import Summary75from openai.types.responses.response_usage import (76 InputTokensDetails,77 OutputTokensDetails,78 ResponseUsage,79)80from pydantic import BaseModel, Field, SecretStr81from typing_extensions import Self, TypedDict8283from langchain_openai import ChatOpenAI84from langchain_openai.chat_models._compat import (85 _FUNCTION_CALL_IDS_MAP_KEY,86 _convert_from_v1_to_chat_completions,87 _convert_from_v1_to_responses,88 _convert_to_v03_ai_message,89)90from langchain_openai.chat_models.base import (91 OpenAIRefusalError,92 _construct_lc_result_from_responses_api,93 _construct_responses_api_input,94 _convert_dict_to_message,95 _convert_message_to_dict,96 _convert_responses_chunk_to_generation_chunk,97 _convert_to_openai_response_format,98 _create_usage_metadata,99 _create_usage_metadata_responses,100 _format_message_content,101 _get_last_messages,102 _make_computer_call_output_from_message,103 _model_prefers_responses_api,104 _oai_structured_outputs_parser,105 _resize,106)107108OPENAI_TEST_MODEL = "gpt-5.5"109OPENAI_TEMPERATURE_CAPABLE_TEST_MODEL = "gpt-4o-mini"110111112def test_openai_model_param() -> None:113 llm = ChatOpenAI(model="foo")114 assert llm.model_name == "foo"115 assert llm.model == "foo"116 llm = ChatOpenAI(model_name="foo") # type: ignore[call-arg]117 assert llm.model_name == "foo"118 assert llm.model == "foo"119120 llm = ChatOpenAI(max_tokens=10) # type: ignore[call-arg]121 assert llm.max_tokens == 10122 llm = ChatOpenAI(max_completion_tokens=10)123 assert llm.max_tokens == 10124125126@pytest.mark.parametrize("async_api", [True, False])127def test_streaming_attribute_should_stream(async_api: bool) -> None:128 llm = ChatOpenAI(model="foo", streaming=True)129 assert llm._should_stream(async_api=async_api)130131132def test_openai_client_caching() -> None:133 """Test that the OpenAI client is cached."""134 llm1 = ChatOpenAI(model=OPENAI_TEST_MODEL)135 llm2 = ChatOpenAI(model=OPENAI_TEST_MODEL)136 assert llm1.root_client._client is llm2.root_client._client137138 llm3 = ChatOpenAI(model=OPENAI_TEST_MODEL, base_url="foo")139 assert llm1.root_client._client is not llm3.root_client._client140141 llm4 = ChatOpenAI(model=OPENAI_TEST_MODEL, timeout=None)142 assert llm1.root_client._client is llm4.root_client._client143144 llm5 = ChatOpenAI(model=OPENAI_TEST_MODEL, timeout=3)145 assert llm1.root_client._client is not llm5.root_client._client146147 llm6 = ChatOpenAI(148 model=OPENAI_TEST_MODEL, timeout=httpx.Timeout(timeout=60.0, connect=5.0)149 )150 assert llm1.root_client._client is not llm6.root_client._client151152 llm7 = ChatOpenAI(model=OPENAI_TEST_MODEL, timeout=(5, 1))153 assert llm1.root_client._client is not llm7.root_client._client154155156def test_profile() -> None:157 model = ChatOpenAI(model="gpt-5.2-pro")158 assert model.profile159 assert not model.profile["structured_output"]160161 model = ChatOpenAI(model="gpt-5")162 assert model.profile163 assert model.profile["structured_output"]164 assert model.profile["tool_calling"]165166 # Test overwriting a field167 model.profile["tool_calling"] = False168 assert not model.profile["tool_calling"]169170 # Test we didn't mutate171 model = ChatOpenAI(model="gpt-5")172 assert model.profile173 assert model.profile["tool_calling"]174175 # Test passing in profile176 model = ChatOpenAI(model="gpt-5", profile={"tool_calling": False})177 assert model.profile == {"tool_calling": False}178179 # Test overrides for gpt-5 input tokens180 model = ChatOpenAI(model="gpt-5")181 assert model.profile["max_input_tokens"] == 272_000182183184def test_gpt_5_reasoning_effort_levels() -> None:185 """GPT-5 and GPT-5.1 predate `xhigh`, and GPT-5 supports `minimal`."""186 for model_name in ("gpt-5", "gpt-5-mini", "gpt-5-nano"):187 model = ChatOpenAI(model=model_name)188 assert model.profile189 assert model.profile["reasoning_effort_levels"] == [190 "minimal",191 "low",192 "medium",193 "high",194 ]195196 model = ChatOpenAI(model="gpt-5.1")197 assert model.profile198 assert model.profile["reasoning_effort_levels"] == ["none", "low", "medium", "high"]199200201def test_gpt_5_3_chat_latest_profile_has_no_reasoning_effort() -> None:202 model = ChatOpenAI(model="gpt-5.3-chat-latest")203204 assert model.profile205 assert model.profile["reasoning_output"] is False206 assert "reasoning_effort_levels" not in model.profile207208209def test_gpt_6_astra_reasoning_effort_levels() -> None:210 model = ChatOpenAI(model="gpt-6-astra")211212 assert model.profile213 assert model.profile["reasoning_effort_levels"] == [214 "low",215 "medium",216 "high",217 "xhigh",218 "max",219 ]220 assert "reasoning_effort_default" not in model.profile221222223def test_function_message_dict_to_function_message() -> None:224 content = json.dumps({"result": "Example #1"})225 name = "test_function"226 result = _convert_dict_to_message(227 {"role": "function", "name": name, "content": content}228 )229 assert isinstance(result, FunctionMessage)230 assert result.name == name231 assert result.content == content232233234def test__convert_dict_to_message_human() -> None:235 message = {"role": "user", "content": "foo"}236 result = _convert_dict_to_message(message)237 expected_output = HumanMessage(content="foo")238 assert result == expected_output239 assert _convert_message_to_dict(expected_output) == message240241242def test__convert_dict_to_message_human_with_name() -> None:243 message = {"role": "user", "content": "foo", "name": "test"}244 result = _convert_dict_to_message(message)245 expected_output = HumanMessage(content="foo", name="test")246 assert result == expected_output247 assert _convert_message_to_dict(expected_output) == message248249250def test__convert_dict_to_message_ai() -> None:251 message = {"role": "assistant", "content": "foo"}252 result = _convert_dict_to_message(message)253 expected_output = AIMessage(content="foo")254 assert result == expected_output255 assert _convert_message_to_dict(expected_output) == message256257258def test__convert_dict_to_message_ai_with_name() -> None:259 message = {"role": "assistant", "content": "foo", "name": "test"}260 result = _convert_dict_to_message(message)261 expected_output = AIMessage(content="foo", name="test")262 assert result == expected_output263 assert _convert_message_to_dict(expected_output) == message264265266def test__convert_dict_to_message_system() -> None:267 message = {"role": "system", "content": "foo"}268 result = _convert_dict_to_message(message)269 expected_output = SystemMessage(content="foo")270 assert result == expected_output271 assert _convert_message_to_dict(expected_output) == message272273274def test__convert_dict_to_message_developer() -> None:275 message = {"role": "developer", "content": "foo"}276 result = _convert_dict_to_message(message)277 expected_output = SystemMessage(278 content="foo", additional_kwargs={"__openai_role__": "developer"}279 )280 assert result == expected_output281 assert _convert_message_to_dict(expected_output) == message282283284def test__convert_dict_to_message_system_with_name() -> None:285 message = {"role": "system", "content": "foo", "name": "test"}286 result = _convert_dict_to_message(message)287 expected_output = SystemMessage(content="foo", name="test")288 assert result == expected_output289 assert _convert_message_to_dict(expected_output) == message290291292def test__convert_dict_to_message_tool() -> None:293 message = {"role": "tool", "content": "foo", "tool_call_id": "bar"}294 result = _convert_dict_to_message(message)295 expected_output = ToolMessage(content="foo", tool_call_id="bar")296 assert result == expected_output297 assert _convert_message_to_dict(expected_output) == message298299300def test__convert_dict_to_message_tool_call() -> None:301 raw_tool_call = {302 "id": "call_wm0JY6CdwOMZ4eTxHWUThDNz",303 "function": {304 "arguments": '{"name": "Sally", "hair_color": "green"}',305 "name": "GenerateUsername",306 },307 "type": "function",308 }309 message = {"role": "assistant", "content": None, "tool_calls": [raw_tool_call]}310 result = _convert_dict_to_message(message)311 expected_output = AIMessage(312 content="",313 tool_calls=[314 ToolCall(315 name="GenerateUsername",316 args={"name": "Sally", "hair_color": "green"},317 id="call_wm0JY6CdwOMZ4eTxHWUThDNz",318 type="tool_call",319 )320 ],321 )322 assert result == expected_output323 assert _convert_message_to_dict(expected_output) == message324325 # Test malformed tool call326 raw_tool_calls: list = [327 {328 "id": "call_wm0JY6CdwOMZ4eTxHWUThDNz",329 "function": {"arguments": "oops", "name": "GenerateUsername"},330 "type": "function",331 },332 {333 "id": "call_abc123",334 "function": {335 "arguments": '{"name": "Sally", "hair_color": "green"}',336 "name": "GenerateUsername",337 },338 "type": "function",339 },340 ]341 raw_tool_calls = sorted(raw_tool_calls, key=lambda x: x["id"])342 message = {"role": "assistant", "content": None, "tool_calls": raw_tool_calls}343 result = _convert_dict_to_message(message)344 expected_output = AIMessage(345 content="",346 invalid_tool_calls=[347 InvalidToolCall(348 name="GenerateUsername",349 args="oops",350 id="call_wm0JY6CdwOMZ4eTxHWUThDNz",351 error=(352 "Function GenerateUsername arguments:\n\noops\n\nare not "353 "valid JSON. Received JSONDecodeError Expecting value: line 1 "354 "column 1 (char 0)\nFor troubleshooting, visit: https://docs"355 ".langchain.com/oss/python/langchain/errors/OUTPUT_PARSING_FAILURE "356 ),357 type="invalid_tool_call",358 )359 ],360 tool_calls=[361 ToolCall(362 name="GenerateUsername",363 args={"name": "Sally", "hair_color": "green"},364 id="call_abc123",365 type="tool_call",366 )367 ],368 )369 assert result == expected_output370 reverted_message_dict = _convert_message_to_dict(expected_output)371 reverted_message_dict["tool_calls"] = sorted(372 reverted_message_dict["tool_calls"], key=lambda x: x["id"]373 )374 assert reverted_message_dict == message375376377class MockAsyncContextManager:378 def __init__(self, chunk_list: list) -> None:379 self.current_chunk = 0380 self.chunk_list = chunk_list381 self.chunk_num = len(chunk_list)382383 async def __aenter__(self) -> Self:384 return self385386 async def __aexit__(387 self,388 exc_type: type[BaseException] | None,389 exc: BaseException | None,390 tb: TracebackType | None,391 ) -> None:392 pass393394 def __aiter__(self) -> MockAsyncContextManager:395 return self396397 async def __anext__(self) -> dict:398 if self.current_chunk < self.chunk_num:399 chunk = self.chunk_list[self.current_chunk]400 self.current_chunk += 1401 return chunk402 raise StopAsyncIteration403404405class MockSyncContextManager:406 def __init__(self, chunk_list: list) -> None:407 self.current_chunk = 0408 self.chunk_list = chunk_list409 self.chunk_num = len(chunk_list)410411 def __enter__(self) -> Self:412 return self413414 def __exit__(415 self,416 exc_type: type[BaseException] | None,417 exc: BaseException | None,418 tb: TracebackType | None,419 ) -> None:420 pass421422 def __iter__(self) -> MockSyncContextManager:423 return self424425 def __next__(self) -> dict:426 if self.current_chunk < self.chunk_num:427 chunk = self.chunk_list[self.current_chunk]428 self.current_chunk += 1429 return chunk430 raise StopIteration431432433GLM4_STREAM_META = """{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u4eba\u5de5\u667a\u80fd"}}]}434{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u52a9\u624b"}}]}435{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":","}}]}436{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u4f60\u53ef\u4ee5"}}]}437{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u53eb\u6211"}}]}438{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"AI"}}]}439{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"\u52a9\u624b"}}]}440{"id":"20240722102053e7277a4f94e848248ff9588ed37fb6e6","created":1721614853,"model":"glm-4","choices":[{"index":0,"delta":{"role":"assistant","content":"。"}}]}441{"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}}442[DONE]""" # noqa: E501443444445@pytest.fixture446def mock_glm4_completion() -> list:447 list_chunk_data = GLM4_STREAM_META.split("\n")448 result_list = []449 for msg in list_chunk_data:450 if msg != "[DONE]":451 result_list.append(json.loads(msg))452453 return result_list454455456async def test_glm4_astream(mock_glm4_completion: list) -> None:457 llm_name = "glm-4"458 llm = ChatOpenAI(model=llm_name, stream_usage=True)459 mock_client = AsyncMock()460461 async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:462 return MockAsyncContextManager(mock_glm4_completion)463464 mock_client.create = mock_create465 usage_chunk = mock_glm4_completion[-1]466467 usage_metadata: UsageMetadata | None = None468 with patch.object(llm, "async_client", mock_client):469 async for chunk in llm.astream("你的名字叫什么?只回答名字"):470 assert isinstance(chunk, AIMessageChunk)471 if chunk.usage_metadata is not None:472 usage_metadata = chunk.usage_metadata473474 assert usage_metadata is not None475476 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]477 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]478 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]479480481def test_glm4_stream(mock_glm4_completion: list) -> None:482 llm_name = "glm-4"483 llm = ChatOpenAI(model=llm_name, stream_usage=True)484 mock_client = MagicMock()485486 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:487 return MockSyncContextManager(mock_glm4_completion)488489 mock_client.create = mock_create490 usage_chunk = mock_glm4_completion[-1]491492 usage_metadata: UsageMetadata | None = None493 with patch.object(llm, "client", mock_client):494 for chunk in llm.stream("你的名字叫什么?只回答名字"):495 assert isinstance(chunk, AIMessageChunk)496 if chunk.usage_metadata is not None:497 usage_metadata = chunk.usage_metadata498499 assert usage_metadata is not None500501 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]502 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]503 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]504505506DEEPSEEK_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}507{"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}508{"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}509{"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}510{"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}511{"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}512{"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}513{"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}514{"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}515{"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}516{"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}517{"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}518{"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}519{"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}520{"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}521{"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}522{"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}}523[DONE]""" # noqa: E501524525526@pytest.fixture527def mock_deepseek_completion() -> list[dict]:528 list_chunk_data = DEEPSEEK_STREAM_DATA.split("\n")529 result_list = []530 for msg in list_chunk_data:531 if msg != "[DONE]":532 result_list.append(json.loads(msg))533534 return result_list535536537async def test_deepseek_astream(mock_deepseek_completion: list) -> None:538 llm_name = "deepseek-chat"539 llm = ChatOpenAI(model=llm_name, stream_usage=True)540 mock_client = AsyncMock()541542 async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:543 return MockAsyncContextManager(mock_deepseek_completion)544545 mock_client.create = mock_create546 usage_chunk = mock_deepseek_completion[-1]547 usage_metadata: UsageMetadata | None = None548 with patch.object(llm, "async_client", mock_client):549 async for chunk in llm.astream("你的名字叫什么?只回答名字"):550 assert isinstance(chunk, AIMessageChunk)551 if chunk.usage_metadata is not None:552 usage_metadata = chunk.usage_metadata553554 assert usage_metadata is not None555556 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]557 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]558 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]559560561def test_deepseek_stream(mock_deepseek_completion: list) -> None:562 llm_name = "deepseek-chat"563 llm = ChatOpenAI(model=llm_name, stream_usage=True)564 mock_client = MagicMock()565566 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:567 return MockSyncContextManager(mock_deepseek_completion)568569 mock_client.create = mock_create570 usage_chunk = mock_deepseek_completion[-1]571 usage_metadata: UsageMetadata | None = None572 with patch.object(llm, "client", mock_client):573 for chunk in llm.stream("你的名字叫什么?只回答名字"):574 assert isinstance(chunk, AIMessageChunk)575 if chunk.usage_metadata is not None:576 usage_metadata = chunk.usage_metadata577578 assert usage_metadata is not None579580 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]581 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]582 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]583584585OPENAI_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}586{"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}587{"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}588{"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}589{"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}590{"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}}591[DONE]""" # noqa: E501592593594@pytest.fixture595def mock_openai_completion() -> list[dict]:596 list_chunk_data = OPENAI_STREAM_DATA.split("\n")597 result_list = []598 for msg in list_chunk_data:599 if msg != "[DONE]":600 result_list.append(json.loads(msg))601602 return result_list603604605async def test_openai_astream(mock_openai_completion: list) -> None:606 llm_name = OPENAI_TEST_MODEL607 llm = ChatOpenAI(model=llm_name, stream_usage=True)608 assert llm.stream_usage609 mock_client = AsyncMock()610611 async def mock_create(*args: Any, **kwargs: Any) -> MockAsyncContextManager:612 return MockAsyncContextManager(mock_openai_completion)613614 mock_client.create = mock_create615 usage_chunk = mock_openai_completion[-1]616 usage_metadata: UsageMetadata | None = None617 with patch.object(llm, "async_client", mock_client):618 async for chunk in llm.astream("你的名字叫什么?只回答名字"):619 assert isinstance(chunk, AIMessageChunk)620 if chunk.usage_metadata is not None:621 usage_metadata = chunk.usage_metadata622623 assert usage_metadata is not None624625 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]626 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]627 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]628629630def test_openai_stream(mock_openai_completion: list) -> None:631 llm_name = OPENAI_TEST_MODEL632 llm = ChatOpenAI(model=llm_name, stream_usage=True)633 assert llm.stream_usage634 mock_client = MagicMock()635636 call_kwargs = []637638 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:639 call_kwargs.append(kwargs)640 return MockSyncContextManager(mock_openai_completion)641642 mock_client.create = mock_create643 usage_chunk = mock_openai_completion[-1]644 usage_metadata: UsageMetadata | None = None645 with patch.object(llm, "client", mock_client):646 for chunk in llm.stream("你的名字叫什么?只回答名字"):647 assert isinstance(chunk, AIMessageChunk)648 if chunk.usage_metadata is not None:649 usage_metadata = chunk.usage_metadata650651 assert call_kwargs[-1]["stream_options"] == {"include_usage": True}652 assert usage_metadata is not None653 assert usage_metadata["input_tokens"] == usage_chunk["usage"]["prompt_tokens"]654 assert usage_metadata["output_tokens"] == usage_chunk["usage"]["completion_tokens"]655 assert usage_metadata["total_tokens"] == usage_chunk["usage"]["total_tokens"]656657 # Verify no streaming outside of default base URL or clients658 for param, value in {659 "stream_usage": False,660 "openai_proxy": "http://localhost:7890",661 "openai_api_base": "https://example.com/v1",662 "base_url": "https://example.com/v1",663 "client": mock_client,664 "root_client": mock_client,665 "async_client": mock_client,666 "root_async_client": mock_client,667 "http_client": httpx.Client(),668 "http_async_client": httpx.AsyncClient(),669 }.items():670 llm = ChatOpenAI(model=llm_name, **{param: value}) # type: ignore[arg-type]671 assert not llm.stream_usage672 with patch.object(llm, "client", mock_client):673 _ = list(llm.stream("..."))674 assert "stream_options" not in call_kwargs[-1]675676677def test_openai_stream_events_v3_lifecycle(mock_openai_completion: list) -> None:678 """`stream_events(version="v3")` on chat completions emits a valid lifecycle."""679 from langchain_tests.utils.stream_lifecycle import assert_valid_event_stream680681 llm = ChatOpenAI(model=OPENAI_TEST_MODEL)682 mock_client = MagicMock()683684 def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:685 return MockSyncContextManager(mock_openai_completion)686687 mock_client.create = mock_create688 with patch.object(llm, "client", mock_client):689 events = list(llm.stream_events("你的名字叫什么?只回答名字", version="v3"))690691 assert_valid_event_stream(events)692 # At minimum, a text block with the accumulated answer.693 finishes = [e for e in events if e["event"] == "content-block-finish"]694 assert len(finishes) >= 1695 text_finishes = [f for f in finishes if f["content"]["type"] == "text"]696 assert len(text_finishes) == 1697698699@pytest.fixture700def mock_completion() -> dict:701 return {702 "id": "chatcmpl-7fcZavknQda3SQ",703 "object": "chat.completion",704 "created": 1689989000,705 "model": OPENAI_TEST_MODEL,706 "choices": [707 {708 "index": 0,709 "message": {"role": "assistant", "content": "Bar Baz", "name": "Erick"},710 "finish_reason": "stop",711 }712 ],713 }714715716@pytest.fixture717def mock_client(mock_completion: dict) -> MagicMock:718 rtn = MagicMock()719720 mock_create = MagicMock()721722 mock_resp = MagicMock()723 mock_resp.headers = {"content-type": "application/json"}724 mock_resp.parse.return_value = mock_completion725 mock_create.return_value = mock_resp726727 rtn.with_raw_response.create = mock_create728 rtn.create.return_value = mock_completion729 return rtn730731732@pytest.fixture733def mock_async_client(mock_completion: dict) -> AsyncMock:734 rtn = AsyncMock()735736 mock_create = AsyncMock()737 mock_resp = MagicMock()738 mock_resp.parse.return_value = mock_completion739 mock_create.return_value = mock_resp740741 rtn.with_raw_response.create = mock_create742 rtn.create.return_value = mock_completion743 return rtn744745746def test_openai_invoke(mock_client: MagicMock) -> None:747 llm = ChatOpenAI()748749 with patch.object(llm, "client", mock_client):750 res = llm.invoke("bar")751 assert res.content == "Bar Baz"752753 # headers are not in response_metadata if include_response_headers not set754 assert "headers" not in res.response_metadata755 assert mock_client.with_raw_response.create.called756757758async def test_openai_ainvoke(mock_async_client: AsyncMock) -> None:759 llm = ChatOpenAI()760761 with patch.object(llm, "async_client", mock_async_client):762 res = await llm.ainvoke("bar")763 assert res.content == "Bar Baz"764765 # headers are not in response_metadata if include_response_headers not set766 assert "headers" not in res.response_metadata767 assert mock_async_client.with_raw_response.create.called768769770class _GatewayMetadataTracer(BaseTracer):771 """Captures gateway metadata promoted onto completed LLM runs."""772773 def __init__(self) -> None:774 super().__init__()775 self.gateway_metadata: dict | None = None776777 def _persist_run(self, run: Run) -> None:778 """No-op; runs are inspected as they complete."""779780 def _on_llm_end(self, run: Run) -> None:781 metadata = run.extra.get("metadata", {})782 if "ls_gateway_info" in metadata:783 self.gateway_metadata = metadata["ls_gateway_info"]784785786_GATEWAY_METADATA_HEADERS = httpx2.Headers(787 {"x-langsmith-gateway-metadata": '{"provider": "openai"}'}788)789790_RESPONSES_API_COMPLETION = Response(791 id="resp_123",792 created_at=1234567890,793 model=OPENAI_TEST_MODEL,794 object="response",795 parallel_tool_calls=True,796 tools=[],797 tool_choice="auto",798 output=[799 ResponseOutputMessage(800 type="message",801 id="msg_123",802 content=[803 ResponseOutputText(type="output_text", text="Bar Baz", annotations=[])804 ],805 role="assistant",806 status="completed",807 )808 ],809)810811_RESPONSES_API_STREAM = [812 ResponseTextDeltaEvent(813 content_index=0,814 delta="Bar Baz",815 item_id="msg_123",816 output_index=0,817 sequence_number=0,818 logprobs=[],819 type="response.output_text.delta",820 ),821]822823824@pytest.mark.parametrize("use_responses_api", [False, True])825def test_openai_invoke_surfaces_gateway_metadata(826 mock_completion: dict, *, use_responses_api: bool827) -> None:828 """Gateway metadata header is surfaced on `generation_info`, not the message."""829 llm = ChatOpenAI(use_responses_api=use_responses_api)830 mock_client = MagicMock()831 mock_resp = MagicMock()832 mock_resp.headers = _GATEWAY_METADATA_HEADERS833 if use_responses_api:834 mock_resp.parse.return_value = _RESPONSES_API_COMPLETION835 mock_client.responses.with_raw_response.create.return_value = mock_resp836 client_attr = "root_client"837 else:838 mock_resp.parse.return_value = mock_completion839 mock_client.with_raw_response.create.return_value = mock_resp840 client_attr = "client"841842 tracer = _GatewayMetadataTracer()843 with patch.object(llm, client_attr, mock_client):844 res = llm.invoke("bar", config={"callbacks": [tracer]})845846 # Gateway metadata reaches the tracer via `generation_info`...847 assert tracer.gateway_metadata == {"provider": "openai"}848 # ...but is kept off the user-facing message `response_metadata`.849 assert GATEWAY_METADATA_RESPONSE_KEY not in res.response_metadata850851852@pytest.mark.parametrize("use_responses_api", [False, True])853def test_openai_stream_surfaces_gateway_metadata(854 mock_openai_completion: list, *, use_responses_api: bool855) -> None:856 """Gateway metadata reaches the tracer for a gateway-routed stream."""857 # A LangSmith API key signals gateway routing, so streaming fetches raw858 # headers.859 llm = ChatOpenAI(860 model=OPENAI_TEST_MODEL,861 api_key="lsv2_pt_example", # type: ignore[arg-type]862 use_responses_api=use_responses_api,863 )864 mock_client = MagicMock()865 mock_resp = MagicMock()866 mock_resp.headers = _GATEWAY_METADATA_HEADERS867 if use_responses_api:868 mock_resp.parse.return_value = MockSyncContextManager(_RESPONSES_API_STREAM)869 mock_client.with_raw_response.responses.create.return_value = mock_resp870 mock_client.responses.create.return_value = MockSyncContextManager(871 _RESPONSES_API_STREAM872 )873 client_attr = "root_client"874 else:875 mock_resp.parse.return_value = MockSyncContextManager(mock_openai_completion)876 mock_client.with_raw_response.create.return_value = mock_resp877 client_attr = "client"878879 tracer = _GatewayMetadataTracer()880 with patch.object(llm, client_attr, mock_client):881 for chunk in llm.stream("what is your name?", config={"callbacks": [tracer]}):882 # Gateway metadata is kept off the user-facing chunk metadata.883 assert GATEWAY_METADATA_RESPONSE_KEY not in chunk.response_metadata884885 assert tracer.gateway_metadata == {"provider": "openai"}886887888@pytest.mark.parametrize("use_responses_api", [False, True])889async def test_openai_astream_surfaces_gateway_metadata(890 mock_openai_completion: list, *, use_responses_api: bool891) -> None:892 """Gateway metadata reaches the tracer for a gateway-routed async stream."""893 llm = ChatOpenAI(894 model=OPENAI_TEST_MODEL,895 api_key="lsv2_pt_example", # type: ignore[arg-type]896 use_responses_api=use_responses_api,897 )898 mock_client = AsyncMock()899 mock_resp = MagicMock()900 mock_resp.headers = _GATEWAY_METADATA_HEADERS901 if use_responses_api:902 mock_resp.parse.return_value = MockAsyncContextManager(_RESPONSES_API_STREAM)903 mock_client.with_raw_response.responses.create.return_value = mock_resp904 mock_client.responses.create.return_value = MockAsyncContextManager(905 _RESPONSES_API_STREAM906 )907 client_attr = "root_async_client"908 else:909 mock_resp.parse.return_value = MockAsyncContextManager(mock_openai_completion)910 mock_client.with_raw_response.create.return_value = mock_resp911 client_attr = "async_client"912913 tracer = _GatewayMetadataTracer()914 with patch.object(llm, client_attr, mock_client):915 async for chunk in llm.astream(916 "what is your name?", config={"callbacks": [tracer]}917 ):918 # Gateway metadata is kept off the user-facing chunk metadata.919 assert GATEWAY_METADATA_RESPONSE_KEY not in chunk.response_metadata920921 assert tracer.gateway_metadata == {"provider": "openai"}922923924@pytest.mark.parametrize(925 "model",926 [927 OPENAI_TEST_MODEL,928 "gpt-5-nano",929 "o3",930 "gpt-5.2",931 ],932)933def test__get_encoding_model(model: str) -> None:934 ChatOpenAI(model=model)._get_encoding_model()935936937def test_openai_invoke_name(mock_client: MagicMock) -> None:938 llm = ChatOpenAI()939940 with patch.object(llm, "client", mock_client):941 messages = [HumanMessage(content="Foo", name="Katie")]942 res = llm.invoke(messages)943 call_args, call_kwargs = mock_client.with_raw_response.create.call_args944 assert len(call_args) == 0 # no positional args945 call_messages = call_kwargs["messages"]946 assert len(call_messages) == 1947 assert call_messages[0]["role"] == "user"948 assert call_messages[0]["content"] == "Foo"949 assert call_messages[0]["name"] == "Katie"950951 # check return type has name952 assert res.content == "Bar Baz"953 assert res.name == "Erick"954955956def test_function_calls_with_tool_calls(mock_client: MagicMock) -> None:957 # Test that we ignore function calls if tool_calls are present958 llm = ChatOpenAI(model=OPENAI_TEST_MODEL)959 tool_call_message = AIMessage(960 content="",961 additional_kwargs={962 "function_call": {963 "name": "get_weather",964 "arguments": '{"location": "Boston"}',965 }966 },967 tool_calls=[968 {969 "name": "get_weather",970 "args": {"location": "Boston"},971 "id": "abc123",972 "type": "tool_call",973 }974 ],975 )976 messages = [977 HumanMessage("What's the weather in Boston?"),978 tool_call_message,979 ToolMessage(content="It's sunny.", name="get_weather", tool_call_id="abc123"),980 ]981 with patch.object(llm, "client", mock_client):982 _ = llm.invoke(messages)983 _, call_kwargs = mock_client.with_raw_response.create.call_args984 call_messages = call_kwargs["messages"]985 tool_call_message_payload = call_messages[1]986 assert "tool_calls" in tool_call_message_payload987 assert "function_call" not in tool_call_message_payload988989 # Test we don't ignore function calls if tool_calls are not present990 cast(AIMessage, messages[1]).tool_calls = []991 with patch.object(llm, "client", mock_client):992 _ = llm.invoke(messages)993 _, call_kwargs = mock_client.with_raw_response.create.call_args994 call_messages = call_kwargs["messages"]995 tool_call_message_payload = call_messages[1]996 assert "function_call" in tool_call_message_payload997 assert "tool_calls" not in tool_call_message_payload9989991000def test_custom_token_counting() -> None:1001 def token_encoder(text: str) -> list[int]:1002 return [1, 2, 3]10031004 llm = ChatOpenAI(custom_get_token_ids=token_encoder)1005 assert llm.get_token_ids("foo") == [1, 2, 3]100610071008def test_format_message_content() -> None:1009 content: Any = "hello"1010 assert content == _format_message_content(content)10111012 content = None1013 assert content == _format_message_content(content)10141015 content = []1016 assert content == _format_message_content(content)10171018 content = [1019 {"type": "text", "text": "What is in this image?"},1020 {"type": "image_url", "image_url": {"url": "url.com"}},1021 ]1022 assert content == _format_message_content(content)10231024 content = [1025 {"type": "text", "text": "hello"},1026 {1027 "type": "tool_use",1028 "id": "toolu_01A09q90qw90lq917835lq9",1029 "name": "get_weather",1030 "input": {"location": "San Francisco, CA", "unit": "celsius"},1031 },1032 ]1033 assert _format_message_content(content) == [{"type": "text", "text": "hello"}]10341035 # Standard multi-modal inputs1036 contents = [1037 {"type": "image", "source_type": "url", "url": "https://..."}, # v01038 {"type": "image", "url": "https://..."}, # v11039 ]1040 expected = [{"type": "image_url", "image_url": {"url": "https://..."}}]1041 for content in contents:1042 assert expected == _format_message_content([content])10431044 contents = [1045 {1046 "type": "image",1047 "source_type": "base64",1048 "data": "<base64 data>",1049 "mime_type": "image/png",1050 },1051 {"type": "image", "base64": "<base64 data>", "mime_type": "image/png"},1052 ]1053 expected = [1054 {1055 "type": "image_url",1056 "image_url": {"url": "data:image/png;base64,<base64 data>"},1057 }1058 ]1059 for content in contents:1060 assert expected == _format_message_content([content])10611062 contents = [1063 {1064 "type": "file",1065 "source_type": "base64",1066 "data": "<base64 data>",1067 "mime_type": "application/pdf",1068 "filename": "my_file",1069 },1070 {1071 "type": "file",1072 "base64": "<base64 data>",1073 "mime_type": "application/pdf",1074 "filename": "my_file",1075 },1076 ]1077 expected = [1078 {1079 "type": "file",1080 "file": {1081 "filename": "my_file",1082 "file_data": "data:application/pdf;base64,<base64 data>",1083 },1084 }1085 ]1086 for content in contents:1087 assert expected == _format_message_content([content])10881089 # Test warn if PDF is missing a filename and that we add a default filename1090 pdf_block = {1091 "type": "file",1092 "base64": "<base64 data>",1093 "mime_type": "application/pdf",1094 }1095 expected = [1096 {1097 "type": "file",1098 "file": {1099 "file_data": "data:application/pdf;base64,<base64 data>",1100 "filename": "LC_AUTOGENERATED",1101 },1102 }1103 ]1104 with pytest.warns(match="filename"):1105 assert expected == _format_message_content([pdf_block])11061107 contents = [1108 {"type": "file", "source_type": "id", "id": "file-abc123"},1109 {"type": "file", "file_id": "file-abc123"},1110 ]1111 expected = [{"type": "file", "file": {"file_id": "file-abc123"}}]1112 for content in contents:1113 assert expected == _format_message_content([content])111411151116class GenerateUsername(BaseModel):1117 "Get a username based on someone's name and hair color."11181119 name: str1120 hair_color: str112111221123class MakeASandwich(BaseModel):1124 "Make a sandwich given a list of ingredients."11251126 bread_type: str1127 cheese_type: str1128 condiments: list[str]1129 vegetables: list[str]113011311132@pytest.mark.parametrize(1133 "tool_choice",1134 [1135 "any",1136 "none",1137 "auto",1138 "required",1139 "GenerateUsername",1140 {"type": "function", "function": {"name": "MakeASandwich"}},1141 False,1142 None,1143 ],1144)1145@pytest.mark.parametrize("strict", [True, False, None])1146def test_bind_tools_tool_choice(tool_choice: Any, strict: bool | None) -> None:1147 """Test passing in manually construct tool call message."""1148 llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0)1149 llm.bind_tools(1150 tools=[GenerateUsername, MakeASandwich], tool_choice=tool_choice, strict=strict1151 )115211531154def test_bind_tools_response_format_defaults_strict() -> None:1155 """Test that strict defaults to True when response_format is provided."""1156 llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0)1157 bound = llm.bind_tools(1158 tools=[GenerateUsername],1159 response_format=MakeASandwich,1160 )1161 tools = bound.kwargs["tools"] # type: ignore[attr-defined]1162 assert tools[0]["function"]["strict"] is True116311641165def test_bind_tools_response_format_respects_strict_false() -> None:1166 """Test that strict=False is respected even when response_format is provided."""1167 llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0)1168 bound = llm.bind_tools(1169 tools=[GenerateUsername],1170 response_format=MakeASandwich,1171 strict=False,1172 )1173 tools = bound.kwargs["tools"] # type: ignore[attr-defined]1174 assert tools[0]["function"]["strict"] is False117511761177def test_bind_tools_no_response_format_keeps_strict_none() -> None:1178 """Test that strict stays None when response_format is not provided."""1179 llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0)1180 bound = llm.bind_tools(tools=[GenerateUsername])1181 tools = bound.kwargs["tools"] # type: ignore[attr-defined]1182 assert "strict" not in tools[0]["function"]118311841185@pytest.mark.parametrize(1186 "schema", [GenerateUsername, GenerateUsername.model_json_schema()]1187)1188@pytest.mark.parametrize("method", ["json_schema", "function_calling", "json_mode"])1189@pytest.mark.parametrize("include_raw", [True, False])1190@pytest.mark.parametrize("strict", [True, False, None])1191def test_with_structured_output(1192 schema: type | dict[str, Any] | None,1193 method: Literal["function_calling", "json_mode", "json_schema"],1194 include_raw: bool,1195 strict: bool | None,1196) -> None:1197 """Test passing in manually construct tool call message."""1198 if method == "json_mode":1199 strict = None1200 llm = ChatOpenAI(model=OPENAI_TEST_MODEL, temperature=0)1201 llm.with_structured_output(1202 schema, method=method, strict=strict, include_raw=include_raw1203 )120412051206def test_get_num_tokens_from_messages() -> None:1207 llm = ChatOpenAI(model=OPENAI_TEST_MODEL)1208 messages = [1209 SystemMessage("you're a good assistant"),1210 HumanMessage("how are you"),1211 HumanMessage(1212 [1213 {"type": "text", "text": "what's in this image"},1214 {"type": "image_url", "image_url": {"url": "https://foobar.com"}},1215 {1216 "type": "image_url",1217 "image_url": {"url": "https://foobar.com", "detail": "low"},1218 },1219 ]1220 ),1221 AIMessage("a nice bird"),1222 AIMessage(1223 "",1224 tool_calls=[1225 ToolCall(id="foo", name="bar", args={"arg1": "arg1"}, type="tool_call")1226 ],1227 ),1228 AIMessage(1229 "",1230 additional_kwargs={1231 "function_call": {1232 "arguments": json.dumps({"arg1": "arg1"}),1233 "name": "fun",1234 }1235 },1236 ),1237 AIMessage(1238 "text",1239 tool_calls=[1240 ToolCall(id="foo", name="bar", args={"arg1": "arg1"}, type="tool_call")1241 ],1242 ),1243 ToolMessage("foobar", tool_call_id="foo"),1244 ]1245 expected = 431 # Updated to match token count with mocked 100x100 image12461247 # Mock _url_to_size to avoid PIL dependency in unit tests1248 with patch("langchain_openai.chat_models.base._url_to_size") as mock_url_to_size:1249 mock_url_to_size.return_value = (100, 100) # 100x100 pixel image1250 actual = llm.get_num_tokens_from_messages(messages)12511252 assert expected == actual12531254 # Test file inputs1255 messages = [1256 HumanMessage(1257 [1258 "Summarize this document.",1259 {1260 "type": "file",1261 "file": {1262 "filename": "my file",1263 "file_data": "data:application/pdf;base64,<data>",1264 },1265 },1266 ]1267 )1268 ]1269 actual = 01270 with pytest.warns(match="file inputs are not supported"):1271 actual = llm.get_num_tokens_from_messages(messages)1272 assert actual == 1312731274 # Test Responses1275 messages = [1276 AIMessage(1277 [1278 {1279 "type": "function_call",1280 "name": "multiply",1281 "arguments": '{"x":5,"y":4}',1282 "call_id": "call_abc123",1283 "id": "fc_abc123",1284 "status": "completed",1285 },1286 ],1287 tool_calls=[1288 {1289 "type": "tool_call",1290 "name": "multiply",1291 "args": {"x": 5, "y": 4},1292 "id": "call_abc123",1293 }1294 ],1295 )1296 ]1297 actual = llm.get_num_tokens_from_messages(messages)1298 assert actual129913001301@pytest.mark.parametrize(1302 "model", ["o1", "o1-preview", "o1-mini", "o3", "o3-mini", "o4-mini"]1303)1304def test_get_num_tokens_from_messages_o_series(model: str) -> None:1305 """o-series models use the same message token format as gpt-4/gpt-5.13061307 Regression test: these raised NotImplementedError.1308 """1309 llm = ChatOpenAI(model=model)1310 messages = [1311 SystemMessage("you're a good assistant"),1312 HumanMessage("how are you"),1313 ]1314 actual = llm.get_num_tokens_from_messages(messages)1315 expected = ChatOpenAI(model=OPENAI_TEST_MODEL).get_num_tokens_from_messages(1316 messages1317 )1318 assert actual == expected131913201321def test_get_num_tokens_from_messages_gpt_6() -> None:1322 llm = ChatOpenAI(model="gpt-6-astra")1323 messages = [HumanMessage("how are you")]13241325 assert llm._get_encoding_model()[1].name == "o200k_base"1326 assert llm.get_num_tokens_from_messages(messages) > 0132713281329class Foo(BaseModel):1330 bar: int133113321333# class FooV1(BaseModelV1):1334# bar: int133513361337@pytest.mark.parametrize(1338 "schema",1339 [1340 Foo1341 # FooV11342 ],1343)1344def test_schema_from_with_structured_output(schema: type) -> None:1345 """Test schema from with_structured_output."""13461347 llm = ChatOpenAI(model=OPENAI_TEST_MODEL)13481349 structured_llm = llm.with_structured_output(1350 schema, method="json_schema", strict=True1351 )13521353 expected = {1354 "properties": {"bar": {"title": "Bar", "type": "integer"}},1355 "required": ["bar"],1356 "title": schema.__name__,1357 "type": "object",1358 }1359 output_schema = cast("type[BaseModel]", structured_llm.get_output_schema())1360 actual = output_schema.model_json_schema()1361 assert actual == expected136213631364def test__create_usage_metadata() -> None:1365 usage_metadata = {1366 "completion_tokens": 15,1367 "prompt_tokens_details": None,1368 "completion_tokens_details": None,1369 "prompt_tokens": 11,1370 "total_tokens": 26,1371 }1372 result = _create_usage_metadata(usage_metadata)1373 assert result == UsageMetadata(1374 output_tokens=15,1375 input_tokens=11,1376 total_tokens=26,1377 input_token_details={},1378 output_token_details={},1379 )138013811382def test__create_usage_metadata_zero_total_tokens() -> None:1383 """Test that explicit total_tokens=0 is preserved, not replaced by sum."""1384 usage_metadata = {1385 "prompt_tokens": 10,1386 "completion_tokens": 5,1387 "total_tokens": 0,1388 "prompt_tokens_details": None,1389 "completion_tokens_details": None,1390 }1391 result = _create_usage_metadata(usage_metadata)1392 assert result["total_tokens"] == 0139313941395def test__create_usage_metadata_cache_write_tokens() -> None:1396 """`cache_write_tokens` is surfaced under the standard `cache_creation` key."""1397 usage_metadata = {1398 "completion_tokens": 15,1399 # OpenAI's `cache_write_tokens` maps to core's `cache_creation`1400 "prompt_tokens_details": {"cached_tokens": 50, "cache_write_tokens": 25},1401 "completion_tokens_details": None,1402 "prompt_tokens": 100,1403 "total_tokens": 115,1404 }1405 result = _create_usage_metadata(usage_metadata)1406 assert result["input_token_details"] == {1407 "cache_read": 50,1408 "cache_creation": 25,1409 }141014111412def test__create_usage_metadata_cache_read_only() -> None:1413 """Responses without `cache_write_tokens` emit no `cache_creation` key."""1414 usage_metadata = {1415 "completion_tokens": 15,1416 "prompt_tokens_details": {"cached_tokens": 50},1417 "completion_tokens_details": None,1418 "prompt_tokens": 100,1419 "total_tokens": 115,1420 }1421 result = _create_usage_metadata(usage_metadata)1422 assert result["input_token_details"] == {"cache_read": 50}142314241425def test__create_usage_metadata_cache_tokens_zero_retained() -> None:1426 """Explicit zero cache counts are retained (filtered on `None`, not falsiness)."""1427 usage_metadata = {1428 "completion_tokens": 15,1429 "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 0},1430 "completion_tokens_details": None,1431 "prompt_tokens": 100,1432 "total_tokens": 115,1433 }1434 result = _create_usage_metadata(usage_metadata)1435 assert result["input_token_details"] == {1436 "cache_read": 0,1437 "cache_creation": 0,1438 }143914401441def test__create_usage_metadata_service_tier_excludes_cache_read_tokens() -> None:1442 """Tier counts exclude cache reads but not overlapping cache writes."""1443 usage_metadata = {1444 "completion_tokens": 50,1445 "prompt_tokens_details": {1446 "cached_tokens": 256,1447 "cache_write_tokens": 3072,1448 },1449 "completion_tokens_details": {"reasoning_tokens": 10},1450 "prompt_tokens": 2304,1451 "total_tokens": 2354,1452 }1453 result = _create_usage_metadata(usage_metadata, service_tier="priority")1454 assert result["input_token_details"] == {1455 "priority_cache_read": 256,1456 "priority_cache_creation": 3072,1457 "priority": 2048,1458 }1459 assert result["output_token_details"] == {1460 "priority_reasoning": 10,1461 "priority": 40, # 50 - 10 (reasoning)1462 }146314641465def test__create_usage_metadata_service_tier_without_detail_fields() -> None:1466 """Tier arithmetic tolerates missing cache/reasoning fields (no TypeError)."""1467 usage_metadata = {1468 "completion_tokens": 50,1469 "prompt_tokens_details": None,1470 "completion_tokens_details": None,1471 "prompt_tokens": 100,1472 "total_tokens": 150,1473 }1474 result = _create_usage_metadata(usage_metadata, service_tier="flex")1475 assert result["input_token_details"] == {"flex": 100}1476 assert result["output_token_details"] == {"flex": 50}147714781479def test__create_usage_metadata_responses() -> None:1480 response_usage_metadata = {1481 "input_tokens": 100,1482 "input_tokens_details": {"cached_tokens": 50},1483 "output_tokens": 50,1484 "output_tokens_details": {"reasoning_tokens": 10},1485 "total_tokens": 150,1486 }1487 result = _create_usage_metadata_responses(response_usage_metadata)14881489 assert result == UsageMetadata(1490 output_tokens=50,1491 input_tokens=100,1492 total_tokens=150,1493 input_token_details={"cache_read": 50},1494 output_token_details={"reasoning": 10},1495 )149614971498def test__create_usage_metadata_responses_cache_write_tokens() -> None:1499 """Responses usage maps `cache_write_tokens` to the `cache_creation` key."""1500 response_usage_metadata = {1501 "input_tokens": 100,1502 "input_tokens_details": {"cached_tokens": 50, "cache_write_tokens": 25},1503 "output_tokens": 50,1504 "output_tokens_details": {"reasoning_tokens": 10},1505 "total_tokens": 150,1506 }1507 result = _create_usage_metadata_responses(response_usage_metadata)15081509 assert result == UsageMetadata(1510 output_tokens=50,1511 input_tokens=100,1512 total_tokens=150,1513 input_token_details={"cache_read": 50, "cache_creation": 25},1514 output_token_details={"reasoning": 10},1515 )151615171518def test__create_usage_metadata_responses_service_tier_cache_write_overlap() -> None:1519 """Tier counts exclude cache reads but not overlapping cache writes."""1520 response_usage_metadata = {1521 "input_tokens": 2304,1522 "input_tokens_details": {1523 "cached_tokens": 256,1524 "cache_write_tokens": 3072,1525 },1526 "output_tokens": 50,1527 "output_tokens_details": {"reasoning_tokens": 10},1528 "total_tokens": 2354,1529 }1530 result = _create_usage_metadata_responses(1531 response_usage_metadata, service_tier="flex"1532 )1533 assert result["input_token_details"] == {1534 "flex_cache_read": 256,1535 "flex_cache_creation": 3072,1536 "flex": 2048,1537 }1538 assert result["output_token_details"] == {1539 "flex_reasoning": 10,1540 "flex": 40, # 50 - 10 (reasoning)1541 }154215431544def test__create_usage_metadata_responses_service_tier_without_detail_fields() -> None:1545 """Tier arithmetic tolerates missing cache/reasoning fields (no TypeError)."""1546 response_usage_metadata = {1547 "input_tokens": 100,1548 "input_tokens_details": None,1549 "output_tokens": 50,1550 "output_tokens_details": None,1551 "total_tokens": 150,1552 }1553 result = _create_usage_metadata_responses(1554 response_usage_metadata, service_tier="priority"1555 )1556 assert result["input_token_details"] == {"priority": 100}1557 assert result["output_token_details"] == {"priority": 50}155815591560def test__resize_caps_dimensions_preserving_ratio() -> None:1561 """Larger side capped at 2048 then smaller at 768 keeping aspect ratio."""1562 assert _resize(2048, 4096) == (768, 1536)1563 assert _resize(4096, 2048) == (1536, 768)156415651566def test__convert_to_openai_response_format() -> None:1567 # Test response formats that aren't tool-like.1568 response_format: dict = {1569 "type": "json_schema",1570 "json_schema": {1571 "name": "math_reasoning",1572 "schema": {1573 "type": "object",1574 "properties": {1575 "steps": {1576 "type": "array",1577 "items": {1578 "type": "object",1579 "properties": {1580 "explanation": {"type": "string"},1581 "output": {"type": "string"},1582 },1583 "required": ["explanation", "output"],1584 "additionalProperties": False,1585 },1586 },1587 "final_answer": {"type": "string"},1588 },1589 "required": ["steps", "final_answer"],1590 "additionalProperties": False,1591 },1592 "strict": True,1593 },1594 }15951596 actual = _convert_to_openai_response_format(response_format)1597 assert actual == response_format15981599 actual = _convert_to_openai_response_format(response_format["json_schema"])1600 assert actual == response_format16011602 actual = _convert_to_openai_response_format(response_format, strict=True)1603 assert actual == response_format16041605 with pytest.raises(ValueError):1606 _convert_to_openai_response_format(response_format, strict=False)160716081609@pytest.mark.parametrize("method", ["function_calling", "json_schema"])1610@pytest.mark.parametrize("strict", [True, None])1611def test_structured_output_strict(1612 method: Literal["function_calling", "json_schema"], strict: bool | None1613) -> None:1614 """Test to verify structured output with strict=True."""16151616 llm = ChatOpenAI(model=OPENAI_TEST_MODEL)16171618 class Joke(BaseModel):1619 """Joke to tell user."""16201621 setup: str = Field(description="question to set up a joke")1622 punchline: str = Field(description="answer to resolve the joke")16231624 llm.with_structured_output(Joke, method=method, strict=strict)1625 # Schema1626 llm.with_structured_output(Joke.model_json_schema(), method=method, strict=strict)162716281629def test_nested_structured_output_strict() -> None:1630 """Test to verify structured output with strict=True for nested object."""16311632 llm = ChatOpenAI(model=OPENAI_TEST_MODEL)16331634 class SelfEvaluation(TypedDict):1635 score: int1636 text: str16371638 class JokeWithEvaluation(TypedDict):1639 """Joke to tell user."""16401641 setup: str1642 punchline: str1643 _evaluation: SelfEvaluation16441645 llm.with_structured_output(JokeWithEvaluation, method="json_schema")164616471648def test__get_request_payload() -> None:1649 llm = ChatOpenAI(model=OPENAI_TEST_MODEL)1650 messages: list = [1651 SystemMessage("hello"),1652 SystemMessage("bye", additional_kwargs={"__openai_role__": "developer"}),1653 SystemMessage(content=[{"type": "text", "text": "hello!"}]),1654 {"role": "human", "content": "how are you"},1655 {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1656 ]1657 expected = {1658 "messages": [1659 {"role": "system", "content": "hello"},1660 {"role": "developer", "content": "bye"},1661 {"role": "system", "content": [{"type": "text", "text": "hello!"}]},1662 {"role": "user", "content": "how are you"},1663 {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1664 ],1665 "model": OPENAI_TEST_MODEL,1666 "stream": False,1667 }1668 payload = llm._get_request_payload(messages)1669 assert payload == expected16701671 # Test we coerce to developer role for o-series models1672 llm = ChatOpenAI(model="o3")1673 payload = llm._get_request_payload(messages)1674 expected = {1675 "messages": [1676 {"role": "developer", "content": "hello"},1677 {"role": "developer", "content": "bye"},1678 {"role": "developer", "content": [{"type": "text", "text": "hello!"}]},1679 {"role": "user", "content": "how are you"},1680 {"role": "user", "content": [{"type": "text", "text": "feeling today"}]},1681 ],1682 "model": "o3",1683 "stream": False,1684 }1685 assert payload == expected16861687 # Test we ignore reasoning blocks from other providers1688 reasoning_messages: list = [1689 {1690 "role": "user",1691 "content": [1692 {"type": "reasoning_content", "reasoning_content": "reasoning..."},1693 {"type": "text", "text": "reasoned response"},1694 ],1695 },1696 {1697 "role": "user",1698 "content": [1699 {"type": "thinking", "thinking": "thinking..."},1700 {"type": "text", "text": "thoughtful response"},1701 ],1702 },1703 ]1704 expected = {1705 "messages": [1706 {1707 "role": "user",1708 "content": [{"type": "text", "text": "reasoned response"}],1709 },1710 {1711 "role": "user",1712 "content": [{"type": "text", "text": "thoughtful response"}],1713 },1714 ],1715 "model": "o3",1716 "stream": False,1717 }1718 payload = llm._get_request_payload(reasoning_messages)1719 assert payload == expected172017211722def test_sanitize_chat_completions_text_blocks() -> None:1723 messages = [1724 ToolMessage(1725 content=[{"type": "text", "text": "foo", "id": "lc_abc123"}],1726 tool_call_id="def456",1727 ),1728 ]1729 payload = ChatOpenAI(model="gpt-5.2")._get_request_payload(messages)1730 assert payload["messages"] == [1731 {1732 "content": [{"type": "text", "text": "foo"}],1733 "role": "tool",1734 "tool_call_id": "def456",1735 }1736 ]173717381739def test_init_o1() -> None:1740 with warnings.catch_warnings(record=True) as record:1741 warnings.simplefilter("error") # Treat warnings as errors1742 ChatOpenAI(model=OPENAI_TEST_MODEL, reasoning_effort="medium")17431744 assert len(record) == 0174517461747def test_init_minimal_reasoning_effort() -> None:1748 with warnings.catch_warnings(record=True) as record:1749 warnings.simplefilter("error")1750 ChatOpenAI(model="gpt-5", reasoning_effort="minimal")17511752 assert len(record) == 0175317541755@pytest.mark.parametrize("use_responses_api", [False, True])1756@pytest.mark.parametrize("use_max_completion_tokens", [True, False])1757def test_minimal_reasoning_effort_payload(1758 use_max_completion_tokens: bool, use_responses_api: bool1759) -> None:1760 """Test that minimal reasoning effort is included in request payload."""1761 if use_max_completion_tokens:1762 kwargs = {"max_completion_tokens": 100}1763 else:1764 kwargs = {"max_tokens": 100}17651766 init_kwargs: dict[str, Any] = {1767 "model": "gpt-5",1768 "reasoning_effort": "minimal",1769 "use_responses_api": use_responses_api,1770 **kwargs,1771 }17721773 llm = ChatOpenAI(**init_kwargs)17741775 messages = [1776 {"role": "developer", "content": "respond with just 'test'"},1777 {"role": "user", "content": "hello"},1778 ]17791780 payload = llm._get_request_payload(messages, stop=None)17811782 # When using responses API, reasoning_effort becomes reasoning.effort1783 if use_responses_api:1784 assert "reasoning" in payload1785 assert payload["reasoning"] == {"effort": "minimal"}1786 # For responses API, tokens param becomes max_output_tokens1787 assert payload["max_output_tokens"] == 1001788 else:1789 # For non-responses API, reasoning_effort remains as is1790 assert payload["reasoning_effort"] == "minimal"1791 if use_max_completion_tokens:1792 assert payload["max_completion_tokens"] == 1001793 else:1794 # max_tokens gets converted to max_completion_tokens in non-responses API1795 assert payload["max_completion_tokens"] == 100179617971798@pytest.mark.parametrize("via_invoke", [False, True])1799def test_responses_api_payload_excludes_stop(via_invoke: bool) -> None:1800 """The Responses API rejects `stop`, so it must be dropped from the payload.18011802 Covers `stop` supplied both at construction time and at invoke time, since1803 they reach the payload through different code paths.1804 """1805 if via_invoke:1806 llm = ChatOpenAI(model=OPENAI_TEST_MODEL, use_responses_api=True)1807 payload = llm._get_request_payload(1808 [HumanMessage(content="Hello")], stop=["END"]1809 )1810 else:1811 llm = ChatOpenAI( # type: ignore[call-arg]1812 model=OPENAI_TEST_MODEL, stop=["END"], use_responses_api=True1813 )1814 payload = llm._get_request_payload([HumanMessage(content="Hello")])18151816 assert "stop" not in payload181718181819def test_chat_completions_payload_includes_stop() -> None:1820 """`stop` must be preserved for the Chat Completions API, which supports it.18211822 Guards against an over-broad change dropping `stop` outside the Responses API.1823 """1824 llm = ChatOpenAI(model=OPENAI_TEST_MODEL, stop=["END"]) # type: ignore[call-arg]18251826 payload = llm._get_request_payload([HumanMessage(content="Hello")])18271828 assert payload["stop"] == ["END"]182918301831def test_output_version_compat() -> None:1832 llm = ChatOpenAI(model="gpt-5", output_version="responses/v1")1833 assert llm._use_responses_api({}) is True183418351836def test_convert_chunk_to_generation_chunk_v1_keeps_string_content() -> None:1837 """v1 streaming keeps content as '' (not []) and stamps output_version.18381839 Covers both the usage-only (empty-choices) chunk and a content-bearing1840 chunk carrying a tool-call delta; the latter pins the per-content-chunk1841 `output_version` propagation.1842 """1843 llm = ChatOpenAI(model="gpt-4o", output_version="v1")18441845 # Empty-choices chunk (usage-only)1846 empty_chunk: dict[str, Any] = {1847 "id": "chatcmpl-test",1848 "object": "chat.completion.chunk",1849 "created": 0,1850 "model": "gpt-4o",1851 "choices": [],1852 "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},1853 }1854 gen = llm._convert_chunk_to_generation_chunk(empty_chunk, AIMessageChunk, None)1855 assert gen is not None1856 assert gen.message.content == "" # NOT []1857 assert gen.message.response_metadata.get("output_version") == "v1"18581859 # Content-bearing chunk with tool_call delta1860 tool_chunk: dict[str, Any] = {1861 "id": "chatcmpl-test",1862 "object": "chat.completion.chunk",1863 "created": 0,1864 "model": "gpt-4o",1865 "choices": [1866 {1867 "index": 0,1868 "delta": {1869 "role": "assistant",1870 "content": "",1871 "tool_calls": [1872 {1873 "index": 0,1874 "id": "call_abc",1875 "function": {"name": "get_weather", "arguments": ""},1876 }1877 ],1878 },1879 "logprobs": None,1880 "finish_reason": None,1881 }1882 ],1883 "usage": None,1884 }1885 gen = llm._convert_chunk_to_generation_chunk(tool_chunk, AIMessageChunk, None)1886 assert gen is not None1887 assert isinstance(gen.message.content, str)1888 assert gen.message.response_metadata.get("output_version") == "v1"1889 assert gen.message.response_metadata.get("model_provider") == "openai"189018911892def test_v1_streaming_tool_calls_in_content_blocks() -> None:1893 """End-to-end: streaming chunks with tool calls produce correct content_blocks."""1894 stream_chunks: list[dict[str, Any]] = [1895 # Initial empty-choices chunk1896 {1897 "id": "chatcmpl-test",1898 "object": "chat.completion.chunk",1899 "created": 0,1900 "model": "gpt-4o",1901 "choices": [1902 {1903 "index": 0,1904 "delta": {"role": "assistant", "content": ""},1905 "logprobs": None,1906 "finish_reason": None,1907 }1908 ],1909 "usage": None,1910 },1911 # Text token streamed before the tool call1912 {1913 "id": "chatcmpl-test",1914 "object": "chat.completion.chunk",1915 "created": 0,1916 "model": "gpt-4o",1917 "choices": [1918 {1919 "index": 0,1920 "delta": {"content": "Let me check the weather."},1921 "logprobs": None,1922 "finish_reason": None,1923 }1924 ],1925 "usage": None,1926 },1927 # Tool call start1928 {1929 "id": "chatcmpl-test",1930 "object": "chat.completion.chunk",1931 "created": 0,1932 "model": "gpt-4o",1933 "choices": [1934 {1935 "index": 0,1936 "delta": {1937 "tool_calls": [1938 {1939 "index": 0,1940 "id": "call_abc",1941 "function": {1942 "name": "get_weather",1943 "arguments": '{"loc',1944 },1945 }1946 ]1947 },1948 "logprobs": None,1949 "finish_reason": None,1950 }1951 ],1952 "usage": None,1953 },1954 # Tool call args continuation1955 {1956 "id": "chatcmpl-test",1957 "object": "chat.completion.chunk",1958 "created": 0,1959 "model": "gpt-4o",1960 "choices": [1961 {1962 "index": 0,1963 "delta": {1964 "tool_calls": [1965 {1966 "index": 0,1967 "function": {"arguments": 'ation": "SF"}'},1968 }1969 ]1970 },1971 "logprobs": None,1972 "finish_reason": None,1973 }1974 ],1975 "usage": None,1976 },1977 # Finish1978 {1979 "id": "chatcmpl-test",1980 "object": "chat.completion.chunk",1981 "created": 0,1982 "model": "gpt-4o",1983 "choices": [1984 {1985 "index": 0,1986 "delta": {},1987 "logprobs": None,1988 "finish_reason": "tool_calls",1989 }1990 ],1991 "usage": None,1992 },1993 # Usage chunk1994 {1995 "id": "chatcmpl-test",1996 "object": "chat.completion.chunk",1997 "created": 0,1998 "model": "gpt-4o",1999 "choices": [],2000 "usage": {
Findings
✓ No findings reported for this file.