openai[patch]: support structured output via Responses API (#30265)

Also runs all standard tests using Responses API.
This commit is contained in:
ccurme
2025-03-14 15:14:23 -04:00
committed by GitHub
parent f54f14b747
commit c74e7b997d
7 changed files with 308 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
"""Test Responses API usage."""
import json
import os
from typing import Any, Optional, cast
@@ -10,9 +11,13 @@ from langchain_core.messages import (
BaseMessage,
BaseMessageChunk,
)
from pydantic import BaseModel
from typing_extensions import TypedDict
from langchain_openai import ChatOpenAI
MODEL_NAME = "gpt-4o-mini"
def _check_response(response: Optional[BaseMessage]) -> None:
assert isinstance(response, AIMessage)
@@ -48,7 +53,7 @@ def _check_response(response: Optional[BaseMessage]) -> None:
def test_web_search() -> None:
llm = ChatOpenAI(model="gpt-4o-mini")
llm = ChatOpenAI(model=MODEL_NAME)
first_response = llm.invoke(
"What was a positive news story from today?",
tools=[{"type": "web_search_preview"}],
@@ -94,7 +99,7 @@ def test_web_search() -> None:
async def test_web_search_async() -> None:
llm = ChatOpenAI(model="gpt-4o-mini")
llm = ChatOpenAI(model=MODEL_NAME)
response = await llm.ainvoke(
"What was a positive news story from today?",
tools=[{"type": "web_search_preview"}],
@@ -119,7 +124,7 @@ def test_function_calling() -> None:
"""return x * y"""
return x * y
llm = ChatOpenAI(model="gpt-4o-mini")
llm = ChatOpenAI(model=MODEL_NAME)
bound_llm = llm.bind_tools([multiply, {"type": "web_search_preview"}])
ai_msg = cast(AIMessage, bound_llm.invoke("whats 5 * 4"))
assert len(ai_msg.tool_calls) == 1
@@ -138,8 +143,110 @@ def test_function_calling() -> None:
_check_response(response)
class Foo(BaseModel):
response: str
class FooDict(TypedDict):
response: str
def test_parsed_pydantic_schema() -> None:
llm = ChatOpenAI(model=MODEL_NAME, use_responses_api=True)
response = llm.invoke("how are ya", response_format=Foo)
parsed = Foo(**json.loads(response.text()))
assert parsed == response.additional_kwargs["parsed"]
assert parsed.response
# Test stream
full: Optional[BaseMessageChunk] = None
for chunk in llm.stream("how are ya", response_format=Foo):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
parsed = Foo(**json.loads(full.text()))
assert parsed == full.additional_kwargs["parsed"]
assert parsed.response
async def test_parsed_pydantic_schema_async() -> None:
llm = ChatOpenAI(model=MODEL_NAME, use_responses_api=True)
response = await llm.ainvoke("how are ya", response_format=Foo)
parsed = Foo(**json.loads(response.text()))
assert parsed == response.additional_kwargs["parsed"]
assert parsed.response
# Test stream
full: Optional[BaseMessageChunk] = None
async for chunk in llm.astream("how are ya", response_format=Foo):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
parsed = Foo(**json.loads(full.text()))
assert parsed == full.additional_kwargs["parsed"]
assert parsed.response
@pytest.mark.parametrize("schema", [Foo.model_json_schema(), FooDict])
def test_parsed_dict_schema(schema: Any) -> None:
llm = ChatOpenAI(model=MODEL_NAME, use_responses_api=True)
response = llm.invoke("how are ya", response_format=schema)
parsed = json.loads(response.text())
assert parsed == response.additional_kwargs["parsed"]
assert parsed["response"] and isinstance(parsed["response"], str)
# Test stream
full: Optional[BaseMessageChunk] = None
for chunk in llm.stream("how are ya", response_format=schema):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
parsed = json.loads(full.text())
assert parsed == full.additional_kwargs["parsed"]
assert parsed["response"] and isinstance(parsed["response"], str)
@pytest.mark.parametrize("schema", [Foo.model_json_schema(), FooDict])
async def test_parsed_dict_schema_async(schema: Any) -> None:
llm = ChatOpenAI(model=MODEL_NAME, use_responses_api=True)
response = await llm.ainvoke("how are ya", response_format=schema)
parsed = json.loads(response.text())
assert parsed == response.additional_kwargs["parsed"]
assert parsed["response"] and isinstance(parsed["response"], str)
# Test stream
full: Optional[BaseMessageChunk] = None
async for chunk in llm.astream("how are ya", response_format=schema):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
parsed = json.loads(full.text())
assert parsed == full.additional_kwargs["parsed"]
assert parsed["response"] and isinstance(parsed["response"], str)
def test_function_calling_and_structured_output() -> None:
def multiply(x: int, y: int) -> int:
"""return x * y"""
return x * y
llm = ChatOpenAI(model=MODEL_NAME)
bound_llm = llm.bind_tools([multiply], response_format=Foo, strict=True)
# Test structured output
response = llm.invoke("how are ya", response_format=Foo)
parsed = Foo(**json.loads(response.text()))
assert parsed == response.additional_kwargs["parsed"]
assert parsed.response
# Test function calling
ai_msg = cast(AIMessage, bound_llm.invoke("whats 5 * 4"))
assert len(ai_msg.tool_calls) == 1
assert ai_msg.tool_calls[0]["name"] == "multiply"
assert set(ai_msg.tool_calls[0]["args"]) == {"x", "y"}
def test_stateful_api() -> None:
llm = ChatOpenAI(model="gpt-4o-mini", use_responses_api=True)
llm = ChatOpenAI(model=MODEL_NAME, use_responses_api=True)
response = llm.invoke("how are you, my name is Bobo")
assert "id" in response.response_metadata
@@ -152,7 +259,7 @@ def test_stateful_api() -> None:
def test_file_search() -> None:
pytest.skip() # TODO: set up infra
llm = ChatOpenAI(model="gpt-4o-mini")
llm = ChatOpenAI(model=MODEL_NAME)
tool = {
"type": "file_search",
"vector_store_ids": [os.environ["OPENAI_VECTOR_STORE_ID"]],

View File

@@ -0,0 +1,23 @@
"""Standard LangChain interface tests for Responses API"""
from typing import Type
import pytest
from langchain_core.language_models import BaseChatModel
from langchain_openai import ChatOpenAI
from tests.integration_tests.chat_models.test_base_standard import TestOpenAIStandard
class TestOpenAIResponses(TestOpenAIStandard):
@property
def chat_model_class(self) -> Type[BaseChatModel]:
return ChatOpenAI
@property
def chat_model_params(self) -> dict:
return {"model": "gpt-4o-mini", "use_responses_api": True}
@pytest.mark.xfail(reason="Unsupported.")
def test_stop_sequence(self, model: BaseChatModel) -> None:
super().test_stop_sequence(model)

View File

@@ -0,0 +1,31 @@
# serializer version: 1
# name: TestOpenAIResponses.test_serdes[serialized]
dict({
'id': list([
'langchain',
'chat_models',
'openai',
'ChatOpenAI',
]),
'kwargs': dict({
'max_retries': 2,
'max_tokens': 100,
'model_name': 'gpt-3.5-turbo',
'openai_api_key': dict({
'id': list([
'OPENAI_API_KEY',
]),
'lc': 1,
'type': 'secret',
}),
'request_timeout': 60.0,
'stop': list([
]),
'temperature': 0.0,
'use_responses_api': True,
}),
'lc': 1,
'name': 'ChatOpenAI',
'type': 'constructor',
})
# ---

View File

@@ -1569,23 +1569,6 @@ def test__construct_responses_api_input_ai_message_with_tool_calls_and_content()
assert result[1]["id"] == "func_456"
def test__construct_responses_api_input_missing_function_call_ids() -> None:
"""Test AI messages with tool calls but missing function call IDs raise an error."""
tool_calls = [
{
"id": "call_123",
"name": "get_weather",
"args": {"location": "San Francisco"},
"type": "tool_call",
}
]
ai_message = AIMessage(content="", tool_calls=tool_calls)
with pytest.raises(ValueError):
_construct_responses_api_input([ai_message])
def test__construct_responses_api_input_tool_message_conversion() -> None:
"""Test that tool messages are properly converted to function_call_output."""
messages = [

View File

@@ -0,0 +1,36 @@
"""Standard LangChain interface tests"""
from typing import Tuple, Type
from langchain_core.language_models import BaseChatModel
from langchain_tests.unit_tests import ChatModelUnitTests
from langchain_openai import ChatOpenAI
class TestOpenAIResponses(ChatModelUnitTests):
@property
def chat_model_class(self) -> Type[BaseChatModel]:
return ChatOpenAI
@property
def chat_model_params(self) -> dict:
return {"use_responses_api": True}
@property
def init_from_env_params(self) -> Tuple[dict, dict, dict]:
return (
{
"OPENAI_API_KEY": "api_key",
"OPENAI_ORG_ID": "org_id",
"OPENAI_API_BASE": "api_base",
"OPENAI_PROXY": "https://proxy.com",
},
{},
{
"openai_api_key": "api_key",
"openai_organization": "org_id",
"openai_api_base": "api_base",
"openai_proxy": "https://proxy.com",
},
)