release: v1.0.0 (#32567)

Co-authored-by: Mohammad Mohtashim <45242107+keenborder786@users.noreply.github.com>
Co-authored-by: Caspar Broekhuizen <caspar@langchain.dev>
Co-authored-by: ccurme <chester.curme@gmail.com>
Co-authored-by: Christophe Bornet <cbornet@hotmail.com>
Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
Co-authored-by: Sadra Barikbin <sadraqazvin1@yahoo.com>
Co-authored-by: Vadym Barda <vadim.barda@gmail.com>
This commit is contained in:
Mason Daugherty
2025-10-02 10:49:42 -04:00
committed by GitHub
parent d7cce2f469
commit eaa6dcce9e
188 changed files with 23644 additions and 17479 deletions

View File

@@ -1,6 +1,11 @@
"""This module converts between AIMessage output formats for the Responses API.
"""Converts between AIMessage output formats, governed by ``output_version``.
ChatOpenAI v0.3 stores reasoning and tool outputs in AIMessage.additional_kwargs:
``output_version`` is an attribute on ChatOpenAI.
Supported values are ``None``, ``'v0'``, and ``'responses/v1'``.
``'v0'`` corresponds to the format as of ``ChatOpenAI`` v0.3. For the Responses API, it
stores reasoning and tool outputs in ``AIMessage.additional_kwargs``:
.. code-block:: python
@@ -27,8 +32,9 @@ ChatOpenAI v0.3 stores reasoning and tool outputs in AIMessage.additional_kwargs
id="msg_123",
)
To retain information about response item sequencing (and to accommodate multiple
reasoning items), ChatOpenAI now stores these items in the content sequence:
``'responses/v1'`` is only applicable to the Responses API. It retains information
about response item sequencing and accommodates multiple reasoning items by
representing these items in the content sequence:
.. code-block:: python
@@ -56,24 +62,26 @@ There are other, small improvements as well-- e.g., we store message IDs on text
content blocks, rather than on the AIMessage.id, which now stores the response ID.
For backwards compatibility, this module provides functions to convert between the
old and new formats. The functions are used internally by ChatOpenAI.
formats. The functions are used internally by ChatOpenAI.
"""
from __future__ import annotations
import json
from typing import Union
from collections.abc import Iterable, Iterator
from typing import Any, Union, cast
from langchain_core.messages import AIMessage
from langchain_core.messages import AIMessage, is_data_content_block
from langchain_core.messages import content as types
_FUNCTION_CALL_IDS_MAP_KEY = "__openai_function_call_ids__"
# v0.3 / Responses
def _convert_to_v03_ai_message(
message: AIMessage, has_reasoning: bool = False
) -> AIMessage:
"""Mutate an AIMessage to the old-style v0.3 format."""
"""Mutate an `AIMessage` to the old-style v0.3 format."""
if isinstance(message.content, list):
new_content: list[Union[dict, str]] = []
for block in message.content:
@@ -142,115 +150,299 @@ def _convert_to_v03_ai_message(
return message
def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage:
"""Convert an old-style v0.3 AIMessage into the new content-block format."""
# Only update ChatOpenAI v0.3 AIMessages
# TODO: structure provenance into AIMessage
is_chatopenai_v03 = (
isinstance(message.content, list)
and all(isinstance(b, dict) for b in message.content)
) and (
any(
item in message.additional_kwargs
for item in [
"reasoning",
"tool_outputs",
"refusal",
_FUNCTION_CALL_IDS_MAP_KEY,
# v1 / Chat Completions
def _convert_from_v1_to_chat_completions(message: AIMessage) -> AIMessage:
"""Convert a v1 message to the Chat Completions format."""
if isinstance(message.content, list):
new_content: list = []
for block in message.content:
if isinstance(block, dict):
block_type = block.get("type")
if block_type == "text":
# Strip annotations
new_content.append({"type": "text", "text": block["text"]})
elif block_type in ("reasoning", "tool_call"):
pass
else:
new_content.append(block)
else:
new_content.append(block)
return message.model_copy(update={"content": new_content})
return message
# v1 / Responses
def _convert_annotation_from_v1(annotation: types.Annotation) -> dict[str, Any]:
"""Convert a v1 `Annotation` to the v0.3 format (for Responses API)."""
if annotation["type"] == "citation":
new_ann: dict[str, Any] = {}
for field in ("end_index", "start_index"):
if field in annotation:
new_ann[field] = annotation[field]
if "url" in annotation:
# URL citation
if "title" in annotation:
new_ann["title"] = annotation["title"]
new_ann["type"] = "url_citation"
new_ann["url"] = annotation["url"]
else:
# Document citation
new_ann["type"] = "file_citation"
if "title" in annotation:
new_ann["filename"] = annotation["title"]
if extra_fields := annotation.get("extras"):
new_ann.update(dict(extra_fields.items()))
return new_ann
if annotation["type"] == "non_standard_annotation":
return annotation["value"]
return dict(annotation)
def _implode_reasoning_blocks(blocks: list[dict[str, Any]]) -> Iterable[dict[str, Any]]:
i = 0
n = len(blocks)
while i < n:
block = blocks[i]
# Skip non-reasoning blocks or blocks already in Responses format
if block.get("type") != "reasoning" or "summary" in block:
yield dict(block)
i += 1
continue
elif "reasoning" not in block and "summary" not in block:
# {"type": "reasoning", "id": "rs_..."}
oai_format = {**block, "summary": []}
if "extras" in oai_format:
oai_format.update(oai_format.pop("extras"))
oai_format["type"] = oai_format.pop("type", "reasoning")
if "encrypted_content" in oai_format:
oai_format["encrypted_content"] = oai_format.pop("encrypted_content")
yield oai_format
i += 1
continue
else:
pass
summary: list[dict[str, str]] = [
{"type": "summary_text", "text": block.get("reasoning", "")}
]
# 'common' is every field except the exploded 'reasoning'
common = {k: v for k, v in block.items() if k != "reasoning"}
if "extras" in common:
common.update(common.pop("extras"))
i += 1
while i < n:
next_ = blocks[i]
if next_.get("type") == "reasoning" and "reasoning" in next_:
summary.append(
{"type": "summary_text", "text": next_.get("reasoning", "")}
)
i += 1
else:
break
merged = dict(common)
merged["summary"] = summary
merged["type"] = merged.pop("type", "reasoning")
yield merged
def _consolidate_calls(items: Iterable[dict[str, Any]]) -> Iterator[dict[str, Any]]:
"""Generator that walks through *items* and, whenever it meets the pair.
{"type": "server_tool_call", "name": "web_search", "id": X, ...}
{"type": "server_tool_result", "id": X}
merges them into
{"id": X,
"output": ...,
"status": ...,
"type": "web_search_call"}
keeping every other element untouched.
"""
items = iter(items) # make sure we have a true iterator
for current in items:
# Only a call can start a pair worth collapsing
if current.get("type") != "server_tool_call":
yield current
continue
try:
nxt = next(items) # look-ahead one element
except StopIteration: # no “result” - just yield the call back
yield current
break
# If this really is the matching “result” - collapse
if nxt.get("type") == "server_tool_result" and nxt.get(
"tool_call_id"
) == current.get("id"):
if current.get("name") == "web_search":
collapsed = {"id": current["id"]}
if "args" in current:
# N.B. as of 2025-09-17 OpenAI raises BadRequestError if sources
# are passed back in
collapsed["action"] = current["args"]
if status := nxt.get("status"):
if status == "success":
collapsed["status"] = "completed"
elif status == "error":
collapsed["status"] = "failed"
elif nxt.get("extras", {}).get("status"):
collapsed["status"] = nxt["extras"]["status"]
else:
pass
collapsed["type"] = "web_search_call"
if current.get("name") == "file_search":
collapsed = {"id": current["id"]}
if "args" in current and "queries" in current["args"]:
collapsed["queries"] = current["args"]["queries"]
if "output" in nxt:
collapsed["results"] = nxt["output"]
if status := nxt.get("status"):
if status == "success":
collapsed["status"] = "completed"
elif status == "error":
collapsed["status"] = "failed"
elif nxt.get("extras", {}).get("status"):
collapsed["status"] = nxt["extras"]["status"]
else:
pass
collapsed["type"] = "file_search_call"
elif current.get("name") == "code_interpreter":
collapsed = {"id": current["id"]}
if "args" in current and "code" in current["args"]:
collapsed["code"] = current["args"]["code"]
for key in ("container_id",):
if key in current:
collapsed[key] = current[key]
elif key in current.get("extras", {}):
collapsed[key] = current["extras"][key]
else:
pass
if "output" in nxt:
collapsed["outputs"] = nxt["output"]
if status := nxt.get("status"):
if status == "success":
collapsed["status"] = "completed"
elif status == "error":
collapsed["status"] = "failed"
elif nxt.get("extras", {}).get("status"):
collapsed["status"] = nxt["extras"]["status"]
collapsed["type"] = "code_interpreter_call"
elif current.get("name") == "remote_mcp":
collapsed = {"id": current["id"]}
if "args" in current:
collapsed["arguments"] = json.dumps(
current["args"], separators=(",", ":")
)
elif "arguments" in current.get("extras", {}):
collapsed["arguments"] = current["extras"]["arguments"]
else:
pass
if tool_name := current.get("extras", {}).get("tool_name"):
collapsed["name"] = tool_name
if server_label := current.get("extras", {}).get("server_label"):
collapsed["server_label"] = server_label
collapsed["type"] = "mcp_call"
if error := nxt.get("extras", {}).get("error"):
collapsed["error"] = error
if "output" in nxt:
collapsed["output"] = nxt["output"]
for k, v in current.get("extras", {}).items():
if k not in ("server_label", "arguments", "tool_name", "error"):
collapsed[k] = v
elif current.get("name") == "mcp_list_tools":
collapsed = {"id": current["id"]}
if server_label := current.get("extras", {}).get("server_label"):
collapsed["server_label"] = server_label
if "output" in nxt:
collapsed["tools"] = nxt["output"]
collapsed["type"] = "mcp_list_tools"
if error := nxt.get("extras", {}).get("error"):
collapsed["error"] = error
for k, v in current.get("extras", {}).items():
if k not in ("server_label", "error"):
collapsed[k] = v
else:
pass
yield collapsed
else:
# Not a matching pair - emit both, in original order
yield current
yield nxt
def _convert_from_v1_to_responses(
content: list[types.ContentBlock], tool_calls: list[types.ToolCall]
) -> list[dict[str, Any]]:
new_content: list = []
for block in content:
if block["type"] == "text" and "annotations" in block:
# Need a copy because we're changing the annotations list
new_block = dict(block)
new_block["annotations"] = [
_convert_annotation_from_v1(a) for a in block["annotations"]
]
)
or (
isinstance(message.id, str)
and message.id.startswith("msg_")
and (response_id := message.response_metadata.get("id"))
and isinstance(response_id, str)
and response_id.startswith("resp_")
)
)
if not is_chatopenai_v03:
return message
content_order = [
"reasoning",
"code_interpreter_call",
"mcp_call",
"image_generation_call",
"text",
"refusal",
"function_call",
"computer_call",
"mcp_list_tools",
"mcp_approval_request",
# N. B. "web_search_call" and "file_search_call" were not passed back in
# in v0.3
]
# Build a bucket for every known block type
buckets: dict[str, list] = {key: [] for key in content_order}
unknown_blocks = []
# Reasoning
if reasoning := message.additional_kwargs.get("reasoning"):
buckets["reasoning"].append(reasoning)
# Refusal
if refusal := message.additional_kwargs.get("refusal"):
buckets["refusal"].append({"type": "refusal", "refusal": refusal})
# Text
for block in message.content:
if isinstance(block, dict) and block.get("type") == "text":
block_copy = block.copy()
if isinstance(message.id, str) and message.id.startswith("msg_"):
block_copy["id"] = message.id
buckets["text"].append(block_copy)
else:
unknown_blocks.append(block)
# Function calls
function_call_ids = message.additional_kwargs.get(_FUNCTION_CALL_IDS_MAP_KEY)
for tool_call in message.tool_calls:
function_call = {
"type": "function_call",
"name": tool_call["name"],
"arguments": json.dumps(tool_call["args"], ensure_ascii=False),
"call_id": tool_call["id"],
}
if function_call_ids is not None and (
_id := function_call_ids.get(tool_call["id"])
new_content.append(new_block)
elif block["type"] == "tool_call":
new_block = {"type": "function_call", "call_id": block["id"]}
if "extras" in block and "item_id" in block["extras"]:
new_block["id"] = block["extras"]["item_id"]
if "name" in block:
new_block["name"] = block["name"]
if "extras" in block and "arguments" in block["extras"]:
new_block["arguments"] = block["extras"]["arguments"]
if any(key not in block for key in ("name", "arguments")):
matching_tool_calls = [
call for call in tool_calls if call["id"] == block["id"]
]
if matching_tool_calls:
tool_call = matching_tool_calls[0]
if "name" not in block:
new_block["name"] = tool_call["name"]
if "arguments" not in block:
new_block["arguments"] = json.dumps(tool_call["args"])
new_content.append(new_block)
elif (
is_data_content_block(cast(dict, block))
and block["type"] == "image"
and "base64" in block
and isinstance(block.get("id"), str)
and block["id"].startswith("ig_")
):
function_call["id"] = _id
buckets["function_call"].append(function_call)
# Tool outputs
tool_outputs = message.additional_kwargs.get("tool_outputs", [])
for block in tool_outputs:
if isinstance(block, dict) and (key := block.get("type")) and key in buckets:
buckets[key].append(block)
new_block = {"type": "image_generation_call", "result": block["base64"]}
for extra_key in ("id", "status"):
if extra_key in block:
new_block[extra_key] = block[extra_key] # type: ignore[typeddict-item]
elif extra_key in block.get("extras", {}):
new_block[extra_key] = block["extras"][extra_key]
new_content.append(new_block)
elif block["type"] == "non_standard" and "value" in block:
new_content.append(block["value"])
else:
unknown_blocks.append(block)
new_content.append(block)
# Re-assemble the content list in the canonical order
new_content = []
for key in content_order:
new_content.extend(buckets[key])
new_content.extend(unknown_blocks)
new_additional_kwargs = dict(message.additional_kwargs)
new_additional_kwargs.pop("reasoning", None)
new_additional_kwargs.pop("refusal", None)
new_additional_kwargs.pop("tool_outputs", None)
if "id" in message.response_metadata:
new_id = message.response_metadata["id"]
else:
new_id = message.id
return message.model_copy(
update={
"content": new_content,
"additional_kwargs": new_additional_kwargs,
"id": new_id,
},
deep=False,
)
new_content = list(_implode_reasoning_blocks(new_content))
return list(_consolidate_calls(new_content))

View File

@@ -174,7 +174,7 @@ class AzureChatOpenAI(BaseChatOpenAI):
.. code-block:: python
for chunk in llm.stream(messages):
print(chunk.text(), end="")
print(chunk.text, end="")
.. code-block:: python

View File

@@ -22,7 +22,6 @@ from typing import (
Callable,
Literal,
Optional,
TypedDict,
TypeVar,
Union,
cast,
@@ -32,7 +31,6 @@ from urllib.parse import urlparse
import certifi
import openai
import tiktoken
from langchain_core._api.deprecation import deprecated
from langchain_core.callbacks import (
AsyncCallbackManagerForLLMRun,
CallbackManagerForLLMRun,
@@ -61,14 +59,18 @@ from langchain_core.messages import (
ToolCall,
ToolMessage,
ToolMessageChunk,
convert_to_openai_data_block,
is_data_content_block,
)
from langchain_core.messages import content as types
from langchain_core.messages.ai import (
InputTokenDetails,
OutputTokenDetails,
UsageMetadata,
)
from langchain_core.messages.block_translators.openai import (
_convert_from_v03_ai_message,
convert_to_openai_data_block,
)
from langchain_core.messages.tool import tool_call_chunk
from langchain_core.output_parsers import JsonOutputParser, PydanticOutputParser
from langchain_core.output_parsers.openai_tools import (
@@ -107,7 +109,8 @@ from langchain_openai.chat_models._client_utils import (
_get_default_httpx_client,
)
from langchain_openai.chat_models._compat import (
_convert_from_v03_ai_message,
_convert_from_v1_to_chat_completions,
_convert_from_v1_to_responses,
_convert_to_v03_ai_message,
)
@@ -155,7 +158,6 @@ def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage:
tool_calls = []
invalid_tool_calls = []
if raw_tool_calls := _dict.get("tool_calls"):
additional_kwargs["tool_calls"] = raw_tool_calls
for raw_tool_call in raw_tool_calls:
try:
tool_calls.append(parse_tool_call(raw_tool_call, return_id=True))
@@ -199,7 +201,11 @@ def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage:
return ChatMessage(content=_dict.get("content", ""), role=role, id=id_) # type: ignore[arg-type]
def _format_message_content(content: Any) -> Any:
def _format_message_content(
content: Any,
api: Literal["chat/completions", "responses"] = "chat/completions",
role: Optional[str] = None,
) -> Any:
"""Format message content."""
if content and isinstance(content, list):
formatted_content = []
@@ -211,8 +217,14 @@ def _format_message_content(content: Any) -> Any:
and block["type"] in ("tool_use", "thinking", "reasoning_content")
):
continue
if isinstance(block, dict) and is_data_content_block(block):
formatted_content.append(convert_to_openai_data_block(block))
if (
isinstance(block, dict)
and is_data_content_block(block)
# Responses API messages handled separately in _compat (parsed into
# image generation calls)
and not (api == "responses" and str(role).lower().startswith("ai"))
):
formatted_content.append(convert_to_openai_data_block(block, api=api))
# Anthropic image blocks
elif (
isinstance(block, dict)
@@ -244,16 +256,14 @@ def _format_message_content(content: Any) -> Any:
return formatted_content
def _convert_message_to_dict(message: BaseMessage) -> dict:
"""Convert a LangChain message to a dictionary.
Args:
message: The LangChain message.
Returns:
The dictionary.
"""
message_dict: dict[str, Any] = {"content": _format_message_content(message.content)}
def _convert_message_to_dict(
message: BaseMessage,
api: Literal["chat/completions", "responses"] = "chat/completions",
) -> dict:
"""Convert a LangChain message to dictionary format expected by OpenAI."""
message_dict: dict[str, Any] = {
"content": _format_message_content(message.content, api=api, role=message.type)
}
if (name := message.name or message.additional_kwargs.get("name")) is not None:
message_dict["name"] = name
@@ -288,15 +298,25 @@ def _convert_message_to_dict(message: BaseMessage) -> dict:
if "function_call" in message_dict or "tool_calls" in message_dict:
message_dict["content"] = message_dict["content"] or None
if "audio" in message.additional_kwargs:
# openai doesn't support passing the data back - only the id
# https://platform.openai.com/docs/guides/audio/multi-turn-conversations
audio: Optional[dict[str, Any]] = None
for block in message.content:
if (
isinstance(block, dict)
and block.get("type") == "audio"
and (id_ := block.get("id"))
and api != "responses"
):
# openai doesn't support passing the data back - only the id
# https://platform.openai.com/docs/guides/audio/multi-turn-conversations
audio = {"id": id_}
if not audio and "audio" in message.additional_kwargs:
raw_audio = message.additional_kwargs["audio"]
audio = (
{"id": message.additional_kwargs["audio"]["id"]}
if "id" in raw_audio
else raw_audio
)
if audio:
message_dict["audio"] = audio
elif isinstance(message, SystemMessage):
message_dict["role"] = message.additional_kwargs.get(
@@ -331,7 +351,6 @@ def _convert_delta_to_message_chunk(
additional_kwargs["function_call"] = function_call
tool_call_chunks = []
if raw_tool_calls := _dict.get("tool_calls"):
additional_kwargs["tool_calls"] = raw_tool_calls
try:
tool_call_chunks = [
tool_call_chunk(
@@ -426,10 +445,6 @@ def _handle_openai_bad_request(e: openai.BadRequestError) -> None:
raise
class _FunctionCall(TypedDict):
name: str
_BM = TypeVar("_BM", bound=BaseModel)
_DictOrPydanticClass = Union[dict[str, Any], type[_BM], type]
_DictOrPydantic = Union[dict, _BM]
@@ -656,11 +671,7 @@ class BaseChatOpenAI(BaseChatModel):
.. code-block:: python
llm = ChatOpenAI(
model="o4-mini",
use_responses_api=True,
output_version="responses/v1",
)
llm = ChatOpenAI(model="o4-mini", use_responses_api=True)
llm.invoke([HumanMessage("How are you?")], previous_response_id="resp_123")
.. versionadded:: 0.3.26
@@ -675,7 +686,9 @@ class BaseChatOpenAI(BaseChatModel):
.. versionadded:: 0.3.9
"""
output_version: Literal["v0", "responses/v1"] = "v0"
output_version: Optional[str] = Field(
default_factory=from_env("LC_OUTPUT_VERSION", default=None)
)
"""Version of AIMessage output format to use.
This field is used to roll-out new output formats for chat model AIMessages
@@ -685,13 +698,15 @@ class BaseChatOpenAI(BaseChatModel):
- ``'v0'``: AIMessage format as of langchain-openai 0.3.x.
- ``'responses/v1'``: Formats Responses API output
items into AIMessage content blocks.
Currently only impacts the Responses API. ``output_version='responses/v1'`` is
recommended.
items into AIMessage content blocks (Responses API only)
- ``"v1"``: v1 of LangChain cross-provider standard.
.. versionadded:: 0.3.25
.. versionchanged:: 1.0.0
Default updated to ``"responses/v1"``.
"""
model_config = ConfigDict(populate_by_name=True)
@@ -891,10 +906,15 @@ class BaseChatOpenAI(BaseChatModel):
)
if len(choices) == 0:
# logprobs is implicitly None
return ChatGenerationChunk(
generation_chunk = ChatGenerationChunk(
message=default_chunk_class(content="", usage_metadata=usage_metadata),
generation_info=base_generation_info,
)
if self.output_version == "v1":
generation_chunk.message.content = []
generation_chunk.message.response_metadata["output_version"] = "v1"
return generation_chunk
choice = choices[0]
if choice["delta"] is None:
@@ -913,6 +933,8 @@ class BaseChatOpenAI(BaseChatModel):
generation_info["system_fingerprint"] = system_fingerprint
if service_tier := chunk.get("service_tier"):
generation_info["service_tier"] = service_tier
if isinstance(message_chunk, AIMessageChunk):
message_chunk.chunk_position = "last"
logprobs = choice.get("logprobs")
if logprobs:
@@ -921,6 +943,7 @@ class BaseChatOpenAI(BaseChatModel):
if usage_metadata and isinstance(message_chunk, AIMessageChunk):
message_chunk.usage_metadata = usage_metadata
message_chunk.response_metadata["model_provider"] = "openai"
return ChatGenerationChunk(
message=message_chunk, generation_info=generation_info or None
)
@@ -1219,7 +1242,12 @@ class BaseChatOpenAI(BaseChatModel):
else:
payload = _construct_responses_api_payload(messages, payload)
else:
payload["messages"] = [_convert_message_to_dict(m) for m in messages]
payload["messages"] = [
_convert_message_to_dict(_convert_from_v1_to_chat_completions(m))
if isinstance(m, AIMessage)
else _convert_message_to_dict(m)
for m in messages
]
return payload
def _create_chat_result(
@@ -1268,6 +1296,7 @@ class BaseChatOpenAI(BaseChatModel):
generations.append(gen)
llm_output = {
"token_usage": token_usage,
"model_provider": "openai",
"model_name": response_dict.get("model", self.model_name),
"system_fingerprint": response_dict.get("system_fingerprint", ""),
}
@@ -1508,7 +1537,7 @@ class BaseChatOpenAI(BaseChatModel):
def get_num_tokens_from_messages(
self,
messages: list[BaseMessage],
messages: Sequence[BaseMessage],
tools: Optional[
Sequence[Union[dict[str, Any], type, Callable, BaseTool]]
] = None,
@@ -1603,66 +1632,6 @@ class BaseChatOpenAI(BaseChatModel):
num_tokens += 3
return num_tokens
@deprecated(
since="0.2.1",
alternative="langchain_openai.chat_models.base.ChatOpenAI.bind_tools",
removal="1.0.0",
)
def bind_functions(
self,
functions: Sequence[Union[dict[str, Any], type[BaseModel], Callable, BaseTool]],
function_call: Optional[
Union[_FunctionCall, str, Literal["auto", "none"]] # noqa: PYI051
] = None,
**kwargs: Any,
) -> Runnable[LanguageModelInput, BaseMessage]:
"""Bind functions (and other objects) to this chat model.
Assumes model is compatible with OpenAI function-calling API.
.. note::
Using ``bind_tools()`` is recommended instead, as the ``functions`` and
``function_call`` request parameters are officially marked as deprecated by
OpenAI.
Args:
functions: A list of function definitions to bind to this chat model.
Can be a dictionary, pydantic model, or callable. Pydantic
models and callables will be automatically converted to
their schema dictionary representation.
function_call: Which function to require the model to call.
Must be the name of the single provided function or
``'auto'`` to automatically determine which function to call
(if any).
**kwargs: Any additional parameters to pass to the
:class:`~langchain.runnable.Runnable` constructor.
"""
formatted_functions = [convert_to_openai_function(fn) for fn in functions]
if function_call is not None:
function_call = (
{"name": function_call}
if isinstance(function_call, str)
and function_call not in ("auto", "none")
else function_call
)
if isinstance(function_call, dict) and len(formatted_functions) != 1:
msg = (
"When specifying `function_call`, you must provide exactly one "
"function."
)
raise ValueError(msg)
if (
isinstance(function_call, dict)
and formatted_functions[0]["name"] != function_call["name"]
):
msg = (
f"Function call {function_call} was specified, but the only "
f"provided function was {formatted_functions[0]['name']}."
)
raise ValueError(msg)
kwargs = {**kwargs, "function_call": function_call}
return super().bind(functions=formatted_functions, **kwargs)
def bind_tools(
self,
tools: Sequence[Union[dict[str, Any], type, Callable, BaseTool]],
@@ -1673,7 +1642,7 @@ class BaseChatOpenAI(BaseChatModel):
strict: Optional[bool] = None,
parallel_tool_calls: Optional[bool] = None,
**kwargs: Any,
) -> Runnable[LanguageModelInput, BaseMessage]:
) -> Runnable[LanguageModelInput, AIMessage]:
"""Bind tool-like objects to this chat model.
Assumes model is compatible with OpenAI tool-calling API.
@@ -2174,7 +2143,7 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override]
.. code-block:: python
for chunk in llm.stream(messages):
print(chunk.text(), end="")
print(chunk.text, end="")
.. code-block:: python
@@ -2404,7 +2373,7 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override]
output_version="responses/v1",
)
response = llm.invoke("Hi, I'm Bob.")
response.text()
response.text
.. code-block:: python
@@ -2416,7 +2385,7 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override]
"What is my name?",
previous_response_id=response.response_metadata["id"],
)
second_response.text()
second_response.text
.. code-block:: python
@@ -2465,7 +2434,7 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override]
response = llm.invoke("What is 3^3?")
# Response text
print(f"Output: {response.text()}")
print(f"Output: {response.text}")
# Reasoning summaries
for block in response.content:
@@ -3386,6 +3355,20 @@ def _oai_structured_outputs_parser(
return parsed
if ai_msg.additional_kwargs.get("refusal"):
raise OpenAIRefusalError(ai_msg.additional_kwargs["refusal"])
if any(
isinstance(block, dict)
and block.get("type") == "non_standard"
and "refusal" in block["value"]
for block in ai_msg.content
):
refusal = next(
block["value"]["refusal"]
for block in ai_msg.content
if isinstance(block, dict)
and block["type"] == "non_standard"
and "refusal" in block["value"]
)
raise OpenAIRefusalError(refusal)
if ai_msg.tool_calls:
return None
msg = (
@@ -3503,7 +3486,7 @@ def _get_last_messages(
msg = messages[i]
if isinstance(msg, AIMessage):
response_id = msg.response_metadata.get("id")
if response_id:
if response_id and response_id.startswith("resp_"):
return messages[i + 1 :], response_id
# Continue searching for an AIMessage with a valid response_id
@@ -3660,23 +3643,42 @@ def _ensure_valid_tool_message_content(tool_output: Any) -> Union[str, list[dict
return _stringify(tool_output)
def _make_computer_call_output_from_message(message: ToolMessage) -> dict:
computer_call_output: dict = {
"call_id": message.tool_call_id,
"type": "computer_call_output",
}
def _make_computer_call_output_from_message(
message: ToolMessage,
) -> Optional[dict[str, Any]]:
computer_call_output: Optional[dict[str, Any]] = None
if isinstance(message.content, list):
# Use first input_image block
output = next(
block
for block in message.content
if cast(dict, block)["type"] == "input_image"
)
else:
for block in message.content:
if (
message.additional_kwargs.get("type") == "computer_call_output"
and isinstance(block, dict)
and block.get("type") == "input_image"
):
# Use first input_image block
computer_call_output = {
"call_id": message.tool_call_id,
"type": "computer_call_output",
"output": block,
}
break
if (
isinstance(block, dict)
and block.get("type") == "non_standard"
and block.get("value", {}).get("type") == "computer_call_output"
):
computer_call_output = block["value"]
break
elif message.additional_kwargs.get("type") == "computer_call_output":
# string, assume image_url
output = {"type": "input_image", "image_url": message.content}
computer_call_output["output"] = output
if "acknowledged_safety_checks" in message.additional_kwargs:
computer_call_output = {
"call_id": message.tool_call_id,
"type": "computer_call_output",
"output": {"type": "input_image", "image_url": message.content},
}
if (
computer_call_output is not None
and "acknowledged_safety_checks" in message.additional_kwargs
):
computer_call_output["acknowledged_safety_checks"] = message.additional_kwargs[
"acknowledged_safety_checks"
]
@@ -3693,6 +3695,13 @@ def _make_custom_tool_output_from_message(message: ToolMessage) -> Optional[dict
"output": block.get("output") or "",
}
break
if (
isinstance(block, dict)
and block.get("type") == "non_standard"
and block.get("value", {}).get("type") == "custom_tool_call_output"
):
custom_tool_output = block["value"]
break
return custom_tool_output
@@ -3718,20 +3727,40 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
for lc_msg in messages:
if isinstance(lc_msg, AIMessage):
lc_msg = _convert_from_v03_ai_message(lc_msg)
msg = _convert_message_to_dict(lc_msg)
msg = _convert_message_to_dict(lc_msg, api="responses")
if isinstance(msg.get("content"), list) and all(
isinstance(block, dict) for block in msg["content"]
):
tcs: list[types.ToolCall] = [
{
"type": "tool_call",
"name": tool_call["name"],
"args": tool_call["args"],
"id": tool_call.get("id"),
}
for tool_call in lc_msg.tool_calls
]
msg["content"] = _convert_from_v1_to_responses(msg["content"], tcs)
else:
msg = _convert_message_to_dict(lc_msg, api="responses")
# Get content from non-standard content blocks
if isinstance(msg["content"], list):
for i, block in enumerate(msg["content"]):
if isinstance(block, dict) and block.get("type") == "non_standard":
msg["content"][i] = block["value"]
# "name" parameter unsupported
if "name" in msg:
msg.pop("name")
if msg["role"] == "tool":
tool_output = msg["content"]
computer_call_output = _make_computer_call_output_from_message(
cast(ToolMessage, lc_msg)
)
custom_tool_output = _make_custom_tool_output_from_message(lc_msg) # type: ignore[arg-type]
if custom_tool_output:
input_.append(custom_tool_output)
elif lc_msg.additional_kwargs.get("type") == "computer_call_output":
computer_call_output = _make_computer_call_output_from_message(
cast(ToolMessage, lc_msg)
)
if computer_call_output:
input_.append(computer_call_output)
elif custom_tool_output:
input_.append(custom_tool_output)
else:
tool_output = _ensure_valid_tool_message_content(tool_output)
function_call_output = {
@@ -3872,12 +3901,18 @@ def _construct_lc_result_from_responses_api(
response: Response,
schema: Optional[type[_BM]] = None,
metadata: Optional[dict] = None,
output_version: Literal["v0", "responses/v1"] = "v0",
output_version: Optional[str] = None,
) -> ChatResult:
"""Construct ChatResponse from OpenAI Response API response."""
if response.error:
raise ValueError(response.error)
if output_version is None:
# Sentinel value of None lets us know if output_version is set explicitly.
# Explicitly setting `output_version="responses/v1"` separately enables the
# Responses API.
output_version = "responses/v1"
response_metadata = {
k: v
for k, v in response.model_dump(exclude_none=True, mode="json").items()
@@ -3899,6 +3934,7 @@ def _construct_lc_result_from_responses_api(
if metadata:
response_metadata.update(metadata)
# for compatibility with chat completion calls.
response_metadata["model_provider"] = "openai"
response_metadata["model_name"] = response_metadata.get("model")
if response.usage:
usage_metadata = _create_usage_metadata_responses(response.usage.model_dump())
@@ -4012,6 +4048,7 @@ def _construct_lc_result_from_responses_api(
additional_kwargs["parsed"] = parsed
except json.JSONDecodeError:
pass
message = AIMessage(
content=content_blocks,
id=response.id,
@@ -4023,8 +4060,7 @@ def _construct_lc_result_from_responses_api(
)
if output_version == "v0":
message = _convert_to_v03_ai_message(message)
else:
pass
return ChatResult(generations=[ChatGeneration(message=message)])
@@ -4036,7 +4072,7 @@ def _convert_responses_chunk_to_generation_chunk(
schema: Optional[type[_BM]] = None,
metadata: Optional[dict] = None,
has_reasoning: bool = False,
output_version: Literal["v0", "responses/v1"] = "v0",
output_version: Optional[str] = None,
) -> tuple[int, int, int, Optional[ChatGenerationChunk]]:
def _advance(output_idx: int, sub_idx: Optional[int] = None) -> None:
"""Advance indexes tracked during streaming.
@@ -4083,11 +4119,19 @@ def _convert_responses_chunk_to_generation_chunk(
current_sub_index = sub_idx
current_output_index = output_idx
if output_version is None:
# Sentinel value of None lets us know if output_version is set explicitly.
# Explicitly setting `output_version="responses/v1"` separately enables the
# Responses API.
output_version = "responses/v1"
content = []
tool_call_chunks: list = []
additional_kwargs: dict = {}
response_metadata = metadata or {}
response_metadata["model_provider"] = "openai"
usage_metadata = None
chunk_position: Optional[Literal["last"]] = None
id = None
if chunk.type == "response.output_text.delta":
_advance(chunk.output_index, chunk.content_index)
@@ -4099,9 +4143,12 @@ def _convert_responses_chunk_to_generation_chunk(
annotation = chunk.annotation
else:
annotation = chunk.annotation.model_dump(exclude_none=True, mode="json")
content.append({"annotations": [annotation], "index": current_index})
content.append(
{"type": "text", "annotations": [annotation], "index": current_index}
)
elif chunk.type == "response.output_text.done":
content.append({"id": chunk.item_id, "index": current_index})
content.append({"type": "text", "id": chunk.item_id, "index": current_index})
elif chunk.type == "response.created":
id = chunk.response.id
response_metadata["id"] = chunk.response.id # Backwards compatibility
@@ -4122,6 +4169,7 @@ def _convert_responses_chunk_to_generation_chunk(
response_metadata = {
k: v for k, v in msg.response_metadata.items() if k != "id"
}
chunk_position = "last"
elif chunk.type == "response.output_item.added" and chunk.item.type == "message":
if output_version == "v0":
id = chunk.item.id
@@ -4194,6 +4242,7 @@ def _convert_responses_chunk_to_generation_chunk(
content.append({"type": "refusal", "refusal": chunk.refusal})
elif chunk.type == "response.output_item.added" and chunk.item.type == "reasoning":
_advance(chunk.output_index)
current_sub_index = 0
reasoning = chunk.item.model_dump(exclude_none=True, mode="json")
reasoning["index"] = current_index
content.append(reasoning)
@@ -4207,6 +4256,7 @@ def _convert_responses_chunk_to_generation_chunk(
],
"index": current_index,
"type": "reasoning",
"id": chunk.item_id,
}
)
elif chunk.type == "response.image_generation_call.partial_image":
@@ -4237,14 +4287,14 @@ def _convert_responses_chunk_to_generation_chunk(
response_metadata=response_metadata,
additional_kwargs=additional_kwargs,
id=id,
chunk_position=chunk_position,
)
if output_version == "v0":
message = cast(
AIMessageChunk,
_convert_to_v03_ai_message(message, has_reasoning=has_reasoning),
)
else:
pass
return (
current_index,
current_output_index,