mirror of
https://github.com/hwchase17/langchain.git
synced 2025-07-03 11:47:49 +00:00
openai[patch]: allow specification of output format for Responses API (#31686)
This commit is contained in:
parent
59c2b81627
commit
88d5f3edcc
@ -113,6 +113,7 @@ def test_configurable() -> None:
|
|||||||
"openai_api_base": None,
|
"openai_api_base": None,
|
||||||
"openai_organization": None,
|
"openai_organization": None,
|
||||||
"openai_proxy": None,
|
"openai_proxy": None,
|
||||||
|
"output_version": "v0",
|
||||||
"request_timeout": None,
|
"request_timeout": None,
|
||||||
"max_retries": None,
|
"max_retries": None,
|
||||||
"presence_penalty": None,
|
"presence_penalty": None,
|
||||||
|
@ -128,6 +128,8 @@ def _convert_to_v03_ai_message(
|
|||||||
else:
|
else:
|
||||||
new_content.append(block)
|
new_content.append(block)
|
||||||
message.content = new_content
|
message.content = new_content
|
||||||
|
if isinstance(message.id, str) and message.id.startswith("resp_"):
|
||||||
|
message.id = None
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -137,13 +139,29 @@ def _convert_to_v03_ai_message(
|
|||||||
def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage:
|
def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage:
|
||||||
"""Convert an old-style v0.3 AIMessage into the new content-block format."""
|
"""Convert an old-style v0.3 AIMessage into the new content-block format."""
|
||||||
# Only update ChatOpenAI v0.3 AIMessages
|
# Only update ChatOpenAI v0.3 AIMessages
|
||||||
if not (
|
# TODO: structure provenance into AIMessage
|
||||||
|
is_chatopenai_v03 = (
|
||||||
isinstance(message.content, list)
|
isinstance(message.content, list)
|
||||||
and all(isinstance(b, dict) for b in message.content)
|
and all(isinstance(b, dict) for b in message.content)
|
||||||
) or not any(
|
) and (
|
||||||
|
any(
|
||||||
item in message.additional_kwargs
|
item in message.additional_kwargs
|
||||||
for item in ["reasoning", "tool_outputs", "refusal", _FUNCTION_CALL_IDS_MAP_KEY]
|
for item in [
|
||||||
):
|
"reasoning",
|
||||||
|
"tool_outputs",
|
||||||
|
"refusal",
|
||||||
|
_FUNCTION_CALL_IDS_MAP_KEY,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
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
|
return message
|
||||||
|
|
||||||
content_order = [
|
content_order = [
|
||||||
|
@ -649,6 +649,25 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
.. versionadded:: 0.3.9
|
.. versionadded:: 0.3.9
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
output_version: Literal["v0", "responses/v1"] = "v0"
|
||||||
|
"""Version of AIMessage output format to use.
|
||||||
|
|
||||||
|
This field is used to roll-out new output formats for chat model AIMessages
|
||||||
|
in a backwards-compatible way.
|
||||||
|
|
||||||
|
Supported values:
|
||||||
|
|
||||||
|
- ``"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.
|
||||||
|
|
||||||
|
.. versionadded:: 0.3.25
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@ -903,6 +922,7 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
schema=original_schema_obj,
|
schema=original_schema_obj,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
has_reasoning=has_reasoning,
|
has_reasoning=has_reasoning,
|
||||||
|
output_version=self.output_version,
|
||||||
)
|
)
|
||||||
if generation_chunk:
|
if generation_chunk:
|
||||||
if run_manager:
|
if run_manager:
|
||||||
@ -957,6 +977,7 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
schema=original_schema_obj,
|
schema=original_schema_obj,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
has_reasoning=has_reasoning,
|
has_reasoning=has_reasoning,
|
||||||
|
output_version=self.output_version,
|
||||||
)
|
)
|
||||||
if generation_chunk:
|
if generation_chunk:
|
||||||
if run_manager:
|
if run_manager:
|
||||||
@ -1096,7 +1117,10 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
else:
|
else:
|
||||||
response = self.root_client.responses.create(**payload)
|
response = self.root_client.responses.create(**payload)
|
||||||
return _construct_lc_result_from_responses_api(
|
return _construct_lc_result_from_responses_api(
|
||||||
response, schema=original_schema_obj, metadata=generation_info
|
response,
|
||||||
|
schema=original_schema_obj,
|
||||||
|
metadata=generation_info,
|
||||||
|
output_version=self.output_version,
|
||||||
)
|
)
|
||||||
elif self.include_response_headers:
|
elif self.include_response_headers:
|
||||||
raw_response = self.client.with_raw_response.create(**payload)
|
raw_response = self.client.with_raw_response.create(**payload)
|
||||||
@ -1109,6 +1133,8 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
def _use_responses_api(self, payload: dict) -> bool:
|
def _use_responses_api(self, payload: dict) -> bool:
|
||||||
if isinstance(self.use_responses_api, bool):
|
if isinstance(self.use_responses_api, bool):
|
||||||
return self.use_responses_api
|
return self.use_responses_api
|
||||||
|
elif self.output_version == "responses/v1":
|
||||||
|
return True
|
||||||
elif self.include is not None:
|
elif self.include is not None:
|
||||||
return True
|
return True
|
||||||
elif self.reasoning is not None:
|
elif self.reasoning is not None:
|
||||||
@ -1327,7 +1353,10 @@ class BaseChatOpenAI(BaseChatModel):
|
|||||||
else:
|
else:
|
||||||
response = await self.root_async_client.responses.create(**payload)
|
response = await self.root_async_client.responses.create(**payload)
|
||||||
return _construct_lc_result_from_responses_api(
|
return _construct_lc_result_from_responses_api(
|
||||||
response, schema=original_schema_obj, metadata=generation_info
|
response,
|
||||||
|
schema=original_schema_obj,
|
||||||
|
metadata=generation_info,
|
||||||
|
output_version=self.output_version,
|
||||||
)
|
)
|
||||||
elif self.include_response_headers:
|
elif self.include_response_headers:
|
||||||
raw_response = await self.async_client.with_raw_response.create(**payload)
|
raw_response = await self.async_client.with_raw_response.create(**payload)
|
||||||
@ -3540,6 +3569,7 @@ def _construct_lc_result_from_responses_api(
|
|||||||
response: Response,
|
response: Response,
|
||||||
schema: Optional[type[_BM]] = None,
|
schema: Optional[type[_BM]] = None,
|
||||||
metadata: Optional[dict] = None,
|
metadata: Optional[dict] = None,
|
||||||
|
output_version: Literal["v0", "responses/v1"] = "v0",
|
||||||
) -> ChatResult:
|
) -> ChatResult:
|
||||||
"""Construct ChatResponse from OpenAI Response API response."""
|
"""Construct ChatResponse from OpenAI Response API response."""
|
||||||
if response.error:
|
if response.error:
|
||||||
@ -3676,7 +3706,10 @@ def _construct_lc_result_from_responses_api(
|
|||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
invalid_tool_calls=invalid_tool_calls,
|
invalid_tool_calls=invalid_tool_calls,
|
||||||
)
|
)
|
||||||
|
if output_version == "v0":
|
||||||
message = _convert_to_v03_ai_message(message)
|
message = _convert_to_v03_ai_message(message)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||||
|
|
||||||
|
|
||||||
@ -3688,6 +3721,7 @@ def _convert_responses_chunk_to_generation_chunk(
|
|||||||
schema: Optional[type[_BM]] = None,
|
schema: Optional[type[_BM]] = None,
|
||||||
metadata: Optional[dict] = None,
|
metadata: Optional[dict] = None,
|
||||||
has_reasoning: bool = False,
|
has_reasoning: bool = False,
|
||||||
|
output_version: Literal["v0", "responses/v1"] = "v0",
|
||||||
) -> tuple[int, int, int, Optional[ChatGenerationChunk]]:
|
) -> tuple[int, int, int, Optional[ChatGenerationChunk]]:
|
||||||
def _advance(output_idx: int, sub_idx: Optional[int] = None) -> None:
|
def _advance(output_idx: int, sub_idx: Optional[int] = None) -> None:
|
||||||
"""Advance indexes tracked during streaming.
|
"""Advance indexes tracked during streaming.
|
||||||
@ -3756,12 +3790,15 @@ def _convert_responses_chunk_to_generation_chunk(
|
|||||||
elif chunk.type == "response.output_text.done":
|
elif chunk.type == "response.output_text.done":
|
||||||
content.append({"id": chunk.item_id, "index": current_index})
|
content.append({"id": chunk.item_id, "index": current_index})
|
||||||
elif chunk.type == "response.created":
|
elif chunk.type == "response.created":
|
||||||
response_metadata["id"] = chunk.response.id
|
id = chunk.response.id
|
||||||
|
response_metadata["id"] = chunk.response.id # Backwards compatibility
|
||||||
elif chunk.type == "response.completed":
|
elif chunk.type == "response.completed":
|
||||||
msg = cast(
|
msg = cast(
|
||||||
AIMessage,
|
AIMessage,
|
||||||
(
|
(
|
||||||
_construct_lc_result_from_responses_api(chunk.response, schema=schema)
|
_construct_lc_result_from_responses_api(
|
||||||
|
chunk.response, schema=schema, output_version=output_version
|
||||||
|
)
|
||||||
.generations[0]
|
.generations[0]
|
||||||
.message
|
.message
|
||||||
),
|
),
|
||||||
@ -3773,7 +3810,10 @@ def _convert_responses_chunk_to_generation_chunk(
|
|||||||
k: v for k, v in msg.response_metadata.items() if k != "id"
|
k: v for k, v in msg.response_metadata.items() if k != "id"
|
||||||
}
|
}
|
||||||
elif chunk.type == "response.output_item.added" and chunk.item.type == "message":
|
elif chunk.type == "response.output_item.added" and chunk.item.type == "message":
|
||||||
|
if output_version == "v0":
|
||||||
id = chunk.item.id
|
id = chunk.item.id
|
||||||
|
else:
|
||||||
|
pass
|
||||||
elif (
|
elif (
|
||||||
chunk.type == "response.output_item.added"
|
chunk.type == "response.output_item.added"
|
||||||
and chunk.item.type == "function_call"
|
and chunk.item.type == "function_call"
|
||||||
@ -3868,9 +3908,13 @@ def _convert_responses_chunk_to_generation_chunk(
|
|||||||
additional_kwargs=additional_kwargs,
|
additional_kwargs=additional_kwargs,
|
||||||
id=id,
|
id=id,
|
||||||
)
|
)
|
||||||
|
if output_version == "v0":
|
||||||
message = cast(
|
message = cast(
|
||||||
AIMessageChunk, _convert_to_v03_ai_message(message, has_reasoning=has_reasoning)
|
AIMessageChunk,
|
||||||
|
_convert_to_v03_ai_message(message, has_reasoning=has_reasoning),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
return (
|
return (
|
||||||
current_index,
|
current_index,
|
||||||
current_output_index,
|
current_output_index,
|
||||||
|
Binary file not shown.
BIN
libs/partners/openai/tests/cassettes/test_reasoning.yaml.gz
Normal file
BIN
libs/partners/openai/tests/cassettes/test_reasoning.yaml.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Annotated, Any, Optional, cast
|
from typing import Annotated, Any, Literal, Optional, cast
|
||||||
|
|
||||||
import openai
|
import openai
|
||||||
import pytest
|
import pytest
|
||||||
@ -50,15 +50,11 @@ def _check_response(response: Optional[BaseMessage]) -> None:
|
|||||||
assert response.usage_metadata["total_tokens"] > 0
|
assert response.usage_metadata["total_tokens"] > 0
|
||||||
assert response.response_metadata["model_name"]
|
assert response.response_metadata["model_name"]
|
||||||
assert response.response_metadata["service_tier"]
|
assert response.response_metadata["service_tier"]
|
||||||
for tool_output in response.additional_kwargs["tool_outputs"]:
|
|
||||||
assert tool_output["id"]
|
|
||||||
assert tool_output["status"]
|
|
||||||
assert tool_output["type"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.vcr
|
@pytest.mark.vcr
|
||||||
def test_web_search() -> None:
|
def test_web_search() -> None:
|
||||||
llm = ChatOpenAI(model=MODEL_NAME)
|
llm = ChatOpenAI(model=MODEL_NAME, output_version="responses/v1")
|
||||||
first_response = llm.invoke(
|
first_response = llm.invoke(
|
||||||
"What was a positive news story from today?",
|
"What was a positive news story from today?",
|
||||||
tools=[{"type": "web_search_preview"}],
|
tools=[{"type": "web_search_preview"}],
|
||||||
@ -111,6 +107,11 @@ def test_web_search() -> None:
|
|||||||
)
|
)
|
||||||
_check_response(response)
|
_check_response(response)
|
||||||
|
|
||||||
|
for msg in [first_response, full, response]:
|
||||||
|
assert isinstance(msg, AIMessage)
|
||||||
|
block_types = [block["type"] for block in msg.content] # type: ignore[index]
|
||||||
|
assert block_types == ["web_search_call", "text"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.flaky(retries=3, delay=1)
|
@pytest.mark.flaky(retries=3, delay=1)
|
||||||
async def test_web_search_async() -> None:
|
async def test_web_search_async() -> None:
|
||||||
@ -133,6 +134,12 @@ async def test_web_search_async() -> None:
|
|||||||
assert isinstance(full, AIMessageChunk)
|
assert isinstance(full, AIMessageChunk)
|
||||||
_check_response(full)
|
_check_response(full)
|
||||||
|
|
||||||
|
for msg in [response, full]:
|
||||||
|
assert msg.additional_kwargs["tool_outputs"]
|
||||||
|
assert len(msg.additional_kwargs["tool_outputs"]) == 1
|
||||||
|
tool_output = msg.additional_kwargs["tool_outputs"][0]
|
||||||
|
assert tool_output["type"] == "web_search_call"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.flaky(retries=3, delay=1)
|
@pytest.mark.flaky(retries=3, delay=1)
|
||||||
def test_function_calling() -> None:
|
def test_function_calling() -> None:
|
||||||
@ -288,20 +295,32 @@ def test_function_calling_and_structured_output() -> None:
|
|||||||
assert set(ai_msg.tool_calls[0]["args"]) == {"x", "y"}
|
assert set(ai_msg.tool_calls[0]["args"]) == {"x", "y"}
|
||||||
|
|
||||||
|
|
||||||
def test_reasoning() -> None:
|
@pytest.mark.default_cassette("test_reasoning.yaml.gz")
|
||||||
llm = ChatOpenAI(model="o3-mini", use_responses_api=True)
|
@pytest.mark.vcr
|
||||||
|
@pytest.mark.parametrize("output_version", ["v0", "responses/v1"])
|
||||||
|
def test_reasoning(output_version: Literal["v0", "responses/v1"]) -> None:
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
model="o4-mini", use_responses_api=True, output_version=output_version
|
||||||
|
)
|
||||||
response = llm.invoke("Hello", reasoning={"effort": "low"})
|
response = llm.invoke("Hello", reasoning={"effort": "low"})
|
||||||
assert isinstance(response, AIMessage)
|
assert isinstance(response, AIMessage)
|
||||||
assert response.additional_kwargs["reasoning"]
|
|
||||||
|
|
||||||
# Test init params + streaming
|
# Test init params + streaming
|
||||||
llm = ChatOpenAI(model="o3-mini", reasoning_effort="low", use_responses_api=True)
|
llm = ChatOpenAI(
|
||||||
|
model="o4-mini", reasoning={"effort": "low"}, output_version=output_version
|
||||||
|
)
|
||||||
full: Optional[BaseMessageChunk] = None
|
full: Optional[BaseMessageChunk] = None
|
||||||
for chunk in llm.stream("Hello"):
|
for chunk in llm.stream("Hello"):
|
||||||
assert isinstance(chunk, AIMessageChunk)
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
full = chunk if full is None else full + chunk
|
full = chunk if full is None else full + chunk
|
||||||
assert isinstance(full, AIMessage)
|
assert isinstance(full, AIMessage)
|
||||||
assert full.additional_kwargs["reasoning"]
|
|
||||||
|
for msg in [response, full]:
|
||||||
|
if output_version == "v0":
|
||||||
|
assert msg.additional_kwargs["reasoning"]
|
||||||
|
else:
|
||||||
|
block_types = [block["type"] for block in msg.content]
|
||||||
|
assert block_types == ["reasoning", "text"]
|
||||||
|
|
||||||
|
|
||||||
def test_stateful_api() -> None:
|
def test_stateful_api() -> None:
|
||||||
@ -355,20 +374,37 @@ def test_file_search() -> None:
|
|||||||
_check_response(full)
|
_check_response(full)
|
||||||
|
|
||||||
|
|
||||||
def test_stream_reasoning_summary() -> None:
|
@pytest.mark.default_cassette("test_stream_reasoning_summary.yaml.gz")
|
||||||
|
@pytest.mark.vcr
|
||||||
|
@pytest.mark.parametrize("output_version", ["v0", "responses/v1"])
|
||||||
|
def test_stream_reasoning_summary(
|
||||||
|
output_version: Literal["v0", "responses/v1"],
|
||||||
|
) -> None:
|
||||||
llm = ChatOpenAI(
|
llm = ChatOpenAI(
|
||||||
model="o4-mini",
|
model="o4-mini",
|
||||||
# Routes to Responses API if `reasoning` is set.
|
# Routes to Responses API if `reasoning` is set.
|
||||||
reasoning={"effort": "medium", "summary": "auto"},
|
reasoning={"effort": "medium", "summary": "auto"},
|
||||||
|
output_version=output_version,
|
||||||
)
|
)
|
||||||
message_1 = {"role": "user", "content": "What is 3^3?"}
|
message_1 = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "What was the third tallest buliding in the year 2000?",
|
||||||
|
}
|
||||||
response_1: Optional[BaseMessageChunk] = None
|
response_1: Optional[BaseMessageChunk] = None
|
||||||
for chunk in llm.stream([message_1]):
|
for chunk in llm.stream([message_1]):
|
||||||
assert isinstance(chunk, AIMessageChunk)
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
response_1 = chunk if response_1 is None else response_1 + chunk
|
response_1 = chunk if response_1 is None else response_1 + chunk
|
||||||
assert isinstance(response_1, AIMessageChunk)
|
assert isinstance(response_1, AIMessageChunk)
|
||||||
|
if output_version == "v0":
|
||||||
reasoning = response_1.additional_kwargs["reasoning"]
|
reasoning = response_1.additional_kwargs["reasoning"]
|
||||||
assert set(reasoning.keys()) == {"id", "type", "summary"}
|
assert set(reasoning.keys()) == {"id", "type", "summary"}
|
||||||
|
else:
|
||||||
|
reasoning = next(
|
||||||
|
block
|
||||||
|
for block in response_1.content
|
||||||
|
if block["type"] == "reasoning" # type: ignore[index]
|
||||||
|
)
|
||||||
|
assert set(reasoning.keys()) == {"id", "type", "summary", "index"}
|
||||||
summary = reasoning["summary"]
|
summary = reasoning["summary"]
|
||||||
assert isinstance(summary, list)
|
assert isinstance(summary, list)
|
||||||
for block in summary:
|
for block in summary:
|
||||||
@ -462,11 +498,11 @@ def test_mcp_builtin() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip
|
@pytest.mark.vcr
|
||||||
def test_mcp_builtin_zdr() -> None:
|
def test_mcp_builtin_zdr() -> None:
|
||||||
llm = ChatOpenAI(
|
llm = ChatOpenAI(
|
||||||
model="o4-mini",
|
model="o4-mini",
|
||||||
use_responses_api=True,
|
output_version="responses/v1",
|
||||||
store=False,
|
store=False,
|
||||||
include=["reasoning.encrypted_content"],
|
include=["reasoning.encrypted_content"],
|
||||||
)
|
)
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
}),
|
}),
|
||||||
'openai_api_type': 'azure',
|
'openai_api_type': 'azure',
|
||||||
'openai_api_version': '2021-10-01',
|
'openai_api_version': '2021-10-01',
|
||||||
|
'output_version': 'v0',
|
||||||
'request_timeout': 60.0,
|
'request_timeout': 60.0,
|
||||||
'stop': list([
|
'stop': list([
|
||||||
]),
|
]),
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
'lc': 1,
|
'lc': 1,
|
||||||
'type': 'secret',
|
'type': 'secret',
|
||||||
}),
|
}),
|
||||||
|
'output_version': 'v0',
|
||||||
'request_timeout': 60.0,
|
'request_timeout': 60.0,
|
||||||
'stop': list([
|
'stop': list([
|
||||||
]),
|
]),
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
'lc': 1,
|
'lc': 1,
|
||||||
'type': 'secret',
|
'type': 'secret',
|
||||||
}),
|
}),
|
||||||
|
'output_version': 'v0',
|
||||||
'request_timeout': 60.0,
|
'request_timeout': 60.0,
|
||||||
'stop': list([
|
'stop': list([
|
||||||
]),
|
]),
|
||||||
|
@ -1192,6 +1192,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)
|
||||||
|
|
||||||
assert isinstance(result, ChatResult)
|
assert isinstance(result, ChatResult)
|
||||||
@ -1209,6 +1210,16 @@ def test__construct_lc_result_from_responses_api_basic_text_response() -> None:
|
|||||||
assert result.generations[0].message.response_metadata["id"] == "resp_123"
|
assert result.generations[0].message.response_metadata["id"] == "resp_123"
|
||||||
assert result.generations[0].message.response_metadata["model_name"] == "gpt-4o"
|
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"
|
||||||
|
)
|
||||||
|
assert result.generations[0].message.content == [
|
||||||
|
{"type": "text", "text": "Hello, world!", "annotations": [], "id": "msg_123"}
|
||||||
|
]
|
||||||
|
assert result.generations[0].message.id == "resp_123"
|
||||||
|
assert result.generations[0].message.response_metadata["id"] == "resp_123"
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_multiple_text_blocks() -> None:
|
def test__construct_lc_result_from_responses_api_multiple_text_blocks() -> None:
|
||||||
"""Test a response with multiple text blocks."""
|
"""Test a response with multiple text blocks."""
|
||||||
@ -1284,6 +1295,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)
|
||||||
|
|
||||||
assert result.generations[0].message.content == [
|
assert result.generations[0].message.content == [
|
||||||
@ -1297,6 +1309,23 @@ def test__construct_lc_result_from_responses_api_multiple_messages() -> None:
|
|||||||
"id": "rs_123",
|
"id": "rs_123",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
assert result.generations[0].message.id == "msg_234"
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
result = _construct_lc_result_from_responses_api(
|
||||||
|
response, output_version="responses/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.generations[0].message.content == [
|
||||||
|
{"type": "text", "text": "foo", "annotations": [], "id": "msg_123"},
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": [{"type": "summary_text", "text": "reasoning foo"}],
|
||||||
|
"id": "rs_123",
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "bar", "annotations": [], "id": "msg_234"},
|
||||||
|
]
|
||||||
|
assert result.generations[0].message.id == "resp_123"
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_refusal_response() -> None:
|
def test__construct_lc_result_from_responses_api_refusal_response() -> None:
|
||||||
@ -1324,12 +1353,25 @@ 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)
|
||||||
|
|
||||||
assert result.generations[0].message.additional_kwargs["refusal"] == (
|
assert result.generations[0].message.additional_kwargs["refusal"] == (
|
||||||
"I cannot assist with that request."
|
"I cannot assist with that request."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
result = _construct_lc_result_from_responses_api(
|
||||||
|
response, output_version="responses/v1"
|
||||||
|
)
|
||||||
|
assert result.generations[0].message.content == [
|
||||||
|
{
|
||||||
|
"type": "refusal",
|
||||||
|
"refusal": "I cannot assist with that request.",
|
||||||
|
"id": "msg_123",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_function_call_valid_json() -> None:
|
def test__construct_lc_result_from_responses_api_function_call_valid_json() -> None:
|
||||||
"""Test a response with a valid function call."""
|
"""Test a response with a valid function call."""
|
||||||
@ -1352,6 +1394,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)
|
||||||
|
|
||||||
msg: AIMessage = cast(AIMessage, result.generations[0].message)
|
msg: AIMessage = cast(AIMessage, result.generations[0].message)
|
||||||
@ -1368,6 +1411,22 @@ def test__construct_lc_result_from_responses_api_function_call_valid_json() -> N
|
|||||||
== "func_123"
|
== "func_123"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
result = _construct_lc_result_from_responses_api(
|
||||||
|
response, output_version="responses/v1"
|
||||||
|
)
|
||||||
|
msg = cast(AIMessage, result.generations[0].message)
|
||||||
|
assert msg.tool_calls
|
||||||
|
assert msg.content == [
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"id": "func_123",
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": '{"location": "New York", "unit": "celsius"}',
|
||||||
|
"call_id": "call_123",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_function_call_invalid_json() -> None:
|
def test__construct_lc_result_from_responses_api_function_call_invalid_json() -> None:
|
||||||
"""Test a response with an invalid JSON function call."""
|
"""Test a response with an invalid JSON function call."""
|
||||||
@ -1444,6 +1503,7 @@ def test__construct_lc_result_from_responses_api_complex_response() -> None:
|
|||||||
user="user_123",
|
user="user_123",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# v0
|
||||||
result = _construct_lc_result_from_responses_api(response)
|
result = _construct_lc_result_from_responses_api(response)
|
||||||
|
|
||||||
# Check message content
|
# Check message content
|
||||||
@ -1472,6 +1532,28 @@ def test__construct_lc_result_from_responses_api_complex_response() -> None:
|
|||||||
assert result.generations[0].message.response_metadata["status"] == "completed"
|
assert result.generations[0].message.response_metadata["status"] == "completed"
|
||||||
assert result.generations[0].message.response_metadata["user"] == "user_123"
|
assert result.generations[0].message.response_metadata["user"] == "user_123"
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
result = _construct_lc_result_from_responses_api(
|
||||||
|
response, output_version="responses/v1"
|
||||||
|
)
|
||||||
|
msg = cast(AIMessage, result.generations[0].message)
|
||||||
|
assert msg.response_metadata["metadata"] == {"key1": "value1", "key2": "value2"}
|
||||||
|
assert msg.content == [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Here's the information you requested:",
|
||||||
|
"annotations": [],
|
||||||
|
"id": "msg_123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"id": "func_123",
|
||||||
|
"call_id": "call_123",
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": '{"location": "New York"}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_no_usage_metadata() -> None:
|
def test__construct_lc_result_from_responses_api_no_usage_metadata() -> None:
|
||||||
"""Test a response without usage metadata."""
|
"""Test a response without usage metadata."""
|
||||||
@ -1525,6 +1607,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)
|
||||||
|
|
||||||
assert "tool_outputs" in result.generations[0].message.additional_kwargs
|
assert "tool_outputs" in result.generations[0].message.additional_kwargs
|
||||||
@ -1542,6 +1625,14 @@ def test__construct_lc_result_from_responses_api_web_search_response() -> None:
|
|||||||
== "completed"
|
== "completed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
result = _construct_lc_result_from_responses_api(
|
||||||
|
response, output_version="responses/v1"
|
||||||
|
)
|
||||||
|
assert result.generations[0].message.content == [
|
||||||
|
{"type": "web_search_call", "id": "websearch_123", "status": "completed"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_file_search_response() -> None:
|
def test__construct_lc_result_from_responses_api_file_search_response() -> None:
|
||||||
"""Test a response with file search output."""
|
"""Test a response with file search output."""
|
||||||
@ -1572,6 +1663,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)
|
||||||
|
|
||||||
assert "tool_outputs" in result.generations[0].message.additional_kwargs
|
assert "tool_outputs" in result.generations[0].message.additional_kwargs
|
||||||
@ -1612,6 +1704,28 @@ def test__construct_lc_result_from_responses_api_file_search_response() -> None:
|
|||||||
== 0.95
|
== 0.95
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
result = _construct_lc_result_from_responses_api(
|
||||||
|
response, output_version="responses/v1"
|
||||||
|
)
|
||||||
|
assert result.generations[0].message.content == [
|
||||||
|
{
|
||||||
|
"type": "file_search_call",
|
||||||
|
"id": "filesearch_123",
|
||||||
|
"status": "completed",
|
||||||
|
"queries": ["python code", "langchain"],
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"file_id": "file_123",
|
||||||
|
"filename": "example.py",
|
||||||
|
"score": 0.95,
|
||||||
|
"text": "def hello_world() -> None:\n print('Hello, world!')",
|
||||||
|
"attributes": {"language": "python", "size": 42},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_lc_result_from_responses_api_mixed_search_responses() -> None:
|
def test__construct_lc_result_from_responses_api_mixed_search_responses() -> None:
|
||||||
"""Test a response with both web search and file search outputs."""
|
"""Test a response with both web search and file search outputs."""
|
||||||
@ -1656,6 +1770,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)
|
||||||
|
|
||||||
# Check message content
|
# Check message content
|
||||||
@ -1686,6 +1801,34 @@ def test__construct_lc_result_from_responses_api_mixed_search_responses() -> Non
|
|||||||
assert file_search["queries"] == ["python code"]
|
assert file_search["queries"] == ["python code"]
|
||||||
assert file_search["results"][0]["filename"] == "example.py"
|
assert file_search["results"][0]["filename"] == "example.py"
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
result = _construct_lc_result_from_responses_api(
|
||||||
|
response, output_version="responses/v1"
|
||||||
|
)
|
||||||
|
assert result.generations[0].message.content == [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Here's what I found:",
|
||||||
|
"annotations": [],
|
||||||
|
"id": "msg_123",
|
||||||
|
},
|
||||||
|
{"type": "web_search_call", "id": "websearch_123", "status": "completed"},
|
||||||
|
{
|
||||||
|
"type": "file_search_call",
|
||||||
|
"id": "filesearch_123",
|
||||||
|
"queries": ["python code"],
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"file_id": "file_123",
|
||||||
|
"filename": "example.py",
|
||||||
|
"score": 0.95,
|
||||||
|
"text": "def hello_world() -> None:\n print('Hello, world!')",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "completed",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test__construct_responses_api_input_human_message_with_text_blocks_conversion() -> (
|
def test__construct_responses_api_input_human_message_with_text_blocks_conversion() -> (
|
||||||
None
|
None
|
||||||
@ -1706,7 +1849,29 @@ def test__construct_responses_api_input_human_message_with_text_blocks_conversio
|
|||||||
|
|
||||||
def test__construct_responses_api_input_multiple_message_components() -> None:
|
def test__construct_responses_api_input_multiple_message_components() -> None:
|
||||||
"""Test that human messages with text blocks are properly converted."""
|
"""Test that human messages with text blocks are properly converted."""
|
||||||
messages: list = [
|
# v0
|
||||||
|
messages = [
|
||||||
|
AIMessage(
|
||||||
|
content=[{"type": "text", "text": "foo"}, {"type": "text", "text": "bar"}],
|
||||||
|
id="msg_123",
|
||||||
|
response_metadata={"id": "resp_123"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
result = _construct_responses_api_input(messages)
|
||||||
|
assert result == [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "output_text", "text": "foo", "annotations": []},
|
||||||
|
{"type": "output_text", "text": "bar", "annotations": []},
|
||||||
|
],
|
||||||
|
"id": "msg_123",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# responses/v1
|
||||||
|
messages = [
|
||||||
AIMessage(
|
AIMessage(
|
||||||
content=[
|
content=[
|
||||||
{"type": "text", "text": "foo", "id": "msg_123"},
|
{"type": "text", "text": "foo", "id": "msg_123"},
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from langchain_core.messages import AIMessageChunk, BaseMessageChunk
|
from langchain_core.messages import AIMessageChunk, BaseMessageChunk
|
||||||
from openai.types.responses import (
|
from openai.types.responses import (
|
||||||
ResponseCompletedEvent,
|
ResponseCompletedEvent,
|
||||||
@ -601,9 +600,18 @@ responses_stream = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="Will be fixed with output format flags.")
|
def _strip_none(obj: Any) -> Any:
|
||||||
|
"""Recursively strip None values from dictionaries and lists."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _strip_none(v) for k, v in obj.items() if v is not None}
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [_strip_none(v) for v in obj]
|
||||||
|
else:
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def test_responses_stream() -> None:
|
def test_responses_stream() -> None:
|
||||||
llm = ChatOpenAI(model="o4-mini", use_responses_api=True)
|
llm = ChatOpenAI(model="o4-mini", output_version="responses/v1")
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
|
|
||||||
def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:
|
def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager:
|
||||||
@ -644,3 +652,20 @@ def test_responses_stream() -> None:
|
|||||||
]
|
]
|
||||||
assert full.content == expected_content
|
assert full.content == expected_content
|
||||||
assert full.additional_kwargs == {}
|
assert full.additional_kwargs == {}
|
||||||
|
assert full.id == "resp_123"
|
||||||
|
|
||||||
|
# Test reconstruction
|
||||||
|
payload = llm._get_request_payload([full])
|
||||||
|
completed = [
|
||||||
|
item
|
||||||
|
for item in responses_stream
|
||||||
|
if item.type == "response.completed" # type: ignore[attr-defined]
|
||||||
|
]
|
||||||
|
assert len(completed) == 1
|
||||||
|
response = completed[0].response # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
assert len(response.output) == len(payload["input"])
|
||||||
|
for idx, item in enumerate(response.output):
|
||||||
|
dumped = _strip_none(item.model_dump())
|
||||||
|
_ = dumped.pop("status", None)
|
||||||
|
assert dumped == payload["input"][idx]
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
'max_retries': 2,
|
'max_retries': 2,
|
||||||
'max_tokens': 100,
|
'max_tokens': 100,
|
||||||
'model_name': 'grok-beta',
|
'model_name': 'grok-beta',
|
||||||
|
'output_version': 'v0',
|
||||||
'request_timeout': 60.0,
|
'request_timeout': 60.0,
|
||||||
'stop': list([
|
'stop': list([
|
||||||
]),
|
]),
|
||||||
|
Loading…
Reference in New Issue
Block a user