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

@@ -24,7 +24,6 @@
}),
'openai_api_type': 'azure',
'openai_api_version': '2021-10-01',
'output_version': 'v0',
'request_timeout': 60.0,
'stop': list([
]),

View File

@@ -18,7 +18,6 @@
'lc': 1,
'type': 'secret',
}),
'output_version': 'v0',
'request_timeout': 60.0,
'stop': list([
]),

View File

@@ -18,7 +18,6 @@
'lc': 1,
'type': 'secret',
}),
'output_version': 'v0',
'request_timeout': 60.0,
'stop': list([
]),

View File

@@ -22,14 +22,18 @@ from langchain_core.messages import (
ToolCall,
ToolMessage,
)
from langchain_core.messages import content as types
from langchain_core.messages.ai import UsageMetadata
from langchain_core.messages.block_translators.openai import (
_convert_from_v03_ai_message,
)
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.runnables import RunnableLambda
from langchain_core.runnables.base import RunnableBinding, RunnableSequence
from langchain_core.tracers.base import BaseTracer
from langchain_core.tracers.schemas import Run
from openai.types.responses import ResponseOutputMessage, ResponseReasoningItem
from openai.types.responses.response import IncompleteDetails, Response, ResponseUsage
from openai.types.responses.response import IncompleteDetails, Response
from openai.types.responses.response_error import ResponseError
from openai.types.responses.response_file_search_tool_call import (
ResponseFileSearchToolCall,
@@ -46,6 +50,7 @@ from openai.types.responses.response_reasoning_item import Summary
from openai.types.responses.response_usage import (
InputTokensDetails,
OutputTokensDetails,
ResponseUsage,
)
from pydantic import BaseModel, Field, SecretStr
from typing_extensions import Self, TypedDict
@@ -53,7 +58,8 @@ from typing_extensions import Self, TypedDict
from langchain_openai import ChatOpenAI
from langchain_openai.chat_models._compat import (
_FUNCTION_CALL_IDS_MAP_KEY,
_convert_from_v03_ai_message,
_convert_from_v1_to_chat_completions,
_convert_from_v1_to_responses,
_convert_to_v03_ai_message,
)
from langchain_openai.chat_models.base import (
@@ -204,7 +210,6 @@ def test__convert_dict_to_message_tool_call() -> None:
result = _convert_dict_to_message(message)
expected_output = AIMessage(
content="",
additional_kwargs={"tool_calls": [raw_tool_call]},
tool_calls=[
ToolCall(
name="GenerateUsername",
@@ -238,7 +243,6 @@ def test__convert_dict_to_message_tool_call() -> None:
result = _convert_dict_to_message(message)
expected_output = AIMessage(
content="",
additional_kwargs={"tool_calls": raw_tool_calls},
invalid_tool_calls=[
InvalidToolCall(
name="GenerateUsername",
@@ -731,17 +735,22 @@ def test_format_message_content() -> None:
assert _format_message_content(content) == [{"type": "text", "text": "hello"}]
# Standard multi-modal inputs
content = [{"type": "image", "source_type": "url", "url": "https://..."}]
contents = [
{"type": "image", "source_type": "url", "url": "https://..."}, # v0
{"type": "image", "url": "https://..."}, # v1
]
expected = [{"type": "image_url", "image_url": {"url": "https://..."}}]
assert expected == _format_message_content(content)
for content in contents:
assert expected == _format_message_content([content])
content = [
contents = [
{
"type": "image",
"source_type": "base64",
"data": "<base64 data>",
"mime_type": "image/png",
}
},
{"type": "image", "base64": "<base64 data>", "mime_type": "image/png"},
]
expected = [
{
@@ -749,16 +758,23 @@ def test_format_message_content() -> None:
"image_url": {"url": "data:image/png;base64,<base64 data>"},
}
]
assert expected == _format_message_content(content)
for content in contents:
assert expected == _format_message_content([content])
content = [
contents = [
{
"type": "file",
"source_type": "base64",
"data": "<base64 data>",
"mime_type": "application/pdf",
"filename": "my_file",
}
},
{
"type": "file",
"base64": "<base64 data>",
"mime_type": "application/pdf",
"filename": "my_file",
},
]
expected = [
{
@@ -769,11 +785,32 @@ def test_format_message_content() -> None:
},
}
]
assert expected == _format_message_content(content)
for content in contents:
assert expected == _format_message_content([content])
content = [{"type": "file", "source_type": "id", "id": "file-abc123"}]
# Test warn if PDF is missing a filename
pdf_block = {
"type": "file",
"base64": "<base64 data>",
"mime_type": "application/pdf",
}
expected = [
# N.B. this format is invalid for OpenAI
{
"type": "file",
"file": {"file_data": "data:application/pdf;base64,<base64 data>"},
}
]
with pytest.warns(match="filename"):
assert expected == _format_message_content([pdf_block])
contents = [
{"type": "file", "source_type": "id", "id": "file-abc123"},
{"type": "file", "file_id": "file-abc123"},
]
expected = [{"type": "file", "file": {"file_id": "file-abc123"}}]
assert expected == _format_message_content(content)
for content in contents:
assert expected == _format_message_content([content])
class GenerateUsername(BaseModel):
@@ -1161,9 +1198,6 @@ def test_minimal_reasoning_effort_payload(
**kwargs,
}
if use_responses_api:
init_kwargs["output_version"] = "responses/v1"
llm = ChatOpenAI(**init_kwargs)
messages = [
@@ -1189,14 +1223,14 @@ def test_minimal_reasoning_effort_payload(
assert payload["max_completion_tokens"] == 100
def test_output_version_compat() -> None:
llm = ChatOpenAI(model="gpt-5", output_version="responses/v1")
assert llm._use_responses_api({}) is True
def test_verbosity_parameter_payload() -> None:
"""Test verbosity parameter is included in request payload for Responses API."""
llm = ChatOpenAI(
model="gpt-5",
verbosity="high",
use_responses_api=True,
output_version="responses/v1",
)
llm = ChatOpenAI(model="gpt-5", verbosity="high", use_responses_api=True)
messages = [{"role": "user", "content": "hello"}]
payload = llm._get_request_payload(messages, stop=None)
@@ -1231,7 +1265,7 @@ def test_structured_outputs_parser() -> None:
serialized = dumps(llm_output)
deserialized = loads(serialized)
assert isinstance(deserialized, ChatGeneration)
result = output_parser.invoke(deserialized.message)
result = output_parser.invoke(cast(AIMessage, deserialized.message))
assert result == parsed_response
@@ -1288,7 +1322,7 @@ def test__construct_lc_result_from_responses_api_basic_text_response() -> None:
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
assert isinstance(result, ChatResult)
assert len(result.generations) == 1
@@ -1306,9 +1340,7 @@ def test__construct_lc_result_from_responses_api_basic_text_response() -> None:
assert result.generations[0].message.response_metadata["model_name"] == "gpt-4o"
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
assert result.generations[0].message.content == [
{"type": "text", "text": "Hello, world!", "annotations": [], "id": "msg_123"}
]
@@ -1344,7 +1376,7 @@ def test__construct_lc_result_from_responses_api_multiple_text_blocks() -> None:
],
)
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
assert len(result.generations[0].message.content) == 2
assert result.generations[0].message.content == [
@@ -1391,7 +1423,7 @@ def test__construct_lc_result_from_responses_api_multiple_messages() -> None:
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
assert result.generations[0].message.content == [
{"type": "text", "text": "foo", "annotations": []},
@@ -1407,9 +1439,7 @@ def test__construct_lc_result_from_responses_api_multiple_messages() -> None:
assert result.generations[0].message.id == "msg_234"
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
assert result.generations[0].message.content == [
{"type": "text", "text": "foo", "annotations": [], "id": "msg_123"},
@@ -1449,16 +1479,14 @@ def test__construct_lc_result_from_responses_api_refusal_response() -> None:
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
assert result.generations[0].message.additional_kwargs["refusal"] == (
"I cannot assist with that request."
)
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
assert result.generations[0].message.content == [
{
"type": "refusal",
@@ -1490,7 +1518,7 @@ def test__construct_lc_result_from_responses_api_function_call_valid_json() -> N
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
msg: AIMessage = cast(AIMessage, result.generations[0].message)
assert len(msg.tool_calls) == 1
@@ -1507,9 +1535,7 @@ def test__construct_lc_result_from_responses_api_function_call_valid_json() -> N
)
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
msg = cast(AIMessage, result.generations[0].message)
assert msg.tool_calls
assert msg.content == [
@@ -1545,7 +1571,7 @@ def test__construct_lc_result_from_responses_api_function_call_invalid_json() ->
],
)
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
msg: AIMessage = cast(AIMessage, result.generations[0].message)
assert len(msg.invalid_tool_calls) == 1
@@ -1599,7 +1625,7 @@ def test__construct_lc_result_from_responses_api_complex_response() -> None:
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
# Check message content
assert result.generations[0].message.content == [
@@ -1628,9 +1654,7 @@ def test__construct_lc_result_from_responses_api_complex_response() -> None:
assert result.generations[0].message.response_metadata["user"] == "user_123"
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
msg = cast(AIMessage, result.generations[0].message)
assert msg.response_metadata["metadata"] == {"key1": "value1", "key2": "value2"}
assert msg.content == [
@@ -1706,7 +1730,7 @@ def test__construct_lc_result_from_responses_api_web_search_response() -> None:
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
assert "tool_outputs" in result.generations[0].message.additional_kwargs
assert len(result.generations[0].message.additional_kwargs["tool_outputs"]) == 1
@@ -1724,9 +1748,7 @@ def test__construct_lc_result_from_responses_api_web_search_response() -> None:
)
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
assert result.generations[0].message.content == [
{
"type": "web_search_call",
@@ -1767,7 +1789,7 @@ def test__construct_lc_result_from_responses_api_file_search_response() -> None:
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
assert "tool_outputs" in result.generations[0].message.additional_kwargs
assert len(result.generations[0].message.additional_kwargs["tool_outputs"]) == 1
@@ -1808,9 +1830,7 @@ def test__construct_lc_result_from_responses_api_file_search_response() -> None:
)
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
assert result.generations[0].message.content == [
{
"type": "file_search_call",
@@ -1877,7 +1897,7 @@ def test__construct_lc_result_from_responses_api_mixed_search_responses() -> Non
)
# v0
result = _construct_lc_result_from_responses_api(response)
result = _construct_lc_result_from_responses_api(response, output_version="v0")
# Check message content
assert result.generations[0].message.content == [
@@ -1908,9 +1928,7 @@ def test__construct_lc_result_from_responses_api_mixed_search_responses() -> Non
assert file_search["results"][0]["filename"] == "example.py"
# responses/v1
result = _construct_lc_result_from_responses_api(
response, output_version="responses/v1"
)
result = _construct_lc_result_from_responses_api(response)
assert result.generations[0].message.content == [
{
"type": "text",
@@ -2276,9 +2294,7 @@ def test__construct_responses_api_input_multiple_message_types() -> None:
assert messages_copy == messages
# Test dict messages
llm = ChatOpenAI(
model="o4-mini", use_responses_api=True, output_version="responses/v1"
)
llm = ChatOpenAI(model="o4-mini", use_responses_api=True)
message_dicts: list = [
{"role": "developer", "content": "This is a developer message."},
{
@@ -2380,7 +2396,7 @@ def test_mcp_tracing() -> None:
assert payload["tools"][0]["headers"]["Authorization"] == "Bearer PLACEHOLDER"
def test_compat() -> None:
def test_compat_responses_v03() -> None:
# Check compatibility with v0.3 message format
message_v03 = AIMessage(
content=[
@@ -2441,6 +2457,178 @@ def test_compat() -> None:
assert message_v03_output is not message_v03
@pytest.mark.parametrize(
("message_v1", "expected"),
[
(
AIMessage(
[
{"type": "reasoning", "reasoning": "Reasoning text"},
{
"type": "tool_call",
"id": "call_123",
"name": "get_weather",
"args": {"location": "San Francisco"},
},
{
"type": "text",
"text": "Hello, world!",
"annotations": [
{"type": "citation", "url": "https://example.com"}
],
},
],
id="chatcmpl-123",
response_metadata={"model_provider": "openai", "model_name": "gpt-4.1"},
),
AIMessage(
[{"type": "text", "text": "Hello, world!"}],
id="chatcmpl-123",
response_metadata={"model_provider": "openai", "model_name": "gpt-4.1"},
),
)
],
)
def test_convert_from_v1_to_chat_completions(
message_v1: AIMessage, expected: AIMessage
) -> None:
result = _convert_from_v1_to_chat_completions(message_v1)
assert result == expected
assert result.tool_calls == message_v1.tool_calls # tool calls remain cached
# Check no mutation
assert message_v1 != result
@pytest.mark.parametrize(
("message_v1", "expected"),
[
(
AIMessage(
content_blocks=[
{"type": "reasoning", "id": "abc123"},
{"type": "reasoning", "id": "abc234", "reasoning": "foo "},
{"type": "reasoning", "id": "abc234", "reasoning": "bar"},
{
"type": "tool_call",
"id": "call_123",
"name": "get_weather",
"args": {"location": "San Francisco"},
},
{
"type": "tool_call",
"id": "call_234",
"name": "get_weather_2",
"args": {"location": "New York"},
"extras": {"item_id": "fc_123"},
},
{"type": "text", "text": "Hello "},
{
"type": "text",
"text": "world",
"annotations": [
{"type": "citation", "url": "https://example.com"},
{
"type": "citation",
"title": "my doc",
"extras": {"file_id": "file_123", "index": 1},
},
{
"type": "non_standard_annotation",
"value": {"bar": "baz"},
},
],
},
{"type": "image", "base64": "...", "id": "ig_123"},
{
"type": "server_tool_call",
"name": "file_search",
"id": "fs_123",
"args": {"queries": ["query for file search"]},
},
{
"type": "server_tool_result",
"tool_call_id": "fs_123",
"output": [{"file_id": "file-123"}],
"status": "success",
},
{
"type": "non_standard",
"value": {"type": "something_else", "foo": "bar"},
},
],
id="resp123",
),
[
{"type": "reasoning", "id": "abc123", "summary": []},
{
"type": "reasoning",
"id": "abc234",
"summary": [
{"type": "summary_text", "text": "foo "},
{"type": "summary_text", "text": "bar"},
],
},
{
"type": "function_call",
"call_id": "call_123",
"name": "get_weather",
"arguments": '{"location": "San Francisco"}',
},
{
"type": "function_call",
"call_id": "call_234",
"name": "get_weather_2",
"arguments": '{"location": "New York"}',
"id": "fc_123",
},
{"type": "text", "text": "Hello "},
{
"type": "text",
"text": "world",
"annotations": [
{"type": "url_citation", "url": "https://example.com"},
{
"type": "file_citation",
"filename": "my doc",
"index": 1,
"file_id": "file_123",
},
{"bar": "baz"},
],
},
{"type": "image_generation_call", "id": "ig_123", "result": "..."},
{
"type": "file_search_call",
"id": "fs_123",
"queries": ["query for file search"],
"results": [{"file_id": "file-123"}],
"status": "completed",
},
{"type": "something_else", "foo": "bar"},
],
)
],
)
def test_convert_from_v1_to_responses(
message_v1: AIMessage, expected: list[dict[str, Any]]
) -> None:
tcs: list[types.ToolCall] = [
{
"type": "tool_call",
"name": tool_call["name"],
"args": tool_call["args"],
"id": tool_call.get("id"),
}
for tool_call in message_v1.tool_calls
]
result = _convert_from_v1_to_responses(message_v1.content_blocks, tcs)
assert result == expected
# Check no mutation
assert message_v1 != result
def test_get_last_messages() -> None:
messages: list[BaseMessage] = [HumanMessage("Hello")]
last_messages, previous_response_id = _get_last_messages(messages)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any, Optional
from unittest.mock import MagicMock, patch
import pytest
from langchain_core.messages import AIMessageChunk, BaseMessageChunk
from openai.types.responses import (
ResponseCompletedEvent,
@@ -22,7 +23,7 @@ from openai.types.responses import (
ResponseTextDeltaEvent,
ResponseTextDoneEvent,
)
from openai.types.responses.response import Response, ResponseUsage
from openai.types.responses.response import Response
from openai.types.responses.response_output_text import ResponseOutputText
from openai.types.responses.response_reasoning_item import Summary
from openai.types.responses.response_reasoning_summary_part_added_event import (
@@ -34,6 +35,7 @@ from openai.types.responses.response_reasoning_summary_part_done_event import (
from openai.types.responses.response_usage import (
InputTokensDetails,
OutputTokensDetails,
ResponseUsage,
)
from openai.types.shared.reasoning import Reasoning
from openai.types.shared.response_format_text import ResponseFormatText
@@ -339,7 +341,7 @@ responses_stream = [
id="rs_234",
summary=[],
type="reasoning",
encrypted_content=None,
encrypted_content="encrypted-content",
status=None,
),
output_index=2,
@@ -418,7 +420,7 @@ responses_stream = [
Summary(text="still more reasoning", type="summary_text"),
],
type="reasoning",
encrypted_content=None,
encrypted_content="encrypted-content",
status=None,
),
output_index=2,
@@ -564,7 +566,7 @@ responses_stream = [
Summary(text="still more reasoning", type="summary_text"),
],
type="reasoning",
encrypted_content=None,
encrypted_content="encrypted-content",
status=None,
),
ResponseOutputMessage(
@@ -621,8 +623,104 @@ def _strip_none(obj: Any) -> Any:
return obj
def test_responses_stream() -> None:
llm = ChatOpenAI(model="o4-mini", output_version="responses/v1")
@pytest.mark.parametrize(
("output_version", "expected_content"),
[
(
"responses/v1",
[
{
"id": "rs_123",
"summary": [
{
"index": 0,
"type": "summary_text",
"text": "reasoning block one",
},
{
"index": 1,
"type": "summary_text",
"text": "another reasoning block",
},
],
"type": "reasoning",
"index": 0,
},
{"type": "text", "text": "text block one", "index": 1, "id": "msg_123"},
{
"type": "text",
"text": "another text block",
"index": 2,
"id": "msg_123",
},
{
"id": "rs_234",
"summary": [
{"index": 0, "type": "summary_text", "text": "more reasoning"},
{
"index": 1,
"type": "summary_text",
"text": "still more reasoning",
},
],
"encrypted_content": "encrypted-content",
"type": "reasoning",
"index": 3,
},
{"type": "text", "text": "more", "index": 4, "id": "msg_234"},
{"type": "text", "text": "text", "index": 5, "id": "msg_234"},
],
),
(
"v1",
[
{
"type": "reasoning",
"reasoning": "reasoning block one",
"id": "rs_123",
"index": "lc_rs_305f30",
},
{
"type": "reasoning",
"reasoning": "another reasoning block",
"id": "rs_123",
"index": "lc_rs_305f31",
},
{
"type": "text",
"text": "text block one",
"index": "lc_txt_1",
"id": "msg_123",
},
{
"type": "text",
"text": "another text block",
"index": "lc_txt_2",
"id": "msg_123",
},
{
"type": "reasoning",
"reasoning": "more reasoning",
"id": "rs_234",
"extras": {"encrypted_content": "encrypted-content"},
"index": "lc_rs_335f30",
},
{
"type": "reasoning",
"reasoning": "still more reasoning",
"id": "rs_234",
"index": "lc_rs_335f31",
},
{"type": "text", "text": "more", "index": "lc_txt_4", "id": "msg_234"},
{"type": "text", "text": "text", "index": "lc_txt_5", "id": "msg_234"},
],
),
],
)
def test_responses_stream(output_version: str, expected_content: list[dict]) -> None:
llm = ChatOpenAI(
model="o4-mini", use_responses_api=True, output_version=output_version
)
mock_client = MagicMock()
def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:
@@ -631,36 +729,14 @@ def test_responses_stream() -> None:
mock_client.responses.create = mock_create
full: Optional[BaseMessageChunk] = None
chunks = []
with patch.object(llm, "root_client", mock_client):
for chunk in llm.stream("test"):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
chunks.append(chunk)
assert isinstance(full, AIMessageChunk)
expected_content = [
{
"id": "rs_123",
"summary": [
{"index": 0, "type": "summary_text", "text": "reasoning block one"},
{"index": 1, "type": "summary_text", "text": "another reasoning block"},
],
"type": "reasoning",
"index": 0,
},
{"type": "text", "text": "text block one", "index": 1, "id": "msg_123"},
{"type": "text", "text": "another text block", "index": 2, "id": "msg_123"},
{
"id": "rs_234",
"summary": [
{"index": 0, "type": "summary_text", "text": "more reasoning"},
{"index": 1, "type": "summary_text", "text": "still more reasoning"},
],
"type": "reasoning",
"index": 3,
},
{"type": "text", "text": "more", "index": 4, "id": "msg_234"},
{"type": "text", "text": "text", "index": 5, "id": "msg_234"},
]
assert full.content == expected_content
assert full.additional_kwargs == {}
assert full.id == "resp_123"